diff --git a/Cargo.lock b/Cargo.lock index 1fa81d2a94b..bc8b40f21b4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5120,6 +5120,7 @@ version = "4.1.0" dependencies = [ "aes", "cbc", + "hex", "hmac", "secp256k1", "sha2", diff --git a/packages/dashpay-contract/schema/v2/dashpay.schema.json b/packages/dashpay-contract/schema/v2/dashpay.schema.json new file mode 100644 index 00000000000..db5ce9b4dcd --- /dev/null +++ b/packages/dashpay-contract/schema/v2/dashpay.schema.json @@ -0,0 +1,271 @@ +{ + "profile": { + "type": "object", + "indices": [ + { + "name": "ownerId", + "properties": [ + { + "$ownerId": "asc" + } + ], + "unique": true + }, + { + "name": "ownerIdAndUpdatedAt", + "properties": [ + { + "$ownerId": "asc" + }, + { + "$updatedAt": "asc" + } + ] + } + ], + "properties": { + "avatarUrl": { + "type": "string", + "format": "uri", + "minLength": 1, + "maxLength": 2048, + "position": 0 + }, + "avatarHash": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "description": "SHA256 hash of the bytes of the image specified by avatarUrl", + "position": 1 + }, + "avatarFingerprint": { + "type": "array", + "byteArray": true, + "minItems": 8, + "maxItems": 8, + "description": "dHash the image specified by avatarUrl", + "position": 2 + }, + "publicMessage": { + "type": "string", + "minLength": 1, + "maxLength": 140, + "position": 3 + }, + "displayName": { + "type": "string", + "minLength": 1, + "maxLength": 25, + "position": 4 + }, + "corePaymentAddress": { + "type": "array", + "byteArray": true, + "minItems": 21, + "maxItems": 21, + "description": "Core chain address in storage form (type byte 0x00 P2PKH / 0x01 P2SH followed by the 20-byte HASH160, i.e. RIPEMD160 of SHA256, of the public key or redeem script) for public payments. The type byte is consensus-enforced by a data trigger; clients render the address as Base58Check for the network they are on. Payments to it are publicly linkable to this profile.", + "position": 5 + }, + "platformPaymentAddress": { + "type": "array", + "byteArray": true, + "minItems": 21, + "maxItems": 21, + "description": "Platform address in storage form (type byte 0x00 P2PKH / 0x01 P2SH followed by the 20-byte HASH160, i.e. RIPEMD160 of SHA256, of the public key or redeem script) for public payments. The type byte is consensus-enforced by a data trigger.", + "position": 6 + } + }, + "minProperties": 1, + "dependentRequired": { + "avatarUrl": ["avatarHash", "avatarFingerprint"], + "avatarHash": ["avatarUrl", "avatarFingerprint"], + "avatarFingerprint": ["avatarUrl", "avatarHash"] + }, + "required": [ + "$createdAt", + "$updatedAt" + ], + "additionalProperties": false + }, + "contactInfo": { + "type": "object", + "indices": [ + { + "name": "ownerIdAndKeys", + "properties": [ + { + "$ownerId": "asc" + }, + { + "rootEncryptionKeyIndex": "asc" + }, + { + "derivationEncryptionKeyIndex": "asc" + } + ], + "unique": true + }, + { + "name": "ownerIdAndUpdatedAt", + "properties": [ + { + "$ownerId": "asc" + }, + { + "$updatedAt": "asc" + } + ] + } + ], + "properties": { + "encToUserId": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "position": 0 + }, + "rootEncryptionKeyIndex": { + "type": "integer", + "minimum": 0, + "position": 1 + }, + "derivationEncryptionKeyIndex": { + "type": "integer", + "minimum": 0, + "position": 2 + }, + "privateData": { + "type": "array", + "byteArray": true, + "minItems": 48, + "maxItems": 2048, + "position": 3, + "description": "This is the encrypted values of aliasName + note + displayHidden encoded as an array in cbor" + } + }, + "required": [ + "$createdAt", + "$updatedAt", + "encToUserId", + "privateData", + "rootEncryptionKeyIndex", + "derivationEncryptionKeyIndex" + ], + "additionalProperties": false + }, + "contactRequest": { + "documentsMutable": false, + "canBeDeleted": false, + "requiresIdentityEncryptionBoundedKey": 2, + "requiresIdentityDecryptionBoundedKey": 2, + "type": "object", + "indices": [ + { + "name": "ownerIdUserIdAndAccountRef", + "properties": [ + { + "$ownerId": "asc" + }, + { + "toUserId": "asc" + }, + { + "accountReference": "asc" + } + ], + "unique": true + }, + { + "name": "ownerIdUserId", + "properties": [ + { + "$ownerId": "asc" + }, + { + "toUserId": "asc" + } + ] + }, + { + "name": "userIdCreatedAt", + "properties": [ + { + "toUserId": "asc" + }, + { + "$createdAt": "asc" + } + ] + }, + { + "name": "ownerIdCreatedAt", + "properties": [ + { + "$ownerId": "asc" + }, + { + "$createdAt": "asc" + } + ] + } + ], + "properties": { + "toUserId": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "position": 0, + "contentMediaType": "application/x.dash.dpp.identifier" + }, + "encryptedPublicKey": { + "type": "array", + "byteArray": true, + "minItems": 96, + "maxItems": 96, + "position": 1 + }, + "senderKeyIndex": { + "type": "integer", + "minimum": 0, + "position": 2 + }, + "recipientKeyIndex": { + "type": "integer", + "minimum": 0, + "position": 3 + }, + "accountReference": { + "type": "integer", + "minimum": 0, + "position": 4 + }, + "encryptedAccountLabel": { + "type": "array", + "byteArray": true, + "minItems": 48, + "maxItems": 80, + "position": 5 + }, + "autoAcceptProof": { + "type": "array", + "byteArray": true, + "minItems": 38, + "maxItems": 102, + "position": 6 + } + }, + "required": [ + "$createdAt", + "$createdAtCoreBlockHeight", + "toUserId", + "encryptedPublicKey", + "senderKeyIndex", + "recipientKeyIndex", + "accountReference" + ], + "additionalProperties": false + } +} diff --git a/packages/dashpay-contract/src/lib.rs b/packages/dashpay-contract/src/lib.rs index f17916867c2..3132e0acd50 100644 --- a/packages/dashpay-contract/src/lib.rs +++ b/packages/dashpay-contract/src/lib.rs @@ -1,5 +1,6 @@ mod error; pub mod v1; +pub mod v2; pub use crate::error::Error; use platform_value::{Identifier, IdentifierBytes32}; @@ -18,10 +19,10 @@ pub const OWNER_ID: Identifier = Identifier(IdentifierBytes32(OWNER_ID_BYTES)); pub fn load_definitions(platform_version: &PlatformVersion) -> Result, Error> { match platform_version.system_data_contracts.dashpay { - 1 => Ok(None), + 1 | 2 => Ok(None), version => Err(Error::UnknownVersionMismatch { method: "dashpay_contract::load_definitions".to_string(), - known_versions: vec![1], + known_versions: vec![1, 2], received: version, }), } @@ -29,9 +30,10 @@ pub fn load_definitions(platform_version: &PlatformVersion) -> Result Result { match platform_version.system_data_contracts.dashpay { 1 => v1::load_documents_schemas(), + 2 => v2::load_documents_schemas(), version => Err(Error::UnknownVersionMismatch { method: "dashpay_contract::load_documents_schemas".to_string(), - known_versions: vec![1], + known_versions: vec![1, 2], received: version, }), } diff --git a/packages/dashpay-contract/src/v2/mod.rs b/packages/dashpay-contract/src/v2/mod.rs new file mode 100644 index 00000000000..131fecb1769 --- /dev/null +++ b/packages/dashpay-contract/src/v2/mod.rs @@ -0,0 +1,11 @@ +use crate::error::Error; +use serde_json::Value; + +// Document-type name and property constants live in `crate::v1::document_types`; +// v2 does not change any names v1 defined, it only adds the optional +// `corePaymentAddress` / `platformPaymentAddress` properties to `profile`. + +pub fn load_documents_schemas() -> Result { + serde_json::from_str(include_str!("../../schema/v2/dashpay.schema.json")) + .map_err(Error::InvalidSchemaJson) +} diff --git a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/identity/AddIdentityKeyScreen.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/identity/AddIdentityKeyScreen.kt index 5cc95cd7fb5..30b3854ca01 100644 --- a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/identity/AddIdentityKeyScreen.kt +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/identity/AddIdentityKeyScreen.kt @@ -413,6 +413,8 @@ private fun KeyPurpose.displayName(): String = when (this) { KeyPurpose.SYSTEM -> "System" KeyPurpose.VOTING -> "Voting" KeyPurpose.OWNER -> "Owner" + KeyPurpose.PAYMENT_SCAN -> "Payment Scan" + KeyPurpose.PAYMENT_SPEND -> "Payment Spend" } private fun SecurityLevel.displayName(): String = when (this) { diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/identity/IdentityUpdates.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/identity/IdentityUpdates.kt index d9435126d55..4d1e59be8ba 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/identity/IdentityUpdates.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/identity/IdentityUpdates.kt @@ -32,6 +32,8 @@ enum class KeyPurpose(val ffiValue: Int) { SYSTEM(4), VOTING(5), OWNER(6), + PAYMENT_SCAN(7), + PAYMENT_SPEND(8), } /** diff --git a/packages/rs-dpp/src/address_funds/witness_verification_operations.rs b/packages/rs-dpp/src/address_funds/witness_verification_operations.rs index 0cade1aee3c..fea81e87054 100644 --- a/packages/rs-dpp/src/address_funds/witness_verification_operations.rs +++ b/packages/rs-dpp/src/address_funds/witness_verification_operations.rs @@ -20,8 +20,8 @@ pub struct AddressWitnessVerificationOperations { /// Hash160 = RIPEMD160(SHA256(pubkey)) pub pubkey_hash_verifications: u16, - /// Number of script hash (SHA256) verifications performed. - /// Used to verify P2SH redeem scripts. + /// Number of script hash (Hash160) verifications performed. + /// Hash160 = RIPEMD160(SHA256(redeem_script)); used to verify P2SH redeem scripts. pub script_hash_verifications: u16, /// Size of the signable bytes in bytes. 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 6791910923e..27eb67ef6a2 100644 --- a/packages/rs-dpp/src/errors/consensus/basic/basic_error.rs +++ b/packages/rs-dpp/src/errors/consensus/basic/basic_error.rs @@ -68,8 +68,9 @@ use crate::consensus::basic::identity::{ InvalidIdentityPublicKeySecurityLevelError, InvalidIdentityUpdateTransitionDisableKeysError, InvalidIdentityUpdateTransitionEmptyError, InvalidInstantAssetLockProofError, InvalidInstantAssetLockProofSignatureError, InvalidKeyPurposeForContractBoundsError, - MissingMasterPublicKeyError, NotImplementedCreditWithdrawalTransitionPoolingError, - TooManyMasterPublicKeyError, WithdrawalOutputScriptNotAllowedWhenSigningWithOwnerKeyError, + InvalidKeyPurposeKeyTypeError, MissingMasterPublicKeyError, + NotImplementedCreditWithdrawalTransitionPoolingError, TooManyMasterPublicKeyError, + TooManyPublicKeysOfPurposeError, WithdrawalOutputScriptNotAllowedWhenSigningWithOwnerKeyError, }; use crate::consensus::basic::invalid_identifier_error::InvalidIdentifierError; use crate::consensus::basic::state_transition::{ @@ -696,6 +697,12 @@ pub enum BasicError { #[error(transparent)] TokenPricingScheduleEmptyError(TokenPricingScheduleEmptyError), + + #[error(transparent)] + InvalidKeyPurposeKeyTypeError(InvalidKeyPurposeKeyTypeError), + + #[error(transparent)] + TooManyPublicKeysOfPurposeError(TooManyPublicKeysOfPurposeError), } impl From for ConsensusError { @@ -703,3 +710,56 @@ impl From for ConsensusError { Self::BasicError(error) } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::identity::{KeyType, Purpose}; + use platform_value::Identifier; + + fn discriminant_byte(error: &BasicError) -> u8 { + let bytes = bincode::encode_to_vec(error, bincode::config::standard()) + .expect("should encode BasicError"); + bytes[0] + } + + // `BasicError` is bincode-encoded positionally, so every variant's wire discriminant is its + // index in the enum. These pins fail if a variant is ever inserted mid-enum instead of + // appended at the tail, which would mis-decode previously encoded errors. + #[test] + fn should_keep_wire_discriminants_of_existing_variants_stable() { + assert_eq!( + discriminant_byte(&BasicError::StateTransitionNotActiveError( + StateTransitionNotActiveError::new("test", 13, 14), + )), + 143, + ); + assert_eq!( + discriminant_byte(&BasicError::TokenPricingScheduleEmptyError( + TokenPricingScheduleEmptyError::new(Identifier::default()), + )), + 172, + ); + } + + #[test] + fn should_append_payment_key_error_variants_at_the_tail() { + assert_eq!( + discriminant_byte(&BasicError::InvalidKeyPurposeKeyTypeError( + InvalidKeyPurposeKeyTypeError::new( + 0, + Purpose::PAYMENT_SCAN, + KeyType::BLS12_381, + vec![KeyType::ECDSA_SECP256K1], + ), + )), + 173, + ); + assert_eq!( + discriminant_byte(&BasicError::TooManyPublicKeysOfPurposeError( + TooManyPublicKeysOfPurposeError::new(Purpose::PAYMENT_SPEND, 1), + )), + 174, + ); + } +} diff --git a/packages/rs-dpp/src/errors/consensus/basic/identity/invalid_key_purpose_key_type_error.rs b/packages/rs-dpp/src/errors/consensus/basic/identity/invalid_key_purpose_key_type_error.rs new file mode 100644 index 00000000000..e7e17254a23 --- /dev/null +++ b/packages/rs-dpp/src/errors/consensus/basic/identity/invalid_key_purpose_key_type_error.rs @@ -0,0 +1,64 @@ +use crate::consensus::basic::BasicError; +use crate::errors::ProtocolError; +use platform_serialization_derive::{PlatformDeserialize, PlatformSerialize}; +use thiserror::Error; + +use crate::consensus::ConsensusError; + +use crate::identity::{KeyID, KeyType, Purpose}; +use bincode::{Decode, Encode}; + +#[derive( + Error, Debug, Clone, PartialEq, Eq, Encode, Decode, PlatformSerialize, PlatformDeserialize, +)] +#[error("Identity key {key_id} of purpose {purpose} can not use key type {key_type}. Allowed key types: {allowed_key_types:?}")] +#[platform_serialize(unversioned)] +pub struct InvalidKeyPurposeKeyTypeError { + /* + + DO NOT CHANGE ORDER OF FIELDS WITHOUT INTRODUCING OF NEW VERSION + + */ + key_id: KeyID, + purpose: Purpose, + key_type: KeyType, + allowed_key_types: Vec, +} + +impl InvalidKeyPurposeKeyTypeError { + pub fn new( + key_id: KeyID, + purpose: Purpose, + key_type: KeyType, + allowed_key_types: Vec, + ) -> Self { + Self { + key_id, + purpose, + key_type, + allowed_key_types, + } + } + + pub fn key_id(&self) -> KeyID { + self.key_id + } + + pub fn purpose(&self) -> Purpose { + self.purpose + } + + pub fn key_type(&self) -> KeyType { + self.key_type + } + + pub fn allowed_key_types(&self) -> &Vec { + &self.allowed_key_types + } +} + +impl From for ConsensusError { + fn from(err: InvalidKeyPurposeKeyTypeError) -> Self { + Self::BasicError(BasicError::InvalidKeyPurposeKeyTypeError(err)) + } +} 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..68be52cf671 100644 --- a/packages/rs-dpp/src/errors/consensus/basic/identity/mod.rs +++ b/packages/rs-dpp/src/errors/consensus/basic/identity/mod.rs @@ -28,9 +28,11 @@ pub use invalid_identity_update_transition_empty_error::*; pub use invalid_instant_asset_lock_proof_error::*; pub use invalid_instant_asset_lock_proof_signature_error::*; pub use invalid_key_purpose_for_contract_bounds_error::*; +pub use invalid_key_purpose_key_type_error::*; pub use missing_master_public_key_error::*; pub use not_implemented_credit_withdrawal_transition_pooling_error::*; pub use too_many_master_public_key_error::*; +pub use too_many_public_keys_of_purpose_error::*; pub use withdrawal_output_script_not_allowed_when_signing_with_owner_key::*; mod data_contract_bounds_not_present_error; @@ -64,7 +66,9 @@ mod invalid_identity_update_transition_empty_error; mod invalid_instant_asset_lock_proof_error; mod invalid_instant_asset_lock_proof_signature_error; mod invalid_key_purpose_for_contract_bounds_error; +mod invalid_key_purpose_key_type_error; mod missing_master_public_key_error; mod not_implemented_credit_withdrawal_transition_pooling_error; mod too_many_master_public_key_error; +mod too_many_public_keys_of_purpose_error; mod withdrawal_output_script_not_allowed_when_signing_with_owner_key; diff --git a/packages/rs-dpp/src/errors/consensus/basic/identity/too_many_public_keys_of_purpose_error.rs b/packages/rs-dpp/src/errors/consensus/basic/identity/too_many_public_keys_of_purpose_error.rs new file mode 100644 index 00000000000..b07f0203e08 --- /dev/null +++ b/packages/rs-dpp/src/errors/consensus/basic/identity/too_many_public_keys_of_purpose_error.rs @@ -0,0 +1,44 @@ +use crate::consensus::basic::BasicError; +use crate::errors::ProtocolError; +use platform_serialization_derive::{PlatformDeserialize, PlatformSerialize}; +use thiserror::Error; + +use crate::consensus::ConsensusError; + +use crate::identity::Purpose; +use bincode::{Decode, Encode}; + +#[derive( + Error, Debug, Clone, PartialEq, Eq, Encode, Decode, PlatformSerialize, PlatformDeserialize, +)] +#[error("An identity may have at most {limit} active public key(s) of purpose {purpose}")] +#[platform_serialize(unversioned)] +pub struct TooManyPublicKeysOfPurposeError { + /* + + DO NOT CHANGE ORDER OF FIELDS WITHOUT INTRODUCING OF NEW VERSION + + */ + purpose: Purpose, + limit: u32, +} + +impl TooManyPublicKeysOfPurposeError { + pub fn new(purpose: Purpose, limit: u32) -> Self { + Self { purpose, limit } + } + + pub fn purpose(&self) -> Purpose { + self.purpose + } + + pub fn limit(&self) -> u32 { + self.limit + } +} + +impl From for ConsensusError { + fn from(err: TooManyPublicKeysOfPurposeError) -> Self { + Self::BasicError(BasicError::TooManyPublicKeysOfPurposeError(err)) + } +} diff --git a/packages/rs-dpp/src/errors/consensus/codes.rs b/packages/rs-dpp/src/errors/consensus/codes.rs index c3de95ae6b3..952a0175f30 100644 --- a/packages/rs-dpp/src/errors/consensus/codes.rs +++ b/packages/rs-dpp/src/errors/consensus/codes.rs @@ -204,6 +204,8 @@ impl ErrorWithCode for BasicError { Self::WithdrawalOutputScriptNotAllowedWhenSigningWithOwnerKeyError(_) => 10532, Self::InvalidKeyPurposeForContractBoundsError(_) => 10533, Self::IdentityAssetLockTransactionTooManyInputsError(_) => 10534, + Self::InvalidKeyPurposeKeyTypeError(_) => 10535, + Self::TooManyPublicKeysOfPurposeError(_) => 10536, // State Transition Errors: 10600-10699 Self::InvalidStateTransitionTypeError { .. } => 10600, diff --git a/packages/rs-dpp/src/identity/identity_public_key/purpose.rs b/packages/rs-dpp/src/identity/identity_public_key/purpose.rs index 61cb212f096..398b41ec199 100644 --- a/packages/rs-dpp/src/identity/identity_public_key/purpose.rs +++ b/packages/rs-dpp/src/identity/identity_public_key/purpose.rs @@ -1,5 +1,6 @@ use crate::identity::Purpose::{ - AUTHENTICATION, DECRYPTION, ENCRYPTION, OWNER, SYSTEM, TRANSFER, VOTING, + AUTHENTICATION, DECRYPTION, ENCRYPTION, OWNER, PAYMENT_SCAN, PAYMENT_SPEND, SYSTEM, TRANSFER, + VOTING, }; use anyhow::bail; use bincode::{Decode, Encode}; @@ -25,6 +26,7 @@ use std::convert::TryFrom; Default, strum::EnumIter, )] +#[allow(non_camel_case_types)] pub enum Purpose { /// at least one authentication key must be registered for all security levels #[default] @@ -42,6 +44,14 @@ pub enum Purpose { VOTING = 5, /// this key is used to prove ownership of a masternode or evonode OWNER = 6, + /// this key detects incoming stealth payments and decrypts payment + /// notifications (DIP-33); it cannot be used for signing state transitions + /// or documents. Accepted from protocol version 14. + PAYMENT_SCAN = 7, + /// this key is the spend base for stealth one-time payment addresses + /// (DIP-33); it cannot be used for signing state transitions or documents. + /// Accepted from protocol version 14. + PAYMENT_SPEND = 8, } impl From for [u8; 1] { @@ -60,6 +70,8 @@ impl From for &'static [u8; 1] { SYSTEM => &[4], VOTING => &[5], OWNER => &[6], + PAYMENT_SCAN => &[7], + PAYMENT_SPEND => &[8], } } } @@ -75,6 +87,8 @@ impl TryFrom for Purpose { 4 => Ok(SYSTEM), 5 => Ok(VOTING), 6 => Ok(OWNER), + 7 => Ok(PAYMENT_SCAN), + 8 => Ok(PAYMENT_SPEND), value => bail!("unrecognized purpose: {}", value), } } @@ -91,6 +105,8 @@ impl TryFrom for Purpose { 4 => Ok(SYSTEM), 5 => Ok(VOTING), 6 => Ok(OWNER), + 7 => Ok(PAYMENT_SCAN), + 8 => Ok(PAYMENT_SPEND), value => bail!("unrecognized purpose: {}", value), } } @@ -110,7 +126,7 @@ impl std::fmt::Display for Purpose { impl Purpose { /// The full range of purposes - pub fn full_range() -> [Purpose; 6] { + pub fn full_range() -> [Purpose; 8] { [ AUTHENTICATION, ENCRYPTION, @@ -118,9 +134,18 @@ impl Purpose { TRANSFER, VOTING, OWNER, + PAYMENT_SCAN, + PAYMENT_SPEND, ] } /// Just the authentication and withdraw purposes + /// + /// CONSENSUS-CRITICAL: this list defines the per-purpose key reference + /// trees created for every identity in grovedb. It must never change for + /// existing purposes, and new purposes may only be added together with a + /// versioned migration of the identity key query trees. PAYMENT_SCAN and + /// PAYMENT_SPEND are deliberately NOT searchable (DIP-33): they are found + /// by fetching the identity's keys, not by per-purpose search. pub fn searchable_purposes() -> [Purpose; 3] { [AUTHENTICATION, TRANSFER, VOTING] } @@ -128,6 +153,11 @@ impl Purpose { pub fn encryption_decryption() -> [Purpose; 2] { [ENCRYPTION, DECRYPTION] } + /// The DIP-33 payment detection/spend purposes; at most one active key of + /// each may exist per identity + pub fn payment_purposes() -> [Purpose; 2] { + [PAYMENT_SCAN, PAYMENT_SPEND] + } } #[cfg(test)] @@ -144,12 +174,14 @@ mod tests { assert_eq!(Purpose::try_from(4u8).unwrap(), SYSTEM); assert_eq!(Purpose::try_from(5u8).unwrap(), VOTING); assert_eq!(Purpose::try_from(6u8).unwrap(), OWNER); + assert_eq!(Purpose::try_from(7u8).unwrap(), PAYMENT_SCAN); + assert_eq!(Purpose::try_from(8u8).unwrap(), PAYMENT_SPEND); } // -- TryFrom invalid -- #[test] fn test_purpose_try_from_u8_invalid() { - assert!(Purpose::try_from(7u8).is_err()); + assert!(Purpose::try_from(9u8).is_err()); assert!(Purpose::try_from(255u8).is_err()); } @@ -163,12 +195,14 @@ mod tests { assert_eq!(Purpose::try_from(4i32).unwrap(), SYSTEM); assert_eq!(Purpose::try_from(5i32).unwrap(), VOTING); assert_eq!(Purpose::try_from(6i32).unwrap(), OWNER); + assert_eq!(Purpose::try_from(7i32).unwrap(), PAYMENT_SCAN); + assert_eq!(Purpose::try_from(8i32).unwrap(), PAYMENT_SPEND); } #[test] fn test_purpose_try_from_i32_invalid() { assert!(Purpose::try_from(-1i32).is_err()); - assert!(Purpose::try_from(7i32).is_err()); + assert!(Purpose::try_from(9i32).is_err()); assert!(Purpose::try_from(1_000_000i32).is_err()); } @@ -200,6 +234,10 @@ mod tests { assert_eq!(r, &[5u8]); let r: &'static [u8; 1] = OWNER.into(); assert_eq!(r, &[6u8]); + let r: &'static [u8; 1] = PAYMENT_SCAN.into(); + assert_eq!(r, &[7u8]); + let r: &'static [u8; 1] = PAYMENT_SPEND.into(); + assert_eq!(r, &[8u8]); } // -- Display (via Debug) -- @@ -212,6 +250,8 @@ mod tests { assert_eq!(format!("{}", SYSTEM), "SYSTEM"); assert_eq!(format!("{}", VOTING), "VOTING"); assert_eq!(format!("{}", OWNER), "OWNER"); + assert_eq!(format!("{}", PAYMENT_SCAN), "PAYMENT_SCAN"); + assert_eq!(format!("{}", PAYMENT_SPEND), "PAYMENT_SPEND"); } // -- Default -- @@ -225,16 +265,23 @@ mod tests { fn test_purpose_full_range_contents() { // NOTE: full_range() intentionally excludes SYSTEM. let full = Purpose::full_range(); - assert_eq!(full.len(), 6); + assert_eq!(full.len(), 8); assert!(full.contains(&AUTHENTICATION)); assert!(full.contains(&ENCRYPTION)); assert!(full.contains(&DECRYPTION)); assert!(full.contains(&TRANSFER)); assert!(full.contains(&VOTING)); assert!(full.contains(&OWNER)); + assert!(full.contains(&PAYMENT_SCAN)); + assert!(full.contains(&PAYMENT_SPEND)); assert!(!full.contains(&SYSTEM)); } + #[test] + fn test_purpose_payment_purposes_contents() { + assert_eq!(Purpose::payment_purposes(), [PAYMENT_SCAN, PAYMENT_SPEND]); + } + #[test] fn test_purpose_searchable_purposes_contents() { let searchable = Purpose::searchable_purposes(); @@ -252,7 +299,7 @@ mod tests { // -- round-trip: Purpose -> u8 -> Purpose -- #[test] fn test_purpose_round_trip_u8() { - for val in 0u8..=6 { + for val in 0u8..=8 { let p = Purpose::try_from(val).unwrap(); assert_eq!(p as u8, val); } @@ -267,5 +314,7 @@ mod tests { assert!(TRANSFER < SYSTEM); assert!(SYSTEM < VOTING); assert!(VOTING < OWNER); + assert!(OWNER < PAYMENT_SCAN); + assert!(PAYMENT_SCAN < PAYMENT_SPEND); } } 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/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..6dc4456337f --- /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,370 @@ +use crate::consensus::basic::identity::{ + DuplicatedIdentityPublicKeyBasicError, DuplicatedIdentityPublicKeyIdBasicError, + InvalidIdentityPublicKeyDataError, InvalidIdentityPublicKeySecurityLevelError, + InvalidKeyPurposeKeyTypeError, MissingMasterPublicKeyError, TooManyMasterPublicKeyError, + TooManyPublicKeysOfPurposeError, +}; +use crate::consensus::basic::BasicError; +use crate::PublicKeyValidationError; +use lazy_static::lazy_static; +use std::collections::HashMap; + +use crate::consensus::state::identity::max_identity_public_key_limit_reached_error::MaxIdentityPublicKeyLimitReachedError; + +use crate::consensus::state::state_error::StateError; +use crate::identity::{KeyType, Purpose, SecurityLevel}; + +use crate::state_transition::public_key_in_creation::accessors::IdentityPublicKeyInCreationV0Getters; +use crate::state_transition::public_key_in_creation::IdentityPublicKeyInCreation; +use crate::validation::SimpleConsensusValidationResult; +use crate::ProtocolError; +use platform_version::version::PlatformVersion; + +lazy_static! { + static ref ALLOWED_SECURITY_LEVELS_FOR_EXTERNALLY_ADDED_KEYS: HashMap> = { + let mut m = HashMap::new(); + m.insert( + Purpose::AUTHENTICATION, + vec![ + SecurityLevel::MASTER, + SecurityLevel::CRITICAL, + SecurityLevel::HIGH, + SecurityLevel::MEDIUM, + ], + ); + m.insert(Purpose::ENCRYPTION, vec![SecurityLevel::MEDIUM]); + m.insert(Purpose::DECRYPTION, vec![SecurityLevel::MEDIUM]); + m.insert(Purpose::TRANSFER, vec![SecurityLevel::CRITICAL]); + // DIP-33 payment detection/spend keys: non-signing keys, like + // encryption/decryption + m.insert(Purpose::PAYMENT_SCAN, vec![SecurityLevel::MEDIUM]); + m.insert(Purpose::PAYMENT_SPEND, vec![SecurityLevel::MEDIUM]); + m + }; +} + +impl IdentityPublicKeyInCreation { + /// 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. + /// + /// v1 (protocol version 14): accepts the DIP-33 `PAYMENT_SCAN` and + /// `PAYMENT_SPEND` purposes, which must be `ECDSA_SECP256K1` and may appear + /// at most once each per transition (the at-most-one-active-in-state rule + /// for identity updates is enforced in state validation). + #[inline(always)] + pub(super) fn validate_identity_public_keys_structure_v1( + identity_public_keys_with_witness: &[IdentityPublicKeyInCreation], + in_create_identity: bool, + platform_version: &PlatformVersion, + ) -> Result { + if identity_public_keys_with_witness.len() + > platform_version + .dpp + .state_transitions + .identities + .max_public_keys_in_creation as usize + { + return Ok(SimpleConsensusValidationResult::new_with_error( + StateError::MaxIdentityPublicKeyLimitReachedError( + MaxIdentityPublicKeyLimitReachedError::new( + platform_version + .dpp + .state_transitions + .identities + .max_public_keys_in_creation as usize, + ), + ) + .into(), + )); + } + + // Check that there's not duplicates key ids in the state transition + let duplicated_ids = IdentityPublicKeyInCreation::duplicated_key_ids_witness( + identity_public_keys_with_witness, + platform_version, + )?; + if !duplicated_ids.is_empty() { + return Ok(SimpleConsensusValidationResult::new_with_error( + BasicError::DuplicatedIdentityPublicKeyIdBasicError( + DuplicatedIdentityPublicKeyIdBasicError::new(duplicated_ids), + ) + .into(), + )); + } + + // Check that there's no duplicated keys + let duplicated_key_ids = IdentityPublicKeyInCreation::duplicated_keys_witness( + identity_public_keys_with_witness, + platform_version, + )?; + if !duplicated_key_ids.is_empty() { + return Ok(SimpleConsensusValidationResult::new_with_error( + BasicError::DuplicatedIdentityPublicKeyBasicError( + DuplicatedIdentityPublicKeyBasicError::new(duplicated_key_ids), + ) + .into(), + )); + } + + if in_create_identity { + // We should check that we are only adding one master authentication key + + let master_key_count = identity_public_keys_with_witness + .iter() + .filter(|key| key.security_level() == SecurityLevel::MASTER) + .count(); + if master_key_count == 0 { + return Ok(SimpleConsensusValidationResult::new_with_error( + BasicError::MissingMasterPublicKeyError(MissingMasterPublicKeyError::new()) + .into(), + )); + } else if master_key_count > 1 { + return Ok(SimpleConsensusValidationResult::new_with_error( + BasicError::TooManyMasterPublicKeyError(TooManyMasterPublicKeyError::new()) + .into(), + )); + } + } + + // DIP-33 payment keys: at most one of each payment purpose per + // transition (on create this is also the at-most-one-active rule; on + // update the against-state half lives in state validation), and only + // ECDSA_SECP256K1 (the stealth derivation is defined over secp256k1 + // with the full compressed key published) + for payment_purpose in Purpose::payment_purposes() { + let payment_key_count = identity_public_keys_with_witness + .iter() + .filter(|key| key.purpose() == payment_purpose) + .count(); + if payment_key_count > 1 { + return Ok(SimpleConsensusValidationResult::new_with_error( + BasicError::TooManyPublicKeysOfPurposeError( + TooManyPublicKeysOfPurposeError::new(payment_purpose, 1), + ) + .into(), + )); + } + } + + if let Some(invalid_type_key) = identity_public_keys_with_witness.iter().find(|key| { + Purpose::payment_purposes().contains(&key.purpose()) + && key.key_type() != KeyType::ECDSA_SECP256K1 + }) { + return Ok(SimpleConsensusValidationResult::new_with_error( + BasicError::InvalidKeyPurposeKeyTypeError(InvalidKeyPurposeKeyTypeError::new( + invalid_type_key.id(), + invalid_type_key.purpose(), + invalid_type_key.key_type(), + vec![KeyType::ECDSA_SECP256K1], + )) + .into(), + )); + } + + // DIP-33 publishes payment keys as compressed SEC1 points, but DPP's ECDSA + // parser also accepts 65-byte uncompressed keys — and the two encodings of + // the same point would evade the raw-bytes duplicate check above + if let Some(invalid_size_key) = identity_public_keys_with_witness.iter().find(|key| { + Purpose::payment_purposes().contains(&key.purpose()) && key.data().len() != 33 + }) { + return Ok(SimpleConsensusValidationResult::new_with_error( + BasicError::InvalidIdentityPublicKeyDataError( + InvalidIdentityPublicKeyDataError::new( + invalid_size_key.id(), + PublicKeyValidationError::new( + "payment keys must be 33-byte compressed SEC1 points", + ), + ), + ) + .into(), + )); + } + + // We should check all the security levels + let validation_errors = identity_public_keys_with_witness + .iter() + .filter_map(|identity_public_key| { + let allowed_security_levels = ALLOWED_SECURITY_LEVELS_FOR_EXTERNALLY_ADDED_KEYS + .get(&identity_public_key.purpose()); + if let Some(levels) = allowed_security_levels { + if !levels.contains(&identity_public_key.security_level()) { + Some( + InvalidIdentityPublicKeySecurityLevelError::new( + identity_public_key.id(), + identity_public_key.purpose(), + identity_public_key.security_level(), + Some(levels.clone()), + ) + .into(), + ) + } else { + None //No error + } + } else { + Some( + InvalidIdentityPublicKeySecurityLevelError::new( + identity_public_key.id(), + identity_public_key.purpose(), + identity_public_key.security_level(), + None, + ) + .into(), + ) + } + }) + .collect(); + Ok(SimpleConsensusValidationResult::new_with_errors( + validation_errors, + )) + } +} + +#[cfg(test)] +mod tests { + use crate::consensus::basic::BasicError; + use crate::consensus::ConsensusError; + use crate::identity::{KeyType, Purpose, SecurityLevel}; + use crate::state_transition::public_key_in_creation::IdentityPublicKeyInCreation; + use crate::version::PlatformVersion; + use rand::rngs::StdRng; + use rand::SeedableRng; + + fn payment_key( + id: u32, + purpose: Purpose, + key_type: KeyType, + rng: &mut StdRng, + platform_version: &PlatformVersion, + ) -> IdentityPublicKeyInCreation { + let (key, _priv) = crate::identity::IdentityPublicKey::random_key_with_known_attributes( + id, + rng, + purpose, + SecurityLevel::MEDIUM, + key_type, + None, + platform_version, + ) + .expect("random payment key"); + key.into() + } + + fn latest() -> &'static PlatformVersion { + PlatformVersion::latest() + } + + #[test] + fn accepts_a_single_ecdsa_payment_scan_key() { + let platform_version = latest(); + let mut rng = StdRng::seed_from_u64(1); + let keys = vec![payment_key( + 0, + Purpose::PAYMENT_SCAN, + KeyType::ECDSA_SECP256K1, + &mut rng, + platform_version, + )]; + let result = IdentityPublicKeyInCreation::validate_identity_public_keys_structure_v1( + &keys, + false, + platform_version, + ) + .expect("validation ran"); + assert!(result.is_valid(), "errors: {:?}", result.errors); + } + + #[test] + fn rejects_non_ecdsa_payment_key() { + let platform_version = latest(); + let mut rng = StdRng::seed_from_u64(2); + let keys = vec![payment_key( + 0, + Purpose::PAYMENT_SPEND, + KeyType::BLS12_381, + &mut rng, + platform_version, + )]; + let result = IdentityPublicKeyInCreation::validate_identity_public_keys_structure_v1( + &keys, + false, + platform_version, + ) + .expect("validation ran"); + assert!(matches!( + result.errors.first(), + Some(ConsensusError::BasicError( + BasicError::InvalidKeyPurposeKeyTypeError(_) + )) + )); + } + + #[test] + fn rejects_uncompressed_65_byte_payment_key() { + use crate::state_transition::public_key_in_creation::accessors::{ + IdentityPublicKeyInCreationV0Getters, IdentityPublicKeyInCreationV0Setters, + }; + + let platform_version = latest(); + let mut rng = StdRng::seed_from_u64(4); + let mut key = payment_key( + 0, + Purpose::PAYMENT_SCAN, + KeyType::ECDSA_SECP256K1, + &mut rng, + platform_version, + ); + // re-encode the same valid point uncompressed: 65-byte SEC1 + let uncompressed = dashcore::secp256k1::PublicKey::from_slice(key.data().as_slice()) + .expect("valid compressed key") + .serialize_uncompressed(); + key.set_data(uncompressed.to_vec().into()); + + let result = IdentityPublicKeyInCreation::validate_identity_public_keys_structure_v1( + &[key], + false, + platform_version, + ) + .expect("validation ran"); + assert!(matches!( + result.errors.first(), + Some(ConsensusError::BasicError( + BasicError::InvalidIdentityPublicKeyDataError(_) + )) + )); + } + + #[test] + fn rejects_two_keys_of_the_same_payment_purpose() { + let platform_version = latest(); + let mut rng = StdRng::seed_from_u64(3); + let keys = vec![ + payment_key( + 0, + Purpose::PAYMENT_SCAN, + KeyType::ECDSA_SECP256K1, + &mut rng, + platform_version, + ), + payment_key( + 1, + Purpose::PAYMENT_SCAN, + KeyType::ECDSA_SECP256K1, + &mut rng, + platform_version, + ), + ]; + let result = IdentityPublicKeyInCreation::validate_identity_public_keys_structure_v1( + &keys, + false, + platform_version, + ) + .expect("validation ran"); + assert!(matches!( + result.errors.first(), + Some(ConsensusError::BasicError( + BasicError::TooManyPublicKeysOfPurposeError(_) + )) + )); + } +} diff --git a/packages/rs-drive-abci/src/execution/check_tx/v0/mod.rs b/packages/rs-drive-abci/src/execution/check_tx/v0/mod.rs index 3a8978f1622..c7c1bf2fb6c 100644 --- a/packages/rs-drive-abci/src/execution/check_tx/v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/check_tx/v0/mod.rs @@ -644,7 +644,7 @@ mod tests { assert_eq!( processing_result.aggregated_fees().processing_fee, - 24002489210 + 24002816630 ); let check_result = platform @@ -1159,7 +1159,7 @@ mod tests { // Plus we have 24_000_000_000 in base costs assert_eq!( processing_result.aggregated_fees().processing_fee, - 24004978420 + 24005633260 ); let check_result = platform @@ -1634,7 +1634,7 @@ mod tests { assert_eq!( processing_result.aggregated_fees().processing_fee, - 24002489210 + 24002816630 ); platform @@ -1721,7 +1721,7 @@ mod tests { assert_eq!( update_processing_result.aggregated_fees().processing_fee, - 27002504030 + 27002879350 ); let check_result = platform @@ -2093,7 +2093,7 @@ mod tests { assert_eq!( processing_result.aggregated_fees().processing_fee, - 24002489210 + 24002816630 ); platform diff --git a/packages/rs-drive-abci/src/execution/platform_events/protocol_upgrade/perform_events_on_first_block_of_protocol_change/v0/mod.rs b/packages/rs-drive-abci/src/execution/platform_events/protocol_upgrade/perform_events_on_first_block_of_protocol_change/v0/mod.rs index 0866f15c0f7..e3a5cd227d0 100644 --- a/packages/rs-drive-abci/src/execution/platform_events/protocol_upgrade/perform_events_on_first_block_of_protocol_change/v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/platform_events/protocol_upgrade/perform_events_on_first_block_of_protocol_change/v0/mod.rs @@ -114,6 +114,10 @@ impl Platform { self.transition_to_version_13(block_info, transaction, platform_version)?; } + if previous_protocol_version < 14 && platform_version.protocol_version >= 14 { + self.transition_to_version_14(block_info, transaction, platform_version)?; + } + Ok(()) } @@ -686,6 +690,30 @@ impl Platform { Ok(()) } + + /// When transitioning to version 14 we re-store the DashPay contract whose + /// v2 schema adds the optional public payment address fields to the + /// `profile` document type (DIP-33). + fn transition_to_version_14( + &self, + block_info: &BlockInfo, + transaction: &Transaction, + platform_version: &PlatformVersion, + ) -> Result<(), Error> { + let dashpay_contract = + load_system_data_contract(SystemDataContract::Dashpay, platform_version)?; + + self.drive.apply_contract( + &dashpay_contract, + *block_info, + true, + None, + Some(transaction), + platform_version, + )?; + + Ok(()) + } } #[cfg(test)] @@ -1010,6 +1038,314 @@ mod tests { assert!(domain.documents_keep_pricing_history()); } + #[test] + fn test_transition_to_version_14_updates_dashpay_with_payment_address_fields() { + use dpp::data_contract::accessors::v0::DataContractV0Getters; + use dpp::data_contract::document_type::accessors::DocumentTypeV0Getters; + + // A chain born at protocol version 13: DashPay v1 is stored and the + // profile document type has no payment address fields. + let platform = TestPlatformBuilder::new() + .with_initial_protocol_version(13) + .build_with_mock_rpc() + .set_genesis_state(); + + let platform_version = PlatformVersion::get(14).expect("expected platform version 14"); + + let transaction = platform.drive.grove.start_transaction(); + + let block_info = BlockInfo { + time_ms: 1_000_000, + height: 100, + core_height: 100, + epoch: Epoch::new(1).expect("expected epoch"), + }; + + // Before the transition the stored DashPay profile must be v1: no + // corePaymentAddress / platformPaymentAddress properties. + let (_fee_result, pre_upgrade_fetch_info) = platform + .drive + .get_contract_with_fetch_info_and_fee( + *dpp::data_contracts::SystemDataContract::Dashpay + .id() + .as_bytes(), + None, + false, + Some(&transaction), + platform_version, + ) + .expect("expected to fetch DashPay contract"); + let pre_profile = pre_upgrade_fetch_info + .expect("expected the DashPay contract to exist at genesis") + .contract + .document_type_for_name("profile") + .expect("expected the profile document type") + .properties() + .keys() + .cloned() + .collect::>(); + assert!( + !pre_profile.iter().any(|p| p == "corePaymentAddress"), + "profile must not carry corePaymentAddress before transition_to_version_14" + ); + assert!( + !pre_profile.iter().any(|p| p == "platformPaymentAddress"), + "profile must not carry platformPaymentAddress before transition_to_version_14" + ); + + let result = platform.transition_to_version_14(&block_info, &transaction, platform_version); + assert!(result.is_ok(), "transition failed: {:?}", result.err()); + + // After the transition the stored DashPay contract must be v2: the + // profile document type gains the two optional payment address fields. + let (_fee_result, dashpay_fetch_info) = platform + .drive + .get_contract_with_fetch_info_and_fee( + *dpp::data_contracts::SystemDataContract::Dashpay + .id() + .as_bytes(), + None, + false, + Some(&transaction), + platform_version, + ) + .expect("expected to fetch DashPay contract"); + + let profile = dashpay_fetch_info + .expect("expected the DashPay contract to exist") + .contract + .document_type_for_name("profile") + .expect("expected the profile document type") + .properties() + .keys() + .cloned() + .collect::>(); + + assert!( + profile.iter().any(|p| p == "corePaymentAddress"), + "profile must carry corePaymentAddress after transition_to_version_14" + ); + assert!( + profile.iter().any(|p| p == "platformPaymentAddress"), + "profile must carry platformPaymentAddress after transition_to_version_14" + ); + } + + /// The v13→v14 boundary through the production dispatcher + /// (`perform_events_on_first_block_of_protocol_change`), with a v1 profile + /// stored before the upgrade and the contract cache warmed: the dispatcher + /// must select the v14 transition, the refreshed contract must carry both + /// payment address fields, and the legacy profile bytes must stay readable + /// through normal Drive queries against the refreshed contract. + #[tokio::test] + async fn test_protocol_change_v13_to_v14_upgrades_dashpay_and_keeps_v1_profiles_readable() { + use crate::execution::validation::state_transition::tests::setup_identity; + use crate::platform_types::state_transitions_processing_result::StateTransitionExecutionResult; + use assert_matches::assert_matches; + use dpp::dash_to_credits; + use dpp::data_contract::accessors::v0::DataContractV0Getters; + use dpp::data_contract::document_type::accessors::DocumentTypeV0Getters; + use dpp::data_contract::document_type::random_document::{ + CreateRandomDocument, DocumentFieldFillSize, DocumentFieldFillType, + }; + use dpp::document::{DocumentV0Getters, DocumentV0Setters}; + use dpp::identity::accessors::IdentityGettersV0; + use dpp::platform_value::Bytes32; + use dpp::serialization::PlatformSerializable; + use dpp::state_transition::batch_transition::methods::v0::DocumentsBatchTransitionMethodsV0; + use dpp::state_transition::batch_transition::BatchTransition; + use drive::drive::document::query::QueryDocumentsOutcomeV0Methods; + use drive::query::DriveDocumentQuery; + use rand::rngs::StdRng; + use rand::SeedableRng; + + let platform_version_13 = PlatformVersion::get(13).expect("expected platform version 13"); + let platform_version_14 = PlatformVersion::get(14).expect("expected platform version 14"); + + let mut platform = TestPlatformBuilder::new() + .with_initial_protocol_version(13) + .build_with_mock_rpc() + .set_genesis_state(); + + let platform_state = platform.state.load(); + let (identity, signer, key) = setup_identity(&mut platform, 958, dash_to_credits!(0.1)); + + // Store a v1 profile through the normal pipeline at protocol version 13 + let dashpay_v1 = platform + .drive + .cache + .system_data_contracts + .load_dashpay(platform_version_13) + .expect("expected the dashpay system contract"); + let profile_v1 = dashpay_v1 + .document_type_for_name("profile") + .expect("expected a profile document type"); + + let mut rng = StdRng::seed_from_u64(438); + let entropy = Bytes32::random_with_rng(&mut rng); + let mut document = profile_v1 + .random_document_with_identifier_and_entropy( + &mut rng, + identity.id(), + entropy, + DocumentFieldFillType::FillIfNotRequired, + DocumentFieldFillSize::AnyDocumentFillSize, + platform_version_13, + ) + .expect("expected a random v1 profile document"); + document.set("avatarUrl", "http://test.com/bob.jpg".into()); + let stored_profile_id = document.id(); + + let create_transition = BatchTransition::new_document_creation_transition_from_document( + document, + profile_v1, + entropy.0, + &key, + 2, + 0, + None, + &signer, + platform_version_13, + None, + ) + .await + .expect("expect to create documents batch transition"); + let create_serialized = create_transition + .serialize_to_bytes() + .expect("expected serialized transition"); + + let transaction = platform.drive.grove.start_transaction(); + let processing_result = platform + .platform + .process_raw_state_transitions( + &vec![create_serialized], + &platform_state, + &BlockInfo::default(), + &transaction, + platform_version_13, + false, + None, + ) + .expect("expected to process state transition"); + assert_matches!( + processing_result.execution_results().as_slice(), + [StateTransitionExecutionResult::SuccessfulExecution { .. }] + ); + platform + .drive + .grove + .commit_transaction(transaction) + .unwrap() + .expect("expected to commit"); + + // Warm the drive contract cache with the v1 contract and confirm the + // payment address fields are absent pre-upgrade + let transaction = platform.drive.grove.start_transaction(); + let (_fee, pre_fetch_info) = platform + .drive + .get_contract_with_fetch_info_and_fee( + *dpp::data_contracts::SystemDataContract::Dashpay + .id() + .as_bytes(), + None, + true, + Some(&transaction), + platform_version_13, + ) + .expect("expected to fetch DashPay contract"); + let pre_profile_properties = pre_fetch_info + .expect("expected the DashPay contract pre-upgrade") + .contract + .document_type_for_name("profile") + .expect("expected the profile document type") + .properties() + .keys() + .cloned() + .collect::>(); + for field in ["corePaymentAddress", "platformPaymentAddress"] { + assert!( + !pre_profile_properties.iter().any(|p| p == field), + "profile must not carry {field} before the upgrade" + ); + } + + // Run the v13→v14 boundary through the production dispatcher + let block_info = BlockInfo { + time_ms: 2_000_000, + height: 200, + core_height: 200, + epoch: Epoch::new(1).expect("expected epoch"), + }; + platform + .perform_events_on_first_block_of_protocol_change( + &platform_state, + &block_info, + &transaction, + 13, + platform_version_14, + ) + .expect("expected the protocol change events to succeed"); + platform + .drive + .grove + .commit_transaction(transaction) + .unwrap() + .expect("expected to commit the upgrade"); + + // The refreshed contract must be v2 with both payment address fields + let (_fee, post_fetch_info) = platform + .drive + .get_contract_with_fetch_info_and_fee( + *dpp::data_contracts::SystemDataContract::Dashpay + .id() + .as_bytes(), + None, + true, + None, + platform_version_14, + ) + .expect("expected to fetch DashPay contract post-upgrade"); + let dashpay_v2_fetch_info = post_fetch_info.expect("expected the DashPay contract"); + let post_profile_properties = dashpay_v2_fetch_info + .contract + .document_type_for_name("profile") + .expect("expected the profile document type") + .properties() + .keys() + .cloned() + .collect::>(); + for field in ["corePaymentAddress", "platformPaymentAddress"] { + assert!( + post_profile_properties.iter().any(|p| p == field), + "profile must carry {field} after the upgrade" + ); + } + + // The pre-upgrade profile must remain readable through normal Drive + // queries against the refreshed v2 contract + let query = DriveDocumentQuery::from_sql_expr( + "select * from profile", + &dashpay_v2_fetch_info.contract, + Some(&platform.config.drive), + ) + .expect("expected a document query"); + let query_results = platform + .drive + .query_documents(query, None, false, None, None) + .expect("expected to query documents"); + let documents = query_results.documents(); + assert_eq!( + documents.len(), + 1, + "the v1 profile must survive the upgrade" + ); + assert_eq!( + documents.first().expect("expected a document").id(), + stored_profile_id, + "the surviving profile must be the pre-upgrade document" + ); + } + // test_transition_to_version_9 removed: requires prior state from versions 4-8 #[test] diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/common/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/common/mod.rs index 55c46ac5dc2..37bd2712513 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/common/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/common/mod.rs @@ -6,5 +6,6 @@ pub mod validate_identity_public_key_ids_dont_exist_in_state; pub mod validate_identity_public_key_ids_exist_in_state; pub mod validate_non_masternode_identity_exists; pub mod validate_not_disabling_last_master_key; +pub mod validate_payment_key_uniqueness; pub mod validate_state_transition_identity_signed; pub mod validate_unique_identity_public_key_hashes_in_state; diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/common/validate_payment_key_uniqueness/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/common/validate_payment_key_uniqueness/mod.rs new file mode 100644 index 00000000000..a84c1a10dc9 --- /dev/null +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/common/validate_payment_key_uniqueness/mod.rs @@ -0,0 +1,50 @@ +use crate::error::execution::ExecutionError; +use crate::error::Error; +use crate::execution::types::state_transition_execution_context::StateTransitionExecutionContext; +use crate::execution::validation::state_transition::common::validate_payment_key_uniqueness::v0::validate_payment_key_uniqueness_in_state_v0; +use dpp::identity::KeyID; +use dpp::platform_value::Identifier; +use dpp::state_transition::public_key_in_creation::IdentityPublicKeyInCreation; +use dpp::validation::SimpleConsensusValidationResult; +use dpp::version::PlatformVersion; +use drive::drive::Drive; +use drive::grovedb::TransactionArg; + +pub mod v0; + +/// Validates that adding the given keys leaves the identity with at most one +/// active key of each DIP-33 payment purpose (`PAYMENT_SCAN`, `PAYMENT_SPEND`), +/// taking keys disabled in the same transition into account. +#[allow(clippy::too_many_arguments)] +pub(crate) fn validate_payment_key_uniqueness_in_state( + identity_id: Identifier, + public_keys_being_added: &[IdentityPublicKeyInCreation], + public_key_ids_to_disable: &[KeyID], + drive: &Drive, + execution_context: &mut StateTransitionExecutionContext, + transaction: TransactionArg, + platform_version: &PlatformVersion, +) -> Result { + match platform_version + .drive_abci + .validation_and_processing + .state_transitions + .common_validation_methods + .validate_payment_key_uniqueness + { + 0 => validate_payment_key_uniqueness_in_state_v0( + identity_id, + public_keys_being_added, + public_key_ids_to_disable, + drive, + execution_context, + transaction, + platform_version, + ), + version => Err(Error::Execution(ExecutionError::UnknownVersionMismatch { + method: "validate_payment_key_uniqueness_in_state".to_string(), + known_versions: vec![0], + received: version, + })), + } +} diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/common/validate_payment_key_uniqueness/v0/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/common/validate_payment_key_uniqueness/v0/mod.rs new file mode 100644 index 00000000000..25e39de92de --- /dev/null +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/common/validate_payment_key_uniqueness/v0/mod.rs @@ -0,0 +1,85 @@ +use crate::error::Error; + +use dpp::consensus::basic::identity::TooManyPublicKeysOfPurposeError; + +use dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; +use dpp::identity::{KeyID, Purpose}; +use dpp::platform_value::Identifier; + +use dpp::state_transition::public_key_in_creation::accessors::IdentityPublicKeyInCreationV0Getters; +use dpp::state_transition::public_key_in_creation::IdentityPublicKeyInCreation; +use dpp::validation::SimpleConsensusValidationResult; + +use drive::drive::identity::key::fetch::{ + IdentityKeysRequest, KeyIDIdentityPublicKeyPairBTreeMap, KeyRequestType, +}; +use drive::drive::Drive; +use drive::grovedb::TransactionArg; + +use crate::execution::types::state_transition_execution_context::StateTransitionExecutionContext; +use dpp::version::PlatformVersion; + +/// Validates that after this transition the identity holds at most one active +/// key of each DIP-33 payment purpose. +/// +/// The in-transition half (at most one of each payment purpose among the added +/// keys) is enforced by `validate_identity_public_keys_structure` v1; this +/// checks the against-state half for identity updates: an added payment key is +/// only valid if the identity has no other active key of that purpose, unless +/// that key is being disabled in the same transition (the rotation path). +/// +/// Payment purposes are not searchable (no per-purpose key reference tree), so +/// this fetches all of the identity's keys and filters in memory. The fetch is +/// skipped entirely when no payment key is being added, which also makes this +/// check unreachable at protocol versions that reject payment purposes at +/// structure validation. +#[allow(clippy::too_many_arguments)] +pub(super) fn validate_payment_key_uniqueness_in_state_v0( + identity_id: Identifier, + public_keys_being_added: &[IdentityPublicKeyInCreation], + public_key_ids_to_disable: &[KeyID], + drive: &Drive, + _execution_context: &mut StateTransitionExecutionContext, + transaction: TransactionArg, + platform_version: &PlatformVersion, +) -> Result { + let payment_purposes_being_added: Vec = Purpose::payment_purposes() + .into_iter() + .filter(|purpose| { + public_keys_being_added + .iter() + .any(|key| key.purpose() == *purpose) + }) + .collect(); + + if payment_purposes_being_added.is_empty() { + return Ok(SimpleConsensusValidationResult::new()); + } + + let identity_key_request = IdentityKeysRequest { + identity_id: identity_id.to_buffer(), + request_type: KeyRequestType::AllKeys, + limit: None, + offset: None, + }; + let existing_keys = drive.fetch_identity_keys::( + identity_key_request, + transaction, + platform_version, + )?; + + for purpose in payment_purposes_being_added { + let conflicting_active_key_exists = existing_keys.values().any(|key| { + key.purpose() == purpose + && key.disabled_at().is_none() + && !public_key_ids_to_disable.contains(&key.id()) + }); + if conflicting_active_key_exists { + return Ok(SimpleConsensusValidationResult::new_with_error( + TooManyPublicKeysOfPurposeError::new(purpose, 1).into(), + )); + } + } + + Ok(SimpleConsensusValidationResult::new()) +} diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/data_triggers/bindings/list/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/data_triggers/bindings/list/mod.rs index e3e6a001427..e7e67702fd8 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/data_triggers/bindings/list/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/data_triggers/bindings/list/mod.rs @@ -4,6 +4,7 @@ use crate::execution::validation::state_transition::batch::data_triggers::bindin mod v0; mod v1; +mod v2; pub fn data_trigger_bindings_list( platform_version: &PlatformVersion, @@ -24,9 +25,13 @@ pub fn data_trigger_bindings_list( .into_iter() .map(|binding| binding.into()) .collect()), + 2 => Ok(v2::data_trigger_bindings_list_v2()? + .into_iter() + .map(|binding| binding.into()) + .collect()), version => Err(ProtocolError::UnknownVersionMismatch { method: "data_trigger_bindings".to_string(), - known_versions: vec![0, 1], + known_versions: vec![0, 1, 2], received: version, }), } diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/data_triggers/bindings/list/v2/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/data_triggers/bindings/list/v2/mod.rs new file mode 100644 index 00000000000..4b7963db392 --- /dev/null +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/data_triggers/bindings/list/v2/mod.rs @@ -0,0 +1,108 @@ +use crate::execution::validation::state_transition::batch::data_triggers::bindings::data_trigger_binding::DataTriggerBindingV0; +use crate::execution::validation::state_transition::batch::data_triggers::triggers::dashpay::{create_contact_request_data_trigger, validate_profile_payment_addresses_data_trigger}; +use crate::execution::validation::state_transition::batch::data_triggers::triggers::dpns::create_domain_data_trigger; +use crate::execution::validation::state_transition::batch::data_triggers::triggers::reject::reject_data_trigger; +use crate::execution::validation::state_transition::batch::data_triggers::triggers::withdrawals::delete_withdrawal_data_trigger; + +use dpp::errors::ProtocolError; +use dpp::system_data_contracts::withdrawals_contract::v1::document_types::withdrawal; +use dpp::system_data_contracts::{dashpay_contract, dpns_contract, SystemDataContract}; +use drive::state_transition_action::batch::batched_transition::document_transition::DocumentTransitionActionType; + +/// Retrieves a list of data triggers binding with matching params. +/// +/// This function gets all known data triggers which are then returned +/// as a vector of `DataTrigger` structs. +/// +/// v2 (PROTOCOL_VERSION_14): DashPay `profile` documents gain Create and +/// Replace triggers enforcing the DIP-33 payment-address type byte +/// (`0x00` P2PKH / `0x01` P2SH) that the schema vocabulary cannot express. +/// Everything else is unchanged from v1. +/// +/// # Returns +/// +/// A `Vec` containing all known data triggers. +/// +/// # Errors +/// +/// Returns a `ProtocolError` if there was an error. +#[inline(always)] +pub(super) fn data_trigger_bindings_list_v2() -> Result, ProtocolError> { + let data_triggers = vec![ + DataTriggerBindingV0 { + data_contract_id: dpns_contract::ID, + document_type: "domain".to_string(), + transition_action_type: DocumentTransitionActionType::Create, + data_trigger: create_domain_data_trigger, + }, + // Domain documents can never be modified or deleted, but since + // protocol version 13 they can be transferred and sold + DataTriggerBindingV0 { + data_contract_id: dpns_contract::ID, + document_type: "domain".to_string(), + transition_action_type: DocumentTransitionActionType::Replace, + data_trigger: reject_data_trigger, + }, + DataTriggerBindingV0 { + data_contract_id: dpns_contract::ID, + document_type: "domain".to_string(), + transition_action_type: DocumentTransitionActionType::Delete, + data_trigger: reject_data_trigger, + }, + DataTriggerBindingV0 { + data_contract_id: dashpay_contract::ID, + document_type: "contactRequest".to_string(), + transition_action_type: DocumentTransitionActionType::Create, + data_trigger: create_contact_request_data_trigger, + }, + // DIP-33 payment address fields must carry a supported type byte + DataTriggerBindingV0 { + data_contract_id: dashpay_contract::ID, + document_type: "profile".to_string(), + transition_action_type: DocumentTransitionActionType::Create, + data_trigger: validate_profile_payment_addresses_data_trigger, + }, + DataTriggerBindingV0 { + data_contract_id: dashpay_contract::ID, + document_type: "profile".to_string(), + transition_action_type: DocumentTransitionActionType::Replace, + data_trigger: validate_profile_payment_addresses_data_trigger, + }, + // Only masternodes will be able to update it + DataTriggerBindingV0 { + data_contract_id: SystemDataContract::MasternodeRewards.id(), + document_type: "rewardShare".to_string(), + transition_action_type: DocumentTransitionActionType::Create, + data_trigger: reject_data_trigger, + }, + // Only masternodes will be able to update it + DataTriggerBindingV0 { + data_contract_id: SystemDataContract::MasternodeRewards.id(), + document_type: "rewardShare".to_string(), + transition_action_type: DocumentTransitionActionType::Replace, + data_trigger: reject_data_trigger, + }, + // Only masternodes will be able to update it + DataTriggerBindingV0 { + data_contract_id: SystemDataContract::MasternodeRewards.id(), + document_type: "rewardShare".to_string(), + transition_action_type: DocumentTransitionActionType::Delete, + data_trigger: reject_data_trigger, + }, + // We can't use mutability flag otherwise documents won't have revision + DataTriggerBindingV0 { + data_contract_id: SystemDataContract::Withdrawals.id(), + document_type: withdrawal::NAME.to_string(), + transition_action_type: DocumentTransitionActionType::Replace, + data_trigger: reject_data_trigger, + }, + DataTriggerBindingV0 { + data_contract_id: SystemDataContract::Withdrawals.id(), + document_type: withdrawal::NAME.to_string(), + transition_action_type: DocumentTransitionActionType::Delete, + data_trigger: delete_withdrawal_data_trigger, + }, + ]; + + Ok(data_triggers) +} diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/data_triggers/triggers/dashpay/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/data_triggers/triggers/dashpay/mod.rs index c782ac77381..c81debdf64f 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/data_triggers/triggers/dashpay/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/data_triggers/triggers/dashpay/mod.rs @@ -1,5 +1,6 @@ use crate::error::execution::ExecutionError; use crate::error::Error; +use crate::execution::validation::state_transition::batch::data_triggers::triggers::dashpay::profile_v0::validate_profile_payment_addresses_data_trigger_v0; use crate::execution::validation::state_transition::batch::data_triggers::triggers::dashpay::v0::create_contact_request_data_trigger_v0; use crate::execution::validation::state_transition::batch::data_triggers::triggers::dashpay::v1::create_contact_request_data_trigger_v1; use crate::execution::validation::state_transition::batch::data_triggers::{ @@ -8,6 +9,7 @@ use crate::execution::validation::state_transition::batch::data_triggers::{ use dpp::version::PlatformVersion; use drive::state_transition_action::batch::batched_transition::document_transition::DocumentTransitionAction; +mod profile_v0; mod v0; mod v1; @@ -34,3 +36,30 @@ pub fn create_contact_request_data_trigger( })), } } + +pub fn validate_profile_payment_addresses_data_trigger( + document_transition: &DocumentTransitionAction, + context: &mut DataTriggerExecutionContext<'_>, + platform_version: &PlatformVersion, +) -> Result { + match platform_version + .drive_abci + .validation_and_processing + .state_transitions + .batch_state_transition + .data_triggers + .triggers + .validate_profile_payment_addresses_data_trigger + { + 0 => validate_profile_payment_addresses_data_trigger_v0( + document_transition, + context, + platform_version, + ), + version => Err(Error::Execution(ExecutionError::UnknownVersionMismatch { + method: "validate_profile_payment_addresses_data_trigger".to_string(), + known_versions: vec![0], + received: version, + })), + } +} diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/data_triggers/triggers/dashpay/profile_v0/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/data_triggers/triggers/dashpay/profile_v0/mod.rs new file mode 100644 index 00000000000..53d35a343a0 --- /dev/null +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/data_triggers/triggers/dashpay/profile_v0/mod.rs @@ -0,0 +1,86 @@ +use crate::error::execution::ExecutionError; +use crate::error::Error; +use dpp::consensus::state::data_trigger::data_trigger_condition_error::DataTriggerConditionError; +use dpp::data_contract::accessors::v0::DataContractV0Getters; + +use dpp::platform_value::btreemap_extensions::BTreeValueMapHelper; +use dpp::ProtocolError; +use drive::state_transition_action::batch::batched_transition::document_transition::document_base_transition_action::DocumentBaseTransitionActionAccessorsV0; +use drive::state_transition_action::batch::batched_transition::document_transition::document_create_transition_action::DocumentCreateTransitionActionAccessorsV0; +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::DocumentTransitionAction; + +use crate::execution::types::state_transition_execution_context::StateTransitionExecutionContextMethodsV0; +use crate::execution::validation::state_transition::batch::data_triggers::{ + DataTriggerExecutionContext, DataTriggerExecutionResult, +}; +use dpp::version::PlatformVersion; + +/// DIP-33 profile payment address fields, storage form: type byte `0x00` +/// (P2PKH) or `0x01` (P2SH) followed by a 20-byte HASH160. +const PAYMENT_ADDRESS_FIELDS: [&str; 2] = ["corePaymentAddress", "platformPaymentAddress"]; + +/// Validates the DIP-33 payment address fields of a DashPay profile document +/// on creation and replacement. +/// +/// The schema constrains the fields to exactly 21 bytes; this trigger enforces +/// the remaining invariant the schema vocabulary cannot express: the leading +/// type byte must be `0x00` (P2PKH) or `0x01` (P2SH). Unlike a Base58Check +/// string, the storage form carries no checksum, so this check makes an +/// accepted value fully decodable as the advertised address type. +#[inline(always)] +pub(super) fn validate_profile_payment_addresses_data_trigger_v0( + document_transition: &DocumentTransitionAction, + context: &mut DataTriggerExecutionContext<'_>, + _platform_version: &PlatformVersion, +) -> Result { + let data_contract_fetch_info = document_transition.base().data_contract_fetch_info(); + let data_contract = &data_contract_fetch_info.contract; + let mut result = DataTriggerExecutionResult::default(); + + if context.state_transition_execution_context.in_dry_run() { + return Ok(result); + } + + let data = match document_transition { + DocumentTransitionAction::CreateAction(create_transition) => create_transition.data(), + DocumentTransitionAction::ReplaceAction(replace_transition) => replace_transition.data(), + _ => { + return Err(Error::Execution(ExecutionError::DataTriggerExecutionError( + format!( + "the Document Transition {} isn't 'CREATE' or 'REPLACE'", + document_transition.base().id() + ), + ))); + } + }; + + for field in PAYMENT_ADDRESS_FIELDS { + let Some(address_bytes) = data + .get_optional_binary_bytes(field) + .map_err(ProtocolError::ValueError)? + else { + continue; + }; + + if !matches!(address_bytes.first(), Some(0x00) | Some(0x01)) { + let err = DataTriggerConditionError::new( + data_contract.id(), + document_transition.base().id(), + format!( + "{field} must start with address type byte 0x00 (P2PKH) or 0x01 (P2SH), got {}", + address_bytes + .first() + .map(|byte| format!("0x{byte:02x}")) + .unwrap_or_else(|| "an empty value".to_string()), + ), + ); + + result.add_error(err); + + return Ok(result); + } + } + + Ok(result) +} diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/creation.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/creation.rs index 6c7bb06e665..a0a19039af8 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/creation.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/creation.rs @@ -91,6 +91,8 @@ mod creation_tests { ) .expect("expected a random document"); + set_valid_profile_payment_addresses(&mut document, profile); + document.set("avatarUrl", "http://test.com/bob.jpg".into()); let documents_batch_create_transition = @@ -141,6 +143,127 @@ mod creation_tests { .expect("expected to commit transaction"); } + #[tokio::test] + async fn should_enforce_profile_payment_address_type_bytes() { + use dpp::consensus::state::data_trigger::DataTriggerError; + use dpp::consensus::state::state_error::StateError; + + let platform_version = PlatformVersion::latest(); + let mut platform = TestPlatformBuilder::new() + .with_latest_protocol_version() + .build_with_mock_rpc() + .set_genesis_state(); + + let mut rng = StdRng::seed_from_u64(437); + + let platform_state = platform.state.load(); + + let (identity, signer, key) = setup_identity(&mut platform, 958, dash_to_credits!(0.1)); + + let dashpay = platform + .drive + .cache + .system_data_contracts + .load_dashpay(platform_version) + .expect("expected the dashpay system contract"); + let dashpay_contract = dashpay.clone(); + + let profile = dashpay_contract + .document_type_for_name("profile") + .expect("expected a profile document type"); + + // (field under test, leading type byte, accepted by the data trigger) + let cases = [ + ("corePaymentAddress", 0x00u8, true), + ("corePaymentAddress", 0x01, true), + ("corePaymentAddress", 0x02, false), + ("corePaymentAddress", 0x3a, false), + ("platformPaymentAddress", 0x00, true), + ("platformPaymentAddress", 0x01, true), + ("platformPaymentAddress", 0x14, false), + ("platformPaymentAddress", 0xff, false), + ]; + + for (field, leading_byte, expect_valid) in cases { + 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, + platform_version, + ) + .expect("expected a random document"); + + // start from valid values for both fields, then set the case under test + set_valid_profile_payment_addresses(&mut document, profile); + + let mut address = vec![leading_byte]; + address.extend([0u8; 20]); + document.set(field, address.into()); + document.set("avatarUrl", "http://test.com/bob.jpg".into()); + + let documents_batch_create_transition = + BatchTransition::new_document_creation_transition_from_document( + document, + profile, + entropy.0, + &key, + 2, + 0, + None, + &signer, + platform_version, + None, + ) + .await + .expect("expect to create documents batch transition"); + + let documents_batch_create_serialized_transition = documents_batch_create_transition + .serialize_to_bytes() + .expect("expected documents batch serialized state transition"); + + // each case runs in its own discarded transaction, so state and + // identity nonces are untouched between cases + let transaction = platform.drive.grove.start_transaction(); + + let processing_result = platform + .platform + .process_raw_state_transitions( + &vec![documents_batch_create_serialized_transition.clone()], + &platform_state, + &BlockInfo::default(), + &transaction, + platform_version, + false, + None, + ) + .expect("expected to process state transition"); + + if expect_valid { + assert_matches!( + processing_result.execution_results().as_slice(), + [StateTransitionExecutionResult::SuccessfulExecution { .. }], + "{field} with type byte 0x{leading_byte:02x} must be accepted" + ); + } else { + assert_matches!( + processing_result.execution_results().as_slice(), + [StateTransitionExecutionResult::PaidConsensusError { + error: ConsensusError::StateError(StateError::DataTriggerError( + DataTriggerError::DataTriggerConditionError(_) + )), + .. + }], + "{field} with type byte 0x{leading_byte:02x} must be rejected" + ); + } + } + } + #[tokio::test] async fn test_document_creation_should_fail_when_creator_id_is_provided() { let platform_version = PlatformVersion::latest(); @@ -281,6 +404,8 @@ mod creation_tests { ) .expect("expected a random document"); + set_valid_profile_payment_addresses(&mut document, profile); + document.set("avatarUrl", "http://test.com/bob.jpg".into()); let documents_batch_create_transition = @@ -343,6 +468,8 @@ mod creation_tests { ) .expect("expected a random document"); + set_valid_profile_payment_addresses(&mut document, profile); + document.set("avatarUrl", "http://test.com/coy.jpg".into()); let documents_batch_create_transition = @@ -441,6 +568,8 @@ mod creation_tests { ) .expect("expected a random document"); + set_valid_profile_payment_addresses(&mut document, profile); + let max_field_size = platform_version.system_limits.max_field_value_size; let avatar_size = max_field_size + 1000; diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/deletion.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/deletion.rs index 70df01a6e03..f69dfbaf868 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/deletion.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/deletion.rs @@ -10,7 +10,7 @@ mod deletion_tests { async fn test_document_delete_on_document_type_that_is_mutable_and_can_be_deleted() { run_document_delete_on_document_type_that_is_mutable_and_can_be_deleted_at_protocol_version( PlatformVersion::latest().protocol_version, - 1678920, + 1699160, ) .await; } @@ -78,6 +78,8 @@ mod deletion_tests { ) .expect("expected a random document"); + set_valid_profile_payment_addresses(&mut document, profile); + document.set("avatarUrl", "http://test.com/bob.jpg".into()); let mut altered_document = document.clone(); @@ -789,6 +791,8 @@ mod deletion_tests { ) .expect("expected a random document"); + set_valid_profile_payment_addresses(&mut document, profile); + document.set("avatarUrl", "http://test.com/bob.jpg".into()); let mut altered_document = document.clone(); diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/replacement.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/replacement.rs index d9feceec18e..ec5a3d79634 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/replacement.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/document/replacement.rs @@ -13,7 +13,7 @@ mod replacement_tests { async fn test_document_replace_on_document_type_that_is_mutable() { run_document_replace_on_document_type_that_is_mutable_at_protocol_version( PlatformVersion::latest().protocol_version, - 1411320, + 1432760, ) .await; } @@ -74,6 +74,8 @@ mod replacement_tests { ) .expect("expected a random document"); + set_valid_profile_payment_addresses(&mut document, profile); + document.set("avatarUrl", "http://test.com/bob.jpg".into()); let mut altered_document = document.clone(); @@ -241,6 +243,8 @@ mod replacement_tests { ) .expect("expected a random document"); + set_valid_profile_payment_addresses(&mut document, profile); + document.set("displayName", original_name.into()); document.set("avatarUrl", "http://test.com/bob.jpg".into()); @@ -785,6 +789,8 @@ mod replacement_tests { platform_version, ) .expect("expected a random document"); + + set_valid_profile_payment_addresses(&mut document, profile); // Random fillers can produce a non-URI avatarUrl that fails JSON-schema // validation on Create. Pin it to a valid URI like the sibling tests do. document.set("avatarUrl", "http://test.com/bob.jpg".into()); @@ -1240,6 +1246,8 @@ mod replacement_tests { ) .expect("expected a random document"); + set_valid_profile_payment_addresses(&mut document, profile); + document.set("avatarUrl", "http://test.com/bob.jpg".into()); let mut altered_document = document.clone(); @@ -1371,6 +1379,8 @@ mod replacement_tests { ) .expect("expected a random document"); + set_valid_profile_payment_addresses(&mut document, profile); + document.set("avatarUrl", "http://test.com/bob.jpg".into()); let mut altered_document = document.clone(); @@ -1448,7 +1458,7 @@ mod replacement_tests { .first() .expect("expected a document"); - assert_eq!(document.to_string(), "v0 : id:GcviwUsEr9Ji4rCrnnsgmVAghNaVPDumsfcagvBbBy45 owner_id:CisQdz2ej7EwWv8JbetSXBNsV4xsf8QsSS8tqp4tEf7V created_at:1970-01-14 21:20:00 updated_at:1970-01-14 21:20:00 avatarFingerprint:bytes d7b0e2b357c10312 avatarHash:bytes32 YonaRoE0hMgat53AYt5LTlQlIkKLReGpB7xNAqJ5HM8= avatarUrl:string http://test.com/bob.[...(23)] displayName:string QBwBNNXXYCngB0er publicMessage:string 8XG7KBGNvm2 "); + assert_eq!(document.to_string(), "v0 : id:GcviwUsEr9Ji4rCrnnsgmVAghNaVPDumsfcagvBbBy45 owner_id:CisQdz2ej7EwWv8JbetSXBNsV4xsf8QsSS8tqp4tEf7V created_at:1970-01-14 21:20:00 updated_at:1970-01-14 21:20:00 avatarFingerprint:bytes d7b0e2b357c10312 avatarHash:bytes32 YonaRoE0hMgat53AYt5LTlQlIkKLReGpB7xNAqJ5HM8= avatarUrl:string http://test.com/bob.[...(23)] corePaymentAddress:bytes 000000000000000000000000000000000000000000 displayName:string QBwBNNXXYCngB0er platformPaymentAddress:bytes 010000000000000000000000000000000000000000 publicMessage:string 8XG7KBGNvm2 "); let documents_batch_update_transition_1 = BatchTransition::new_document_replacement_transition_from_document( @@ -1529,7 +1539,7 @@ mod replacement_tests { .first() .expect("expected a document"); - assert_eq!(document.to_string(), "v0 : id:GcviwUsEr9Ji4rCrnnsgmVAghNaVPDumsfcagvBbBy45 owner_id:CisQdz2ej7EwWv8JbetSXBNsV4xsf8QsSS8tqp4tEf7V created_at:1970-01-14 21:20:00 updated_at:1970-01-14 21:20:00 avatarFingerprint:bytes d7b0e2b357c10312 avatarHash:bytes32 YonaRoE0hMgat53AYt5LTlQlIkKLReGpB7xNAqJ5HM8= avatarUrl:string http://test.com/drap[...(26)] displayName:string Ody publicMessage:string 8XG7KBGNvm2 "); + assert_eq!(document.to_string(), "v0 : id:GcviwUsEr9Ji4rCrnnsgmVAghNaVPDumsfcagvBbBy45 owner_id:CisQdz2ej7EwWv8JbetSXBNsV4xsf8QsSS8tqp4tEf7V created_at:1970-01-14 21:20:00 updated_at:1970-01-14 21:20:00 avatarFingerprint:bytes d7b0e2b357c10312 avatarHash:bytes32 YonaRoE0hMgat53AYt5LTlQlIkKLReGpB7xNAqJ5HM8= avatarUrl:string http://test.com/drap[...(26)] corePaymentAddress:bytes 000000000000000000000000000000000000000000 displayName:string Ody platformPaymentAddress:bytes 010000000000000000000000000000000000000000 publicMessage:string 8XG7KBGNvm2 "); let issues = platform .drive @@ -1592,6 +1602,8 @@ mod replacement_tests { ) .expect("expected a random document"); + set_valid_profile_payment_addresses(&mut document, profile); + document.set("avatarUrl", "http://test.com/bob.jpg".into()); let mut altered_document = document.clone(); @@ -1669,7 +1681,7 @@ mod replacement_tests { .first() .expect("expected a document"); - assert_eq!(document.to_string(), "v0 : id:GcviwUsEr9Ji4rCrnnsgmVAghNaVPDumsfcagvBbBy45 owner_id:CisQdz2ej7EwWv8JbetSXBNsV4xsf8QsSS8tqp4tEf7V created_at:1970-01-14 21:20:00 updated_at:1970-01-14 21:20:00 avatarFingerprint:bytes d7b0e2b357c10312 avatarHash:bytes32 YonaRoE0hMgat53AYt5LTlQlIkKLReGpB7xNAqJ5HM8= avatarUrl:string http://test.com/bob.[...(23)] displayName:string QBwBNNXXYCngB0er publicMessage:string 8XG7KBGNvm2 "); + assert_eq!(document.to_string(), "v0 : id:GcviwUsEr9Ji4rCrnnsgmVAghNaVPDumsfcagvBbBy45 owner_id:CisQdz2ej7EwWv8JbetSXBNsV4xsf8QsSS8tqp4tEf7V created_at:1970-01-14 21:20:00 updated_at:1970-01-14 21:20:00 avatarFingerprint:bytes d7b0e2b357c10312 avatarHash:bytes32 YonaRoE0hMgat53AYt5LTlQlIkKLReGpB7xNAqJ5HM8= avatarUrl:string http://test.com/bob.[...(23)] corePaymentAddress:bytes 000000000000000000000000000000000000000000 displayName:string QBwBNNXXYCngB0er platformPaymentAddress:bytes 010000000000000000000000000000000000000000 publicMessage:string 8XG7KBGNvm2 "); fast_forward_to_block(&platform, 1_400_000_000, 901, 43, 1, false); //next epoch @@ -1751,7 +1763,7 @@ mod replacement_tests { .first() .expect("expected a document"); - assert_eq!(document.to_string(), "v0 : id:GcviwUsEr9Ji4rCrnnsgmVAghNaVPDumsfcagvBbBy45 owner_id:CisQdz2ej7EwWv8JbetSXBNsV4xsf8QsSS8tqp4tEf7V created_at:1970-01-14 21:20:00 updated_at:1970-01-17 04:53:20 avatarFingerprint:bytes d7b0e2b357c10312 avatarHash:bytes32 YonaRoE0hMgat53AYt5LTlQlIkKLReGpB7xNAqJ5HM8= avatarUrl:string http://test.com/cat.[...(23)] displayName:string Samuel publicMessage:string 8XG7KBGNvm2 "); + assert_eq!(document.to_string(), "v0 : id:GcviwUsEr9Ji4rCrnnsgmVAghNaVPDumsfcagvBbBy45 owner_id:CisQdz2ej7EwWv8JbetSXBNsV4xsf8QsSS8tqp4tEf7V created_at:1970-01-14 21:20:00 updated_at:1970-01-17 04:53:20 avatarFingerprint:bytes d7b0e2b357c10312 avatarHash:bytes32 YonaRoE0hMgat53AYt5LTlQlIkKLReGpB7xNAqJ5HM8= avatarUrl:string http://test.com/cat.[...(23)] corePaymentAddress:bytes 000000000000000000000000000000000000000000 displayName:string Samuel platformPaymentAddress:bytes 010000000000000000000000000000000000000000 publicMessage:string 8XG7KBGNvm2 "); fast_forward_to_block(&platform, 1_600_000_000, 902, 44, 1, false); //next epoch @@ -1795,7 +1807,7 @@ mod replacement_tests { .first() .expect("expected a document"); - assert_eq!(document.to_string(), "v0 : id:GcviwUsEr9Ji4rCrnnsgmVAghNaVPDumsfcagvBbBy45 owner_id:CisQdz2ej7EwWv8JbetSXBNsV4xsf8QsSS8tqp4tEf7V created_at:1970-01-14 21:20:00 updated_at:1970-01-19 12:26:40 avatarFingerprint:bytes d7b0e2b357c10312 avatarHash:bytes32 YonaRoE0hMgat53AYt5LTlQlIkKLReGpB7xNAqJ5HM8= avatarUrl:string http://test.com/drap[...(26)] displayName:string Ody publicMessage:string 8XG7KBGNvm2 "); + assert_eq!(document.to_string(), "v0 : id:GcviwUsEr9Ji4rCrnnsgmVAghNaVPDumsfcagvBbBy45 owner_id:CisQdz2ej7EwWv8JbetSXBNsV4xsf8QsSS8tqp4tEf7V created_at:1970-01-14 21:20:00 updated_at:1970-01-19 12:26:40 avatarFingerprint:bytes d7b0e2b357c10312 avatarHash:bytes32 YonaRoE0hMgat53AYt5LTlQlIkKLReGpB7xNAqJ5HM8= avatarUrl:string http://test.com/drap[...(26)] corePaymentAddress:bytes 000000000000000000000000000000000000000000 displayName:string Ody platformPaymentAddress:bytes 010000000000000000000000000000000000000000 publicMessage:string 8XG7KBGNvm2 "); let issues = platform .drive @@ -1858,6 +1870,8 @@ mod replacement_tests { ) .expect("expected a random document"); + set_valid_profile_payment_addresses(&mut document, profile); + document.set("avatarUrl", "http://test.com/bob.jpg".into()); let mut altered_document = document.clone(); @@ -1931,7 +1945,7 @@ mod replacement_tests { .first() .expect("expected a document"); - assert_eq!(document.to_string(), "v0 : id:GcviwUsEr9Ji4rCrnnsgmVAghNaVPDumsfcagvBbBy45 owner_id:CisQdz2ej7EwWv8JbetSXBNsV4xsf8QsSS8tqp4tEf7V created_at:1970-01-14 21:20:00 updated_at:1970-01-14 21:20:00 avatarFingerprint:bytes d7b0e2b357c10312 avatarHash:bytes32 YonaRoE0hMgat53AYt5LTlQlIkKLReGpB7xNAqJ5HM8= avatarUrl:string http://test.com/bob.[...(23)] displayName:string QBwBNNXXYCngB0er publicMessage:string 8XG7KBGNvm2 "); + assert_eq!(document.to_string(), "v0 : id:GcviwUsEr9Ji4rCrnnsgmVAghNaVPDumsfcagvBbBy45 owner_id:CisQdz2ej7EwWv8JbetSXBNsV4xsf8QsSS8tqp4tEf7V created_at:1970-01-14 21:20:00 updated_at:1970-01-14 21:20:00 avatarFingerprint:bytes d7b0e2b357c10312 avatarHash:bytes32 YonaRoE0hMgat53AYt5LTlQlIkKLReGpB7xNAqJ5HM8= avatarUrl:string http://test.com/bob.[...(23)] corePaymentAddress:bytes 000000000000000000000000000000000000000000 displayName:string QBwBNNXXYCngB0er platformPaymentAddress:bytes 010000000000000000000000000000000000000000 publicMessage:string 8XG7KBGNvm2 "); fast_forward_to_block(&platform, 1_400_000_000, 901, 43, 1, false); //next epoch @@ -2013,7 +2027,7 @@ mod replacement_tests { .first() .expect("expected a document"); - assert_eq!(document.to_string(), "v0 : id:GcviwUsEr9Ji4rCrnnsgmVAghNaVPDumsfcagvBbBy45 owner_id:CisQdz2ej7EwWv8JbetSXBNsV4xsf8QsSS8tqp4tEf7V created_at:1970-01-14 21:20:00 updated_at:1970-01-17 04:53:20 avatarFingerprint:bytes d7b0e2b357c10312 avatarHash:bytes32 YonaRoE0hMgat53AYt5LTlQlIkKLReGpB7xNAqJ5HM8= avatarUrl:string http://test.com/bob.[...(23)] displayName:string QBwBNNXXYCngB0er publicMessage:string 8XG7KBGNvm2 "); + assert_eq!(document.to_string(), "v0 : id:GcviwUsEr9Ji4rCrnnsgmVAghNaVPDumsfcagvBbBy45 owner_id:CisQdz2ej7EwWv8JbetSXBNsV4xsf8QsSS8tqp4tEf7V created_at:1970-01-14 21:20:00 updated_at:1970-01-17 04:53:20 avatarFingerprint:bytes d7b0e2b357c10312 avatarHash:bytes32 YonaRoE0hMgat53AYt5LTlQlIkKLReGpB7xNAqJ5HM8= avatarUrl:string http://test.com/bob.[...(23)] corePaymentAddress:bytes 000000000000000000000000000000000000000000 displayName:string QBwBNNXXYCngB0er platformPaymentAddress:bytes 010000000000000000000000000000000000000000 publicMessage:string 8XG7KBGNvm2 "); fast_forward_to_block(&platform, 1_600_000_000, 902, 44, 1, false); //next epoch @@ -2057,7 +2071,7 @@ mod replacement_tests { .first() .expect("expected a document"); - assert_eq!(document.to_string(), "v0 : id:GcviwUsEr9Ji4rCrnnsgmVAghNaVPDumsfcagvBbBy45 owner_id:CisQdz2ej7EwWv8JbetSXBNsV4xsf8QsSS8tqp4tEf7V created_at:1970-01-14 21:20:00 updated_at:1970-01-19 12:26:40 avatarFingerprint:bytes d7b0e2b357c10312 avatarHash:bytes32 YonaRoE0hMgat53AYt5LTlQlIkKLReGpB7xNAqJ5HM8= avatarUrl:string http://test.com/bob.[...(23)] displayName:string QBwBNNXXYCngB0er publicMessage:string 8XG7KBGNvm2 "); + assert_eq!(document.to_string(), "v0 : id:GcviwUsEr9Ji4rCrnnsgmVAghNaVPDumsfcagvBbBy45 owner_id:CisQdz2ej7EwWv8JbetSXBNsV4xsf8QsSS8tqp4tEf7V created_at:1970-01-14 21:20:00 updated_at:1970-01-19 12:26:40 avatarFingerprint:bytes d7b0e2b357c10312 avatarHash:bytes32 YonaRoE0hMgat53AYt5LTlQlIkKLReGpB7xNAqJ5HM8= avatarUrl:string http://test.com/bob.[...(23)] corePaymentAddress:bytes 000000000000000000000000000000000000000000 displayName:string QBwBNNXXYCngB0er platformPaymentAddress:bytes 010000000000000000000000000000000000000000 publicMessage:string 8XG7KBGNvm2 "); let issues = platform .drive @@ -2120,6 +2134,8 @@ mod replacement_tests { ) .expect("expected a random document"); + set_valid_profile_payment_addresses(&mut document, profile); + document.set("avatarUrl", "http://test.com/bob.jpg".into()); let mut altered_document = document.clone(); @@ -2197,7 +2213,7 @@ mod replacement_tests { .first() .expect("expected a document"); - assert_eq!(document.to_string(), "v0 : id:GcviwUsEr9Ji4rCrnnsgmVAghNaVPDumsfcagvBbBy45 owner_id:CisQdz2ej7EwWv8JbetSXBNsV4xsf8QsSS8tqp4tEf7V created_at:1970-01-14 21:20:00 updated_at:1970-01-14 21:20:00 avatarFingerprint:bytes d7b0e2b357c10312 avatarHash:bytes32 YonaRoE0hMgat53AYt5LTlQlIkKLReGpB7xNAqJ5HM8= avatarUrl:string http://test.com/bob.[...(23)] displayName:string QBwBNNXXYCngB0er publicMessage:string 8XG7KBGNvm2 "); + assert_eq!(document.to_string(), "v0 : id:GcviwUsEr9Ji4rCrnnsgmVAghNaVPDumsfcagvBbBy45 owner_id:CisQdz2ej7EwWv8JbetSXBNsV4xsf8QsSS8tqp4tEf7V created_at:1970-01-14 21:20:00 updated_at:1970-01-14 21:20:00 avatarFingerprint:bytes d7b0e2b357c10312 avatarHash:bytes32 YonaRoE0hMgat53AYt5LTlQlIkKLReGpB7xNAqJ5HM8= avatarUrl:string http://test.com/bob.[...(23)] corePaymentAddress:bytes 000000000000000000000000000000000000000000 displayName:string QBwBNNXXYCngB0er platformPaymentAddress:bytes 010000000000000000000000000000000000000000 publicMessage:string 8XG7KBGNvm2 "); fast_forward_to_block(&platform, 1_400_000_000, 901, 43, 1, false); //next epoch @@ -2279,7 +2295,7 @@ mod replacement_tests { .first() .expect("expected a document"); - assert_eq!(document.to_string(), "v0 : id:GcviwUsEr9Ji4rCrnnsgmVAghNaVPDumsfcagvBbBy45 owner_id:CisQdz2ej7EwWv8JbetSXBNsV4xsf8QsSS8tqp4tEf7V created_at:1970-01-14 21:20:00 updated_at:1970-01-17 04:53:20 avatarFingerprint:bytes d7b0e2b357c10312 avatarHash:bytes32 YonaRoE0hMgat53AYt5LTlQlIkKLReGpB7xNAqJ5HM8= avatarUrl:string http://test.com/cat.[...(23)] displayName:string Samuel publicMessage:string 8XG7KBGNvm2 "); + assert_eq!(document.to_string(), "v0 : id:GcviwUsEr9Ji4rCrnnsgmVAghNaVPDumsfcagvBbBy45 owner_id:CisQdz2ej7EwWv8JbetSXBNsV4xsf8QsSS8tqp4tEf7V created_at:1970-01-14 21:20:00 updated_at:1970-01-17 04:53:20 avatarFingerprint:bytes d7b0e2b357c10312 avatarHash:bytes32 YonaRoE0hMgat53AYt5LTlQlIkKLReGpB7xNAqJ5HM8= avatarUrl:string http://test.com/cat.[...(23)] corePaymentAddress:bytes 000000000000000000000000000000000000000000 displayName:string Samuel platformPaymentAddress:bytes 010000000000000000000000000000000000000000 publicMessage:string 8XG7KBGNvm2 "); fast_forward_to_block(&platform, 1_600_000_000, 905, 44, 2, true); //next epoch @@ -2323,7 +2339,7 @@ mod replacement_tests { .first() .expect("expected a document"); - assert_eq!(document.to_string(), "v0 : id:GcviwUsEr9Ji4rCrnnsgmVAghNaVPDumsfcagvBbBy45 owner_id:CisQdz2ej7EwWv8JbetSXBNsV4xsf8QsSS8tqp4tEf7V created_at:1970-01-14 21:20:00 updated_at:1970-01-19 12:26:40 avatarFingerprint:bytes d7b0e2b357c10312 avatarHash:bytes32 YonaRoE0hMgat53AYt5LTlQlIkKLReGpB7xNAqJ5HM8= avatarUrl:string http://test.com/drap[...(26)] displayName:string Ody publicMessage:string 8XG7KBGNvm2 "); + assert_eq!(document.to_string(), "v0 : id:GcviwUsEr9Ji4rCrnnsgmVAghNaVPDumsfcagvBbBy45 owner_id:CisQdz2ej7EwWv8JbetSXBNsV4xsf8QsSS8tqp4tEf7V created_at:1970-01-14 21:20:00 updated_at:1970-01-19 12:26:40 avatarFingerprint:bytes d7b0e2b357c10312 avatarHash:bytes32 YonaRoE0hMgat53AYt5LTlQlIkKLReGpB7xNAqJ5HM8= avatarUrl:string http://test.com/drap[...(26)] corePaymentAddress:bytes 000000000000000000000000000000000000000000 displayName:string Ody platformPaymentAddress:bytes 010000000000000000000000000000000000000000 publicMessage:string 8XG7KBGNvm2 "); let issues = platform .drive 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 50a2d030648..41ddaad8029 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 @@ -1,6 +1,22 @@ mod document; mod token; +/// Sets valid DIP-33 payment addresses on a random profile document when the +/// schema version carries the fields (dashpay v2, protocol version 14+): the +/// generator fills them with random bytes, and the data trigger requires a +/// valid leading type byte (0x00 P2PKH / 0x01 P2SH). +pub(crate) fn set_valid_profile_payment_addresses( + document: &mut dpp::document::Document, + profile: dpp::data_contract::document_type::DocumentTypeRef, +) { + if profile.properties().contains_key("corePaymentAddress") { + document.set("corePaymentAddress", vec![0u8; 21].into()); + let mut platform_payment_address = vec![1u8]; + platform_payment_address.extend([0u8; 20]); + document.set("platformPaymentAddress", platform_payment_address.into()); + } +} + use crate::platform_types::platform_state::PlatformStateV0Methods; use dpp::data_contract::accessors::v0::DataContractV0Getters; 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..93436c7c03d 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 @@ -1845,4 +1845,388 @@ mod tests { [StateTransitionExecutionResult::PaidConsensusError { .. }] ); } + + /// State-validation coverage for the DIP-33 at-most-one-active-payment-key + /// invariant (`validate_payment_key_uniqueness`), including the rotation + /// exception and the protocol-version gate. + mod payment_key_uniqueness { + use super::*; + use crate::rpc::core::MockCoreRPCLike; + use crate::test::helpers::setup::TempPlatform; + use dpp::consensus::basic::BasicError; + use dpp::identity::{IdentityPublicKey, KeyID}; + use simple_signer::signer::SimpleSigner; + + /// Build, sign, and process an identity update that adds the given + /// payment-purpose keys (each freshly generated and self-signed) and + /// disables the given key ids. Commits on completion so sequential + /// updates in one test observe each other's state. + #[allow(clippy::too_many_arguments)] + async fn process_update( + platform: &TempPlatform, + identity_id: Identifier, + signer: &SimpleSigner, + master_key: &IdentityPublicKey, + revision: u64, + nonce: u64, + add: &[(KeyID, Purpose, u64)], + disable_public_keys: Vec, + platform_version: &PlatformVersion, + ) -> Vec { + let secp = Secp256k1::new(); + + let keypairs: Vec<(KeyID, Purpose, Keypair)> = add + .iter() + .map(|(key_id, purpose, seed)| { + let mut rng = StdRng::seed_from_u64(*seed); + (*key_id, *purpose, Keypair::new(&secp, &mut rng)) + }) + .collect(); + + let build_keys = |signatures: Option<&Vec>>| { + keypairs + .iter() + .enumerate() + .map(|(i, (key_id, purpose, keypair))| { + IdentityPublicKeyInCreation::V0(IdentityPublicKeyInCreationV0 { + id: *key_id, + purpose: *purpose, + security_level: SecurityLevel::MEDIUM, + key_type: ECDSA_SECP256K1, + read_only: false, + data: keypair.public_key().serialize().to_vec().into(), + signature: signatures + .map(|sigs| sigs[i].clone().into()) + .unwrap_or_default(), + contract_bounds: None, + }) + }) + .collect::>() + }; + + let build_transition = |add_public_keys: Vec| { + let transition: IdentityUpdateTransition = IdentityUpdateTransitionV0 { + identity_id, + revision, + nonce, + add_public_keys, + disable_public_keys: disable_public_keys.clone(), + user_fee_increase: 0, + signature_public_key_id: master_key.id(), + signature: Default::default(), + } + .into(); + let transition: StateTransition = transition.into(); + transition + }; + + // added-key signatures are over the transition's signable bytes, + // which exclude those signatures themselves — two-pass build + let signable_bytes = build_transition(build_keys(None)) + .signable_bytes() + .expect("expected signable bytes"); + let signatures: Vec> = keypairs + .iter() + .map(|(_, _, keypair)| { + signer::sign(&signable_bytes, &keypair.secret_key().secret_bytes()) + .expect("expected to sign added key") + .to_vec() + }) + .collect(); + + let mut transition = build_transition(build_keys(Some(&signatures))); + transition.set_signature( + signer + .sign(master_key, signable_bytes.as_slice()) + .await + .expect("expected to sign"), + ); + + let transition_bytes = transition + .serialize_to_bytes() + .expect("expected to serialize"); + + let platform_state = platform.state.load(); + let transaction = platform.drive.grove.start_transaction(); + let processing_result = platform + .platform + .process_raw_state_transitions( + &vec![transition_bytes], + &platform_state, + &BlockInfo::default(), + &transaction, + platform_version, + true, + None, + ) + .expect("expected to process state transition"); + platform + .drive + .grove + .commit_transaction(transaction) + .unwrap() + .expect("expected to commit"); + + processing_result.into_execution_results() + } + + /// Install an already-valid payment key directly into state as a test + /// precondition (bypassing the pipeline). + fn install_payment_key( + platform: &TempPlatform, + identity_id: Identifier, + key_id: KeyID, + purpose: Purpose, + seed: u64, + platform_version: &PlatformVersion, + ) -> IdentityPublicKey { + let mut rng = StdRng::seed_from_u64(seed); + let (key, _) = IdentityPublicKey::random_key_with_known_attributes( + key_id, + &mut rng, + purpose, + SecurityLevel::MEDIUM, + ECDSA_SECP256K1, + None, + platform_version, + ) + .expect("expected key"); + platform + .drive + .add_new_unique_keys_to_identity( + identity_id.to_buffer(), + vec![key.clone()], + &BlockInfo::default(), + true, + None, + platform_version, + ) + .expect("expected to add key to state"); + key + } + + #[tokio::test] + async fn test_adding_first_payment_scan_key_is_valid() { + let platform_version = PlatformVersion::latest(); + let mut platform = TestPlatformBuilder::new() + .build_with_mock_rpc() + .set_genesis_state(); + let (identity, signer, _, master_key) = + setup_identity_return_master_key(&mut platform, 958, dash_to_credits!(0.1)); + + let results = process_update( + &platform, + identity.id(), + &signer, + &master_key, + 1, + 1, + &[(10, Purpose::PAYMENT_SCAN, 4001)], + vec![], + platform_version, + ) + .await; + assert_matches!( + results.as_slice(), + [StateTransitionExecutionResult::SuccessfulExecution { .. }] + ); + } + + #[tokio::test] + async fn test_adding_payment_key_conflicts_with_existing_active_key() { + let platform_version = PlatformVersion::latest(); + let mut platform = TestPlatformBuilder::new() + .build_with_mock_rpc() + .set_genesis_state(); + let (identity, signer, _, master_key) = + setup_identity_return_master_key(&mut platform, 958, dash_to_credits!(0.1)); + install_payment_key( + &platform, + identity.id(), + 10, + Purpose::PAYMENT_SCAN, + 4002, + platform_version, + ); + + let results = process_update( + &platform, + identity.id(), + &signer, + &master_key, + 1, + 1, + &[(11, Purpose::PAYMENT_SCAN, 4003)], + vec![], + platform_version, + ) + .await; + assert_matches!( + results.as_slice(), + [StateTransitionExecutionResult::PaidConsensusError { + error: ConsensusError::BasicError(BasicError::TooManyPublicKeysOfPurposeError( + _ + )), + .. + }] + ); + } + + #[tokio::test] + async fn test_rotating_payment_key_by_disabling_in_same_transition_is_valid() { + let platform_version = PlatformVersion::latest(); + let mut platform = TestPlatformBuilder::new() + .build_with_mock_rpc() + .set_genesis_state(); + let (identity, signer, _, master_key) = + setup_identity_return_master_key(&mut platform, 958, dash_to_credits!(0.1)); + let old_key = install_payment_key( + &platform, + identity.id(), + 10, + Purpose::PAYMENT_SCAN, + 4004, + platform_version, + ); + + let results = process_update( + &platform, + identity.id(), + &signer, + &master_key, + 1, + 1, + &[(11, Purpose::PAYMENT_SCAN, 4005)], + vec![old_key.id()], + platform_version, + ) + .await; + assert_matches!( + results.as_slice(), + [StateTransitionExecutionResult::SuccessfulExecution { .. }] + ); + } + + #[tokio::test] + async fn test_previously_disabled_payment_key_does_not_block_replacement() { + let platform_version = PlatformVersion::latest(); + let mut platform = TestPlatformBuilder::new() + .build_with_mock_rpc() + .set_genesis_state(); + let (identity, signer, _, master_key) = + setup_identity_return_master_key(&mut platform, 958, dash_to_credits!(0.1)); + let old_key = install_payment_key( + &platform, + identity.id(), + 10, + Purpose::PAYMENT_SCAN, + 4006, + platform_version, + ); + + // first update: disable the old key only + let results = process_update( + &platform, + identity.id(), + &signer, + &master_key, + 1, + 1, + &[], + vec![old_key.id()], + platform_version, + ) + .await; + assert_matches!( + results.as_slice(), + [StateTransitionExecutionResult::SuccessfulExecution { .. }] + ); + + // second update: the disabled historical key must not block adding + // a replacement + let results = process_update( + &platform, + identity.id(), + &signer, + &master_key, + 2, + 2, + &[(11, Purpose::PAYMENT_SCAN, 4007)], + vec![], + platform_version, + ) + .await; + assert_matches!( + results.as_slice(), + [StateTransitionExecutionResult::SuccessfulExecution { .. }] + ); + } + + #[tokio::test] + async fn test_scan_and_spend_purposes_are_independent() { + let platform_version = PlatformVersion::latest(); + let mut platform = TestPlatformBuilder::new() + .build_with_mock_rpc() + .set_genesis_state(); + let (identity, signer, _, master_key) = + setup_identity_return_master_key(&mut platform, 958, dash_to_credits!(0.1)); + install_payment_key( + &platform, + identity.id(), + 10, + Purpose::PAYMENT_SCAN, + 4008, + platform_version, + ); + + let results = process_update( + &platform, + identity.id(), + &signer, + &master_key, + 1, + 1, + &[(11, Purpose::PAYMENT_SPEND, 4009)], + vec![], + platform_version, + ) + .await; + assert_matches!( + results.as_slice(), + [StateTransitionExecutionResult::SuccessfulExecution { .. }] + ); + } + + #[tokio::test] + async fn test_payment_keys_rejected_before_protocol_version_14() { + let platform_version = PlatformVersion::get(13).expect("expected version 13"); + let mut platform = TestPlatformBuilder::new() + .with_initial_protocol_version(13) + .build_with_mock_rpc() + .set_genesis_state(); + let (identity, signer, _, master_key) = + setup_identity_return_master_key(&mut platform, 958, dash_to_credits!(0.1)); + + let results = process_update( + &platform, + identity.id(), + &signer, + &master_key, + 1, + 1, + &[(10, Purpose::PAYMENT_SCAN, 4010)], + vec![], + platform_version, + ) + .await; + assert!( + !matches!( + results.as_slice(), + [StateTransitionExecutionResult::SuccessfulExecution { .. }] + ), + "payment keys must be rejected before protocol version 14, got: {:?}", + results + ); + } + } } 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..cc4565ff80f 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 @@ -20,6 +20,7 @@ use crate::execution::validation::state_transition::common::validate_identity_pu 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_payment_key_uniqueness::validate_payment_key_uniqueness_in_state; 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 IdentityUpdateStateTransitionStateValidationV0 @@ -120,6 +121,34 @@ impl IdentityUpdateStateTransitionStateValidationV0 for IdentityUpdateTransition )); } + // DIP-33 payment keys: the identity may end up with at most one active + // key of each payment purpose. This must run even when no keys are + // being disabled (a pure add-only update), unlike the master key + // uniqueness check below. + validation_result.add_errors( + validate_payment_key_uniqueness_in_state( + self.identity_id(), + self.public_keys_to_add(), + self.public_key_ids_to_disable(), + drive, + &mut 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, + )); + } + if !self.public_key_ids_to_disable().is_empty() { let validation_result_and_keys_to_disable = validate_identity_public_key_ids_exist_in_state( diff --git a/packages/rs-drive/src/cache/data_contract.rs b/packages/rs-drive/src/cache/data_contract.rs index f3dbbf3479d..8b658989a20 100644 --- a/packages/rs-drive/src/cache/data_contract.rs +++ b/packages/rs-drive/src/cache/data_contract.rs @@ -1,5 +1,6 @@ use crate::drive::contract::DataContractFetchInfo; use dpp::data_contract::accessors::v0::DataContractV0Getters; +use moka::ops::compute::Op; use moka::sync::Cache; use std::sync::Arc; @@ -20,14 +21,41 @@ impl DataContractCache { /// Inserts DataContract to block cache /// otherwise to goes to global cache + /// + /// The insert is skipped if the cache already holds the same contract at a + /// **higher** version. Contract versions increase strictly monotonically — + /// the data contract update transition enforces `new == old + 1`, and system + /// contract migrations bump the version — so an insert carrying a lower + /// version is always a delayed writer racing a newer copy in, never fresh + /// information. CONSENSUS-CRITICAL: a read-only query thread fetches from + /// committed state without a transaction and populates the global cache from + /// what it read. If such a thread reads a contract, gets descheduled while + /// block execution rewrites that contract, and performs its insert after the + /// block cache is promoted, an unconditional insert would clobber the newer + /// contract with the stale one — and block execution would then serialize + /// documents against a different contract than a node whose cache was cold, + /// producing a different app hash from the same block. The check-and-insert + /// is atomic per key via moka's compute API, so there is no window between + /// the version comparison and the write. Same-version inserts still + /// overwrite: re-inserting an identical contract with a freshly calculated + /// fee is the normal cache-hit fee path. pub fn insert(&self, fetch_info: Arc, is_block_cache: bool) { let data_contract_id_bytes = fetch_info.contract.id().to_buffer(); - if is_block_cache { - self.block_cache.insert(data_contract_id_bytes, fetch_info); + let cache = if is_block_cache { + &self.block_cache } else { - self.global_cache.insert(data_contract_id_bytes, fetch_info); - } + &self.global_cache + }; + + cache + .entry(data_contract_id_bytes) + .and_compute_with(|existing| match existing { + Some(entry) if entry.value().contract.version() > fetch_info.contract.version() => { + Op::Nop + } + _ => Op::Put(Arc::clone(&fetch_info)), + }); } /// Tries to get a data contract from block cache if present @@ -166,6 +194,104 @@ mod tests { } } + mod insert { + use super::*; + use dpp::data_contract::accessors::v0::{DataContractV0Getters, DataContractV0Setters}; + + /// Two copies of the SAME contract (same id) at the given versions. The + /// fixture generates a fresh contract id per call, so both copies must + /// derive from a single fixture. + fn same_contract_at_versions( + first: u32, + second: u32, + ) -> (Arc, Arc) { + let fetch_info = DataContractFetchInfo::dpns_contract_fixture( + PlatformVersion::latest().protocol_version, + ); + let mut first_info = fetch_info.clone(); + first_info.contract.set_version(first); + let mut second_info = fetch_info; + second_info.contract.set_version(second); + (Arc::new(first_info), Arc::new(second_info)) + } + + /// A delayed insert carrying an older contract version must not clobber a newer + /// entry. This is the query-thread race: a read-only query reads a contract from + /// committed state, is descheduled while block execution rewrites the contract, + /// and performs its cache insert only after the migrated contract was promoted + /// to the global cache. + #[test] + fn test_insert_does_not_overwrite_newer_version_with_older() { + let data_contract_cache = DataContractCache::new(10, 10); + + let (stale, newer) = same_contract_at_versions(1, 2); + let contract_id = newer.contract.id().to_buffer(); + data_contract_cache.insert(newer, false); + + // The delayed stale insert + data_contract_cache.insert(stale, false); + + let cached = data_contract_cache + .get(contract_id, false) + .expect("should be present"); + assert_eq!(cached.contract.version(), 2); + } + + /// Same-version inserts must overwrite: re-inserting the same contract with a + /// freshly calculated fee is the normal cache-hit fee path. + #[test] + fn test_insert_overwrites_same_version() { + let data_contract_cache = DataContractCache::new(10, 10); + + let (mut original, mut with_fee) = same_contract_at_versions(1, 1); + let contract_id = original.contract.id().to_buffer(); + Arc::make_mut(&mut original).fee = None; + data_contract_cache.insert(original, false); + + Arc::make_mut(&mut with_fee).fee = + Some(dpp::fee::fee_result::FeeResult::new_from_processing_fee(1)); + data_contract_cache.insert(with_fee, false); + + let cached = data_contract_cache + .get(contract_id, false) + .expect("should be present"); + assert!(cached.fee.is_some(), "same-version insert must overwrite"); + } + + /// The full race, end to end: block execution seeds the migrated (newer) + /// contract into the block cache, the block finalizes and promotes it to the + /// global cache, and only then does the delayed query thread insert the + /// pre-migration contract it read before the rewrite. The promoted contract + /// must survive. + #[test] + fn test_delayed_stale_insert_after_promotion_does_not_stick() { + let data_contract_cache = DataContractCache::new(10, 10); + + // The pre-migration contract, as read from committed state by a query + // thread that will be descheduled before its insert. + let (stale, migrated) = same_contract_at_versions(1, 2); + let contract_id = stale.contract.id().to_buffer(); + + // Block execution writes the migrated contract and seeds the block cache. + data_contract_cache.insert(migrated, true); + + // The block finalizes: block cache promotes to global. + data_contract_cache.merge_and_clear_block_cache(); + + // The query thread wakes up and performs its stale insert. + data_contract_cache.insert(stale, false); + + let cached = data_contract_cache + .get(contract_id, false) + .expect("should be present"); + assert_eq!( + cached.contract.version(), + 2, + "the promoted migrated contract must survive a delayed stale insert" + ); + } + } + mod remove { use super::*; diff --git a/packages/rs-drive/src/drive/contract/refresh_cache/mod.rs b/packages/rs-drive/src/drive/contract/refresh_cache/mod.rs index e023cb155e6..1971fb7003e 100644 --- a/packages/rs-drive/src/drive/contract/refresh_cache/mod.rs +++ b/packages/rs-drive/src/drive/contract/refresh_cache/mod.rs @@ -20,7 +20,14 @@ impl Drive { /// consult the cache first and hand back the very entry being replaced; worse, a /// read-only query thread — which reads committed state with no transaction and does /// populate the global cache — could race a pre-write copy back in between the eviction - /// and the re-seed. + /// and the re-seed. The other half of that race — a query thread that read the + /// pre-migration contract, was descheduled, and performs its cache insert only after the + /// migrated contract was promoted to the global cache — is closed by the monotonic + /// version guard in [`DataContractCache::insert`], which requires every migration rewrite + /// to bump the contract's version (the v13 DPNS rewrite goes 1 → 2, the v14 DashPay + /// rewrite likewise). + /// + /// [`DataContractCache::insert`]: crate::cache::DataContractCache::insert /// * The result is seeded into the **block** cache whenever a transaction is supplied. The /// write is still uncommitted at that point: the block cache is the first cache a /// transactional read consults, it is promoted to the global cache when the block commits diff --git a/packages/rs-drive/src/drive/identity/estimation_costs/for_purpose_in_key_reference_tree/v0/mod.rs b/packages/rs-drive/src/drive/identity/estimation_costs/for_purpose_in_key_reference_tree/v0/mod.rs index 6951d86eda7..68eabe9fcca 100644 --- a/packages/rs-drive/src/drive/identity/estimation_costs/for_purpose_in_key_reference_tree/v0/mod.rs +++ b/packages/rs-drive/src/drive/identity/estimation_costs/for_purpose_in_key_reference_tree/v0/mod.rs @@ -46,6 +46,13 @@ impl Drive { Purpose::SYSTEM => ApproximateElements(1), Purpose::VOTING => ApproximateElements(1), Purpose::OWNER => ApproximateElements(1), + // Payment purposes are not searchable (DIP-33): no key reference tree exists + Purpose::PAYMENT_SCAN => { + return; + } + Purpose::PAYMENT_SPEND => { + return; + } }; let estimated_layer_sizes = match purpose { @@ -60,6 +67,12 @@ impl Drive { Purpose::SYSTEM => AllReference(1, KEY_REFERENCE_SIZE, None), Purpose::VOTING => AllReference(1, KEY_REFERENCE_SIZE, None), Purpose::OWNER => AllReference(1, KEY_REFERENCE_SIZE, None), + Purpose::PAYMENT_SCAN => { + return; + } + Purpose::PAYMENT_SPEND => { + return; + } }; // we then need to insert the identity keys layer estimated_costs_only_with_layer_info.insert( diff --git a/packages/rs-drive/tests/deterministic_root_hash.rs b/packages/rs-drive/tests/deterministic_root_hash.rs index dfa15e21558..63b362aecf1 100644 --- a/packages/rs-drive/tests/deterministic_root_hash.rs +++ b/packages/rs-drive/tests/deterministic_root_hash.rs @@ -302,7 +302,11 @@ mod tests { // We expect a different app hash because data contract is not serialized the same way let expected_app_hash = match platform_version.protocol_version { 0..=8 => "1b80f4a9f00597b3f1ddca904b3cee67576868adcdd802c0a3f91e14209bb402", - _ => "14d9e2cdc3f25d1dfd079c1f9dd0d44db5bf73d397b04258449231a2d5bafda7", + // The DashPay contract v2 (protocol version 14, DIP-33) adds profile + // payment address fields, changing the contract's serialized bytes + // and therefore the root hash after insertion. + 9..=13 => "14d9e2cdc3f25d1dfd079c1f9dd0d44db5bf73d397b04258449231a2d5bafda7", + _ => "02a78b45ecac17a6c08ce352c22b888fcd044de9f4ff82983c5d8fd97e6e8793", }; assert_eq!( diff --git a/packages/rs-platform-encryption/Cargo.toml b/packages/rs-platform-encryption/Cargo.toml index 7c6757d23a0..1f045f953ea 100644 --- a/packages/rs-platform-encryption/Cargo.toml +++ b/packages/rs-platform-encryption/Cargo.toml @@ -22,3 +22,4 @@ thiserror = "1.0" # Tests generate keypairs via secp256k1's RNG helpers (`generate_keypair`, # `secp256k1::rand`), gated behind the `rand` feature. secp256k1 = { version = "0.30.0", features = ["std", "rand"] } +hex = "0.4" diff --git a/packages/rs-platform-encryption/src/error.rs b/packages/rs-platform-encryption/src/error.rs index 763aaa6ee6b..67a4e14c859 100644 --- a/packages/rs-platform-encryption/src/error.rs +++ b/packages/rs-platform-encryption/src/error.rs @@ -14,4 +14,10 @@ pub enum CryptoError { #[error("Invalid compact xpub length (DIP-15 requires exactly 69 bytes, got {0})")] InvalidCompactXpubLength(usize), + + #[error("Stealth tweak reduced to zero; retry with a fresh ephemeral key")] + ZeroStealthTweak, + + #[error("Stealth point operation failed")] + StealthPointOperation, } diff --git a/packages/rs-platform-encryption/src/lib.rs b/packages/rs-platform-encryption/src/lib.rs index 6a6f9c4cc93..30f773409ce 100644 --- a/packages/rs-platform-encryption/src/lib.rs +++ b/packages/rs-platform-encryption/src/lib.rs @@ -10,6 +10,7 @@ //! - [`account_label`] — `encryptedAccountLabel`. //! - [`contact_info`] — `contactInfo` (`encToUserId` + `privateData`). //! - [`account_reference`] — the masked `accountReference`. +//! - [`stealth`] — DIP-33 stealth one-time key derivation. //! - [`error`] — the shared [`CryptoError`]. //! //! Every public item is re-exported at the crate root, so the API is flat @@ -22,6 +23,7 @@ mod compact_xpub; mod contact_info; mod ecdh; mod error; +mod stealth; pub use account_label::{decrypt_account_label, encrypt_account_label}; pub use account_reference::{calculate_account_reference, unmask_account_reference}; @@ -35,3 +37,6 @@ pub use contact_info::{ }; pub use ecdh::derive_shared_key_ecdh; pub use error::CryptoError; +pub use stealth::{ + one_time_public_key, one_time_secret_key, one_time_tweak, stealth_shared_point, StealthRail, +}; diff --git a/packages/rs-platform-encryption/src/stealth.rs b/packages/rs-platform-encryption/src/stealth.rs new file mode 100644 index 00000000000..6ab2ed040fc --- /dev/null +++ b/packages/rs-platform-encryption/src/stealth.rs @@ -0,0 +1,313 @@ +//! DIP-33 stealth one-time key derivation. +//! +//! Implements the curve math of DIP-33 "One-Time Address Derivation": from a +//! recipient's published scan/spend key pair, a payer derives a fresh one-time +//! public key `P_n = B_spend + t_n·G` that only the recipient can recognize +//! (with the scan secret) and spend (with the spend secret). The same math +//! serves both transparent rails — Dash Core P2PKH outputs and Platform +//! payment addresses — separated by a rail domain byte inside the tweak hash, +//! so the two rails' one-time key spaces can never collide even under +//! (faulty) ephemeral-key reuse. +//! +//! This module is pure curve-and-hash math: no derivation paths, no address +//! encoding, no wallet state. Address formation (Core `Address::p2pkh` / +//! `PlatformAddress::P2pkh`) and the DIP-9 feature-33' key derivation live in +//! `platform-wallet`. + +use secp256k1::{PublicKey, Scalar, Secp256k1, SecretKey, Verification}; +use sha2::{Digest, Sha256}; + +use crate::error::CryptoError; + +/// Domain-separation tag for the one-time tweak hash (DIP-33). +const STEALTH_TAG: &[u8] = b"DashPay/Stealth/v1"; + +/// The secp256k1 group order `n`, big-endian. +const CURVE_ORDER_BE: [u8; 32] = [ + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFE, + 0xBA, 0xAE, 0xDC, 0xE6, 0xAF, 0x48, 0xA0, 0x3B, 0xBF, 0xD2, 0x5E, 0x8C, 0xD0, 0x36, 0x41, 0x41, +]; + +/// Payment rail domain byte mixed into the one-time tweak (DIP-33). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[repr(u8)] +pub enum StealthRail { + /// Dash Core chain P2PKH outputs. + Core = 0x00, + /// Platform payment addresses (DIP-17/DIP-18). + Platform = 0x02, +} + +/// Compute the Diffie–Hellman shared point for stealth derivation. +/// +/// Payer side: `secret_half = r` (ephemeral), `public_half = B_scan`. +/// Recipient side: `secret_half = b_scan`, `public_half = R`. +/// Both produce the same point `S = r·B_scan = b_scan·R`. +pub fn stealth_shared_point( + secp: &Secp256k1, + secret_half: &SecretKey, + public_half: &PublicKey, +) -> Result { + public_half + .mul_tweak(secp, &Scalar::from(*secret_half)) + .map_err(|_| CryptoError::StealthPointOperation) +} + +/// Compute the one-time tweak `t_n` for output `n` on `rail` (DIP-33): +/// +/// `t_n = int_be(SHA256("DashPay/Stealth/v1" || ser(S) || ser(R) || rail || LE32(n))) mod order` +/// +/// where `S` is the shared point and `R` the payer's ephemeral public key. +/// Errors with [`CryptoError::ZeroStealthTweak`] on the negligible zero case, +/// in which the payer must restart with a fresh ephemeral key. +pub fn one_time_tweak( + shared_point: &PublicKey, + ephemeral_public: &PublicKey, + rail: StealthRail, + n: u32, +) -> Result { + let mut hasher = Sha256::new(); + hasher.update(STEALTH_TAG); + hasher.update(shared_point.serialize()); + hasher.update(ephemeral_public.serialize()); + hasher.update([rail as u8]); + hasher.update(n.to_le_bytes()); + let digest: [u8; 32] = hasher.finalize().into(); + + let reduced = reduce_mod_order(digest); + if reduced == [0u8; 32] { + return Err(CryptoError::ZeroStealthTweak); + } + // In range by construction after reduction. + Scalar::from_be_bytes(reduced).map_err(|_| CryptoError::StealthPointOperation) +} + +/// Derive the one-time public key `P_n = B_spend + t_n·G`. +/// +/// Callable by the payer (who computed `S` from `r` and `B_scan`) and by the +/// recipient or a watch service (who computed `S` from `b_scan` and `R`); +/// neither needs the spend secret. +pub fn one_time_public_key( + secp: &Secp256k1, + spend_public: &PublicKey, + shared_point: &PublicKey, + ephemeral_public: &PublicKey, + rail: StealthRail, + n: u32, +) -> Result { + let tweak = one_time_tweak(shared_point, ephemeral_public, rail, n)?; + spend_public + .add_exp_tweak(secp, &tweak) + .map_err(|_| CryptoError::StealthPointOperation) +} + +/// Derive the one-time secret key `p_n = b_spend + t_n (mod order)`. +/// +/// Requires the spend secret; this is the only stealth operation a scan-only +/// watch service cannot perform. +pub fn one_time_secret_key( + spend_secret: &SecretKey, + shared_point: &PublicKey, + ephemeral_public: &PublicKey, + rail: StealthRail, + n: u32, +) -> Result { + let tweak = one_time_tweak(shared_point, ephemeral_public, rail, n)?; + spend_secret + .add_tweak(&tweak) + .map_err(|_| CryptoError::StealthPointOperation) +} + +/// Reduce a 256-bit big-endian value modulo the curve order. +/// +/// Any 256-bit value is below `2n` (the order's top bit is set), so a single +/// conditional subtraction is an exact reduction. +fn reduce_mod_order(bytes: [u8; 32]) -> [u8; 32] { + if !ge_order(&bytes) { + return bytes; + } + let mut out = [0u8; 32]; + let mut borrow = 0u16; + for i in (0..32).rev() { + let lhs = bytes[i] as u16; + let rhs = CURVE_ORDER_BE[i] as u16 + borrow; + if lhs >= rhs { + out[i] = (lhs - rhs) as u8; + borrow = 0; + } else { + out[i] = (lhs + 0x100 - rhs) as u8; + borrow = 1; + } + } + out +} + +/// Big-endian comparison: `bytes >= CURVE_ORDER_BE`. +fn ge_order(bytes: &[u8; 32]) -> bool { + for i in 0..32 { + match bytes[i].cmp(&CURVE_ORDER_BE[i]) { + std::cmp::Ordering::Greater => return true, + std::cmp::Ordering::Less => return false, + std::cmp::Ordering::Equal => {} + } + } + true +} + +#[cfg(test)] +mod tests { + use super::*; + + fn sk(hex_str: &str) -> SecretKey { + let mut bytes = [0u8; 32]; + hex::decode_to_slice(hex_str, &mut bytes).expect("valid hex"); + SecretKey::from_slice(&bytes).expect("valid scalar") + } + + /// DIP-33 test vector inputs (test-only scalars). + const B_SCAN: &str = "61aafd85dbca17133515038343b05ed2019ca465d1ba93dcbffbf9534d2f436c"; + const B_SPEND: &str = "8f54f1cfa054739a1d4a87847bdf6a024caf78ff4b2c91aeb75cb843280957ff"; + const R_EPHEMERAL: &str = "37ca08877b0beea1b1e7655649274689e89944edae537ecad42764e59ac31e3e"; + + /// Known-answer test pinning the full DIP-33 vector table: shared point, + /// tweaks, one-time public keys, and one-time secret keys for both rails. + /// A mismatch here means we broke interop with every other implementation + /// of the DIP, not just our own round-trip. + #[test] + fn dip33_known_answer_vectors() { + let secp = Secp256k1::new(); + let b_scan = sk(B_SCAN); + let b_spend = sk(B_SPEND); + let r = sk(R_EPHEMERAL); + + let scan_pub = PublicKey::from_secret_key(&secp, &b_scan); + let spend_pub = PublicKey::from_secret_key(&secp, &b_spend); + let ephemeral_pub = PublicKey::from_secret_key(&secp, &r); + + assert_eq!( + hex::encode(scan_pub.serialize()), + "0218ee61cb2070d8c63456b2acd9cb0243e4a4ad0791c272e79c0eb8de88284448" + ); + assert_eq!( + hex::encode(spend_pub.serialize()), + "0397b4690ee29e51d7da3186a683a563394981832f8860e405f8827bfd5dfbd7f6" + ); + assert_eq!( + hex::encode(ephemeral_pub.serialize()), + "029d683d939d3bb5527ccc96d7ecd4e77274a530b4496fed79f08db99c8fe6e44e" + ); + + // Diffie–Hellman symmetry: payer and recipient reach the same point. + let shared_payer = stealth_shared_point(&secp, &r, &scan_pub).expect("payer S"); + let shared_recipient = + stealth_shared_point(&secp, &b_scan, &ephemeral_pub).expect("recipient S"); + assert_eq!(shared_payer, shared_recipient); + assert_eq!( + hex::encode(shared_payer.serialize()), + "031218547890897c32cbc802902b8b8257cbcbd3cee2f9c393b7e4a74cb635b36e" + ); + + // (rail, n, expected t_n, expected P_n, expected p_n) + let vectors: [(StealthRail, u32, &str, &str, &str); 3] = [ + ( + StealthRail::Core, + 0, + "2fe3ccd4f560aadda6cedb4f4b9e0927ecbdf49c4864ba0bfbdb174a80e94cdf", + "029459124e206a8113bdb4c862794788380bae4b1d8bac57e561a6c88eb4eb96f1", + "bf38bea495b51e77c41962d3c77d732a396d6d9b93914bbab337cf8da8f2a4de", + ), + ( + StealthRail::Core, + 1, + "e0867aad0724465861061aa9d89bf7cb71056bad6888b119ca5b4579f3663cf8", + "03356bc4f9ef4256c306872ab072bdef1e317396f2ae04730c6f252e9a02a2c039", + "6fdb6c7ca778b9f27e50a22e547b61cf030607c6046ca28cc1e59f304b3953b6", + ), + ( + StealthRail::Platform, + 0, + "5bba1d655f05b3b9b91561f818c3d8091863148ed28f129b10e959e8a0847756", + "03b45786021b88c3b0e8730a7164630e75eea49b13fe1fd304c97075103f6fa40e", + "eb0f0f34ff5a2753d65fe97c94a3420b65128d8e1dbba449c846122bc88dcf55", + ), + ]; + + for (rail, n, expected_t, expected_pub, expected_secret) in vectors { + let tweak = one_time_tweak(&shared_payer, &ephemeral_pub, rail, n).expect("tweak"); + assert_eq!(hex::encode(tweak.to_be_bytes()), expected_t); + + let one_time_pub = + one_time_public_key(&secp, &spend_pub, &shared_payer, &ephemeral_pub, rail, n) + .expect("one-time public key"); + assert_eq!(hex::encode(one_time_pub.serialize()), expected_pub); + + let one_time_secret = + one_time_secret_key(&b_spend, &shared_payer, &ephemeral_pub, rail, n) + .expect("one-time secret key"); + assert_eq!(hex::encode(one_time_secret.secret_bytes()), expected_secret); + + // Spend-key consistency: p_n·G == P_n. + assert_eq!( + PublicKey::from_secret_key(&secp, &one_time_secret), + one_time_pub, + "one-time secret must control the one-time public key" + ); + } + } + + /// The rail byte must domain-separate the two rails: identical inputs on + /// different rails may never produce the same tweak. + #[test] + fn rails_are_domain_separated() { + let secp = Secp256k1::new(); + let b_scan = sk(B_SCAN); + let r = sk(R_EPHEMERAL); + let scan_pub = PublicKey::from_secret_key(&secp, &b_scan); + let ephemeral_pub = PublicKey::from_secret_key(&secp, &r); + let shared = stealth_shared_point(&secp, &r, &scan_pub).expect("S"); + + let core = one_time_tweak(&shared, &ephemeral_pub, StealthRail::Core, 0).expect("t core"); + let platform = + one_time_tweak(&shared, &ephemeral_pub, StealthRail::Platform, 0).expect("t platform"); + assert_ne!(core.to_be_bytes(), platform.to_be_bytes()); + } + + /// Output counters must produce distinct tweaks (multi-output payments). + #[test] + fn output_counters_are_distinct() { + let secp = Secp256k1::new(); + let b_scan = sk(B_SCAN); + let r = sk(R_EPHEMERAL); + let scan_pub = PublicKey::from_secret_key(&secp, &b_scan); + let ephemeral_pub = PublicKey::from_secret_key(&secp, &r); + let shared = stealth_shared_point(&secp, &r, &scan_pub).expect("S"); + + let t0 = one_time_tweak(&shared, &ephemeral_pub, StealthRail::Core, 0).expect("t0"); + let t1 = one_time_tweak(&shared, &ephemeral_pub, StealthRail::Core, 1).expect("t1"); + assert_ne!(t0.to_be_bytes(), t1.to_be_bytes()); + } + + /// Exactness of the single-conditional-subtraction modular reduction at + /// the boundaries: values below the order pass through, the order itself + /// reduces to zero, and order+1 reduces to one. + #[test] + fn reduce_mod_order_boundaries() { + assert_eq!(reduce_mod_order([0u8; 32]), [0u8; 32]); + + let mut below = CURVE_ORDER_BE; + below[31] -= 1; + assert_eq!(reduce_mod_order(below), below); + + assert_eq!(reduce_mod_order(CURVE_ORDER_BE), [0u8; 32]); + + let mut above = CURVE_ORDER_BE; + above[31] += 1; + let mut one = [0u8; 32]; + one[31] = 1; + assert_eq!(reduce_mod_order(above), one); + + let max = [0xFFu8; 32]; + let reduced = reduce_mod_order(max); + assert!(!ge_order(&reduced), "reduction must land below the order"); + } +} 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..3f8daf94453 --- /dev/null +++ b/packages/rs-platform-version/src/version/dpp_versions/dpp_state_transition_method_versions/v2.rs @@ -0,0 +1,20 @@ +use crate::version::dpp_versions::dpp_state_transition_method_versions::{ + DPPStateTransitionMethodVersions, PublicKeyInCreationMethodVersions, +}; + +// PROTOCOL_VERSION_14: validate_identity_public_keys_structure v1 accepts the +// DIP-33 PAYMENT_SCAN / PAYMENT_SPEND key purposes (ECDSA_SECP256K1 only, at +// most one of each per transition). v1 of this struct (method version 0) +// remains for PROTOCOL_VERSION_13 chain replay, where those purposes are +// rejected. +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/mod.rs b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/mod.rs index 4c053895190..fc61e5504f2 100644 --- a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/mod.rs +++ b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/mod.rs @@ -141,6 +141,7 @@ pub struct DriveAbciStateTransitionCommonValidationVersions { pub validate_state_transition_identity_signed: FeatureVersion, pub validate_unique_identity_public_key_hashes_in_state: FeatureVersion, pub validate_master_key_uniqueness: FeatureVersion, + pub validate_payment_key_uniqueness: FeatureVersion, pub validate_non_masternode_identity_exists: FeatureVersion, pub validate_identity_exists: FeatureVersion, } @@ -248,6 +249,7 @@ pub struct DriveAbciValidationDataTriggerAndBindingVersions { #[derive(Clone, Debug, Default)] pub struct DriveAbciValidationDataTriggerVersions { pub create_contact_request_data_trigger: FeatureVersion, + pub validate_profile_payment_addresses_data_trigger: FeatureVersion, pub create_domain_data_trigger: FeatureVersion, pub create_identity_data_trigger: FeatureVersion, pub create_feature_flag_data_trigger: FeatureVersion, diff --git a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v1.rs b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v1.rs index 89f540a9f3e..cadb4fc4f47 100644 --- a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v1.rs +++ b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v1.rs @@ -20,6 +20,7 @@ pub const DRIVE_ABCI_VALIDATION_VERSIONS_V1: DriveAbciValidationVersions = validate_state_transition_identity_signed: 0, validate_unique_identity_public_key_hashes_in_state: 0, validate_master_key_uniqueness: 0, + validate_payment_key_uniqueness: 0, validate_non_masternode_identity_exists: 0, validate_identity_exists: 0, }, @@ -113,6 +114,7 @@ pub const DRIVE_ABCI_VALIDATION_VERSIONS_V1: DriveAbciValidationVersions = bindings: 0, triggers: DriveAbciValidationDataTriggerVersions { create_contact_request_data_trigger: 0, + validate_profile_payment_addresses_data_trigger: 0, create_domain_data_trigger: 0, create_identity_data_trigger: 0, create_feature_flag_data_trigger: 0, 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 757d5770fbf..9eb1a999542 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 @@ -24,6 +24,7 @@ pub const DRIVE_ABCI_VALIDATION_VERSIONS_V10: DriveAbciValidationVersions = validate_state_transition_identity_signed: 0, validate_unique_identity_public_key_hashes_in_state: 1, validate_master_key_uniqueness: 0, + validate_payment_key_uniqueness: 0, validate_non_masternode_identity_exists: 0, validate_identity_exists: 0, }, @@ -150,10 +151,13 @@ pub const DRIVE_ABCI_VALIDATION_VERSIONS_V10: DriveAbciValidationVersions = fetch_documents_for_transitions_knowing_contract_and_document_type: 1, fetch_document_with_id: 1, data_triggers: DriveAbciValidationDataTriggerAndBindingVersions { - // PROTOCOL_VERSION_13: v1 drops the reject bindings for - // Transfer, Purchase and UpdatePrice on DPNS `domain` - // documents, enabling username transfers and sales. - bindings: 1, + // PROTOCOL_VERSION_14: v2 adds DashPay `profile` + // Create/Replace triggers enforcing the DIP-33 + // payment-address type byte (0x00 P2PKH / 0x01 P2SH). + // It keeps v1's PROTOCOL_VERSION_13 change: no reject + // bindings for Transfer, Purchase and UpdatePrice on + // DPNS `domain` documents (username transfers/sales). + bindings: 2, triggers: DriveAbciValidationDataTriggerVersions { // PROTOCOL_VERSION_12 (v3.1 hard fork): triggers // that perform drive reads now have `_v1` versions @@ -161,6 +165,7 @@ pub const DRIVE_ABCI_VALIDATION_VERSIONS_V10: DriveAbciValidationVersions = // outer execution_context. v0 versions remain // byte-identical to PV11 (don't bill). create_contact_request_data_trigger: 1, + validate_profile_payment_addresses_data_trigger: 0, create_domain_data_trigger: 1, create_identity_data_trigger: 0, create_feature_flag_data_trigger: 0, diff --git a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v2.rs b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v2.rs index b125c76a94a..f7f76a88cb0 100644 --- a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v2.rs +++ b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v2.rs @@ -20,6 +20,7 @@ pub const DRIVE_ABCI_VALIDATION_VERSIONS_V2: DriveAbciValidationVersions = validate_state_transition_identity_signed: 0, validate_unique_identity_public_key_hashes_in_state: 0, validate_master_key_uniqueness: 0, + validate_payment_key_uniqueness: 0, validate_non_masternode_identity_exists: 0, validate_identity_exists: 0, }, @@ -113,6 +114,7 @@ pub const DRIVE_ABCI_VALIDATION_VERSIONS_V2: DriveAbciValidationVersions = bindings: 0, triggers: DriveAbciValidationDataTriggerVersions { create_contact_request_data_trigger: 0, + validate_profile_payment_addresses_data_trigger: 0, create_domain_data_trigger: 0, create_identity_data_trigger: 0, create_feature_flag_data_trigger: 0, diff --git a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v3.rs b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v3.rs index 103dcf903ac..c910484d6cc 100644 --- a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v3.rs +++ b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v3.rs @@ -20,6 +20,7 @@ pub const DRIVE_ABCI_VALIDATION_VERSIONS_V3: DriveAbciValidationVersions = validate_state_transition_identity_signed: 0, validate_unique_identity_public_key_hashes_in_state: 0, validate_master_key_uniqueness: 0, + validate_payment_key_uniqueness: 0, validate_non_masternode_identity_exists: 0, validate_identity_exists: 0, }, @@ -113,6 +114,7 @@ pub const DRIVE_ABCI_VALIDATION_VERSIONS_V3: DriveAbciValidationVersions = bindings: 0, triggers: DriveAbciValidationDataTriggerVersions { create_contact_request_data_trigger: 0, + validate_profile_payment_addresses_data_trigger: 0, create_domain_data_trigger: 0, create_identity_data_trigger: 0, create_feature_flag_data_trigger: 0, diff --git a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v4.rs b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v4.rs index b5ff34370cb..719b14a4364 100644 --- a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v4.rs +++ b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v4.rs @@ -23,6 +23,7 @@ pub const DRIVE_ABCI_VALIDATION_VERSIONS_V4: DriveAbciValidationVersions = validate_state_transition_identity_signed: 0, validate_unique_identity_public_key_hashes_in_state: 0, validate_master_key_uniqueness: 0, + validate_payment_key_uniqueness: 0, validate_non_masternode_identity_exists: 0, validate_identity_exists: 0, }, @@ -116,6 +117,7 @@ pub const DRIVE_ABCI_VALIDATION_VERSIONS_V4: DriveAbciValidationVersions = bindings: 0, triggers: DriveAbciValidationDataTriggerVersions { create_contact_request_data_trigger: 0, + validate_profile_payment_addresses_data_trigger: 0, create_domain_data_trigger: 0, create_identity_data_trigger: 0, create_feature_flag_data_trigger: 0, diff --git a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v5.rs b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v5.rs index 7704f76b546..b0d82ec7814 100644 --- a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v5.rs +++ b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v5.rs @@ -24,6 +24,7 @@ pub const DRIVE_ABCI_VALIDATION_VERSIONS_V5: DriveAbciValidationVersions = validate_state_transition_identity_signed: 0, validate_unique_identity_public_key_hashes_in_state: 0, validate_master_key_uniqueness: 0, + validate_payment_key_uniqueness: 0, validate_non_masternode_identity_exists: 0, validate_identity_exists: 0, }, @@ -117,6 +118,7 @@ pub const DRIVE_ABCI_VALIDATION_VERSIONS_V5: DriveAbciValidationVersions = bindings: 0, triggers: DriveAbciValidationDataTriggerVersions { create_contact_request_data_trigger: 0, + validate_profile_payment_addresses_data_trigger: 0, create_domain_data_trigger: 0, create_identity_data_trigger: 0, create_feature_flag_data_trigger: 0, diff --git a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v6.rs b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v6.rs index 6119b8ce61b..14416539170 100644 --- a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v6.rs +++ b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v6.rs @@ -27,6 +27,7 @@ pub const DRIVE_ABCI_VALIDATION_VERSIONS_V6: DriveAbciValidationVersions = validate_state_transition_identity_signed: 0, validate_unique_identity_public_key_hashes_in_state: 0, validate_master_key_uniqueness: 0, + validate_payment_key_uniqueness: 0, validate_non_masternode_identity_exists: 0, validate_identity_exists: 0, }, @@ -120,6 +121,7 @@ pub const DRIVE_ABCI_VALIDATION_VERSIONS_V6: DriveAbciValidationVersions = bindings: 0, triggers: DriveAbciValidationDataTriggerVersions { create_contact_request_data_trigger: 0, + validate_profile_payment_addresses_data_trigger: 0, create_domain_data_trigger: 0, create_identity_data_trigger: 0, create_feature_flag_data_trigger: 0, diff --git a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v7.rs b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v7.rs index c28d082c450..abad3fcbdf3 100644 --- a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v7.rs +++ b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v7.rs @@ -21,6 +21,7 @@ pub const DRIVE_ABCI_VALIDATION_VERSIONS_V7: DriveAbciValidationVersions = validate_state_transition_identity_signed: 0, validate_unique_identity_public_key_hashes_in_state: 1, validate_master_key_uniqueness: 0, + validate_payment_key_uniqueness: 0, validate_non_masternode_identity_exists: 0, validate_identity_exists: 0, }, @@ -114,6 +115,7 @@ pub const DRIVE_ABCI_VALIDATION_VERSIONS_V7: DriveAbciValidationVersions = bindings: 0, triggers: DriveAbciValidationDataTriggerVersions { create_contact_request_data_trigger: 0, + validate_profile_payment_addresses_data_trigger: 0, create_domain_data_trigger: 0, create_identity_data_trigger: 0, create_feature_flag_data_trigger: 0, diff --git a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v8.rs b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v8.rs index c44d1a20c29..65728563996 100644 --- a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v8.rs +++ b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v8.rs @@ -33,6 +33,7 @@ pub const DRIVE_ABCI_VALIDATION_VERSIONS_V8: DriveAbciValidationVersions = validate_state_transition_identity_signed: 0, validate_unique_identity_public_key_hashes_in_state: 1, validate_master_key_uniqueness: 0, + validate_payment_key_uniqueness: 0, validate_non_masternode_identity_exists: 0, validate_identity_exists: 0, }, @@ -167,6 +168,7 @@ pub const DRIVE_ABCI_VALIDATION_VERSIONS_V8: DriveAbciValidationVersions = // outer execution_context. v0 versions remain // byte-identical to PV11 (don't bill). create_contact_request_data_trigger: 1, + validate_profile_payment_addresses_data_trigger: 0, create_domain_data_trigger: 1, create_identity_data_trigger: 0, create_feature_flag_data_trigger: 0, diff --git a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v9.rs b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v9.rs index 0a2119580e8..7a1c659ec0c 100644 --- a/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v9.rs +++ b/packages/rs-platform-version/src/version/drive_abci_versions/drive_abci_validation_versions/v9.rs @@ -26,6 +26,7 @@ pub const DRIVE_ABCI_VALIDATION_VERSIONS_V9: DriveAbciValidationVersions = validate_state_transition_identity_signed: 0, validate_unique_identity_public_key_hashes_in_state: 1, validate_master_key_uniqueness: 0, + validate_payment_key_uniqueness: 0, validate_non_masternode_identity_exists: 0, validate_identity_exists: 0, }, @@ -163,6 +164,7 @@ pub const DRIVE_ABCI_VALIDATION_VERSIONS_V9: DriveAbciValidationVersions = // outer execution_context. v0 versions remain // byte-identical to PV11 (don't bill). create_contact_request_data_trigger: 1, + validate_profile_payment_addresses_data_trigger: 0, create_domain_data_trigger: 1, create_identity_data_trigger: 0, create_feature_flag_data_trigger: 0, diff --git a/packages/rs-platform-version/src/version/system_data_contract_versions/mod.rs b/packages/rs-platform-version/src/version/system_data_contract_versions/mod.rs index 4605c85a86b..1f01823d67a 100644 --- a/packages/rs-platform-version/src/version/system_data_contract_versions/mod.rs +++ b/packages/rs-platform-version/src/version/system_data_contract_versions/mod.rs @@ -1,5 +1,6 @@ pub mod v1; pub mod v2; +pub mod v3; use crate::version::FeatureVersion; diff --git a/packages/rs-platform-version/src/version/system_data_contract_versions/v3.rs b/packages/rs-platform-version/src/version/system_data_contract_versions/v3.rs new file mode 100644 index 00000000000..94c03f7dd8a --- /dev/null +++ b/packages/rs-platform-version/src/version/system_data_contract_versions/v3.rs @@ -0,0 +1,17 @@ +use crate::version::system_data_contract_versions::SystemDataContractVersions; + +// PROTOCOL_VERSION_14: DashPay contract v2 adds the optional public payment +// address fields to the `profile` document type (`corePaymentAddress`, +// `platformPaymentAddress`) per DIP-33. v2 (dashpay: 1) remains for +// PROTOCOL_VERSION_13 chain replay. +pub const SYSTEM_DATA_CONTRACT_VERSIONS_V3: SystemDataContractVersions = + SystemDataContractVersions { + withdrawals: 1, + dpns: 2, + dashpay: 2, + masternode_reward_shares: 1, + wallet: 1, + token_history: 1, + keyword_search: 1, + document_history: 1, + }; diff --git a/packages/rs-platform-version/src/version/v14.rs b/packages/rs-platform-version/src/version/v14.rs index a5c0aa7433b..7187aeab469 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::v2::DPP_METHOD_VERSIONS_V2; 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::v2::STATE_TRANSITION_SERIALIZATION_VERSIONS_V2; 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; @@ -24,7 +24,7 @@ use crate::version::drive_abci_versions::DriveAbciVersion; use crate::version::drive_versions::v9::DRIVE_VERSION_V9; use crate::version::fee::v2::FEE_VERSION2; use crate::version::protocol_version::PlatformVersion; -use crate::version::system_data_contract_versions::v2::SYSTEM_DATA_CONTRACT_VERSIONS_V2; +use crate::version::system_data_contract_versions::v3::SYSTEM_DATA_CONTRACT_VERSIONS_V3; use crate::version::system_limits::v3::SYSTEM_LIMITS_V3; use crate::version::ProtocolVersion; @@ -131,7 +131,7 @@ pub const PLATFORM_V14: PlatformVersion = PlatformVersion { validation: DPP_VALIDATION_VERSIONS_V5, state_transition_serialization_versions: STATE_TRANSITION_SERIALIZATION_VERSIONS_V2, 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, // changed: accepts DIP-33 payment key purposes state_transitions: STATE_TRANSITION_VERSIONS_V3, contract_versions: CONTRACT_VERSIONS_V6, // changed: v3 document meta-schema hosts the ranked index keywords document_versions: DOCUMENT_VERSIONS_V3, @@ -142,7 +142,7 @@ pub const PLATFORM_V14: PlatformVersion = PlatformVersion { methods: DPP_METHOD_VERSIONS_V2, factory_versions: DPP_FACTORY_VERSIONS_V1, }, - system_data_contracts: SYSTEM_DATA_CONTRACT_VERSIONS_V2, + system_data_contracts: SYSTEM_DATA_CONTRACT_VERSIONS_V3, // changed: DashPay v2 adds profile payment address fields (DIP-33) fee_version: FEE_VERSION2, system_limits: SYSTEM_LIMITS_V3, consensus: ConsensusVersions { diff --git a/packages/rs-platform-wallet/src/error.rs b/packages/rs-platform-wallet/src/error.rs index 8349eb1df21..76217070f73 100644 --- a/packages/rs-platform-wallet/src/error.rs +++ b/packages/rs-platform-wallet/src/error.rs @@ -436,6 +436,12 @@ pub enum PlatformWalletError { #[error("Key derivation failed: {0}")] KeyDerivation(String), + /// Stealth (DIP-33) crypto failure, preserving the source so callers can + /// distinguish the retryable `CryptoError::ZeroStealthTweak` (pick a fresh + /// ephemeral key) without parsing display text. + #[error("Stealth crypto error: {0}")] + StealthCrypto(#[from] platform_encryption::CryptoError), + #[error("Wallet is locked — unlock it before performing this operation")] WalletLocked, diff --git a/packages/rs-platform-wallet/src/wallet/identity/crypto/mod.rs b/packages/rs-platform-wallet/src/wallet/identity/crypto/mod.rs index c0a0687b44b..20796927104 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/crypto/mod.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/crypto/mod.rs @@ -7,6 +7,7 @@ pub mod auto_accept; pub mod contact_info; pub mod dip14; pub mod invitation; +pub mod stealth; pub mod validation; pub use auto_accept::derive_auto_accept_private_key; @@ -22,4 +23,9 @@ pub use invitation::{ encode_invitation_uri, parse_invitation_uri, voucher_output_index, wif_network_matches, InviterInfo, ParsedInvitation, }; +pub use stealth::{ + derive_one_time_destination, derive_one_time_secret_key, derive_payment_detection_secret_key, + payment_detection_derivation_path, recognize_one_time_destination, OneTimeDestination, + PaymentRail, +}; pub use validation::pubkey_binds_expected_key_data; diff --git a/packages/rs-platform-wallet/src/wallet/identity/crypto/stealth.rs b/packages/rs-platform-wallet/src/wallet/identity/crypto/stealth.rs new file mode 100644 index 00000000000..ee97b335c50 --- /dev/null +++ b/packages/rs-platform-wallet/src/wallet/identity/crypto/stealth.rs @@ -0,0 +1,342 @@ +//! DIP-33 stealth payment key derivation and one-time address construction. +//! +//! This module composes the pure stealth curve math from +//! [`platform_encryption::stealth`] with DashPay's wallet concerns: the DIP-9 +//! feature-33' key derivation, and the formation of concrete payment +//! destinations on the two transparent rails — Dash Core P2PKH addresses and +//! Platform payment addresses ([DIP-18]). +//! +//! # Derivation path +//! +//! Detection keys live under DIP-9 feature `33'`: +//! +//! ```text +//! m / 9' / coin' / 33' / account' / key_class' / index' +//! ``` +//! +//! with `key_class'` selecting the role: `0'` scan, `1'` spend, `2'` +//! notification out-key. All segments are hardened; disclosure of the scan +//! key reveals nothing about the spend or out keys. +//! +//! # Roles +//! +//! * **Payer** ([`derive_one_time_destination`]) — given the recipient's +//! published scan/spend public keys, derives a fresh one-time destination +//! and the ephemeral public key `R` to publish in the payment notification. +//! * **Receiver** ([`recognize_one_time_destination`]) — given `R` and the +//! recipient's scan secret + spend public key, re-derives the destination to +//! match against the chain. Needs no spend secret (watch-only capable). +//! * **Spender** ([`derive_one_time_secret_key`]) — given `R` and the scan + +//! spend secrets, derives the one-time secret key that controls the output. +//! +//! [DIP-18]: https://github.com/dashpay/dips/blob/master/dip-0018.md + +use dashcore::hashes::Hash; +use dashcore::secp256k1::{PublicKey, Secp256k1, SecretKey}; +use dashcore::{Address, PublicKey as DashPublicKey}; +use dpp::address_funds::PlatformAddress; +use key_wallet::bip32::{ChildNumber, DerivationPath}; +use key_wallet::dip9::{DASH_COIN_TYPE, DASH_TESTNET_COIN_TYPE, FEATURE_PURPOSE}; +use key_wallet::wallet::Wallet; +use key_wallet::Network; +use platform_encryption::{ + one_time_public_key, one_time_secret_key, stealth_shared_point, StealthRail, +}; + +use crate::error::PlatformWalletError; + +/// DIP-9 feature index for DashPay payment detection keys (matches DIP-33). +pub const FEATURE_PURPOSE_PAYMENT_DETECTION: u32 = 33; + +/// `key_class'` for the scan key. +pub const KEY_CLASS_SCAN: u32 = 0; +/// `key_class'` for the spend key. +pub const KEY_CLASS_SPEND: u32 = 1; +/// `key_class'` for the notification out-key. +pub const KEY_CLASS_NOTIF_OUT: u32 = 2; + +/// Which transparent rail a one-time destination is being built for. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PaymentRail { + /// Dash Core chain P2PKH output. + Core, + /// Platform payment address (P2PKH). + Platform, +} + +impl From for StealthRail { + fn from(rail: PaymentRail) -> Self { + match rail { + PaymentRail::Core => StealthRail::Core, + PaymentRail::Platform => StealthRail::Platform, + } + } +} + +/// A one-time payment destination on a specific rail. The variant matches the +/// rail requested; both wrap the same underlying `hash160(P_n)`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum OneTimeDestination { + /// A Dash Core chain P2PKH address. + Core(Address), + /// A Platform payment address (P2PKH storage form). + Platform(PlatformAddress), +} + +/// The DIP-33 detection-key derivation path +/// `m/9'/coin'/33'/account'/key_class'/index'` (all hardened). +pub fn payment_detection_derivation_path( + network: Network, + account: u32, + key_class: u32, + index: u32, +) -> Result { + let coin_type: u32 = match network { + Network::Mainnet => DASH_COIN_TYPE, + _ => DASH_TESTNET_COIN_TYPE, + }; + let hardened = |value: u32, label: &str| { + ChildNumber::from_hardened_idx(value).map_err(|e| { + PlatformWalletError::KeyDerivation(format!( + "Invalid {label} index for payment detection path: {e}" + )) + }) + }; + Ok(DerivationPath::from(vec![ + hardened(FEATURE_PURPOSE, "feature purpose")?, + hardened(coin_type, "coin type")?, + hardened(FEATURE_PURPOSE_PAYMENT_DETECTION, "feature")?, + hardened(account, "account")?, + hardened(key_class, "key class")?, + hardened(index, "index")?, + ])) +} + +/// Derive a DIP-33 detection secret key at the given role and index. +pub fn derive_payment_detection_secret_key( + wallet: &Wallet, + network: Network, + account: u32, + key_class: u32, + index: u32, +) -> Result { + let path = payment_detection_derivation_path(network, account, key_class, index)?; + let ext_priv = wallet.derive_extended_private_key(&path).map_err(|e| { + PlatformWalletError::KeyDerivation(format!("Failed to derive payment detection key: {e}")) + })?; + let secret_bytes = zeroize::Zeroizing::new(ext_priv.private_key.secret_bytes()); + SecretKey::from_slice(&*secret_bytes).map_err(|e| { + PlatformWalletError::KeyDerivation(format!("Invalid derived payment detection key: {e}")) + }) +} + +/// Form the rail-specific destination from a one-time public key. +fn destination_from_public_key( + one_time_public: &PublicKey, + rail: PaymentRail, + network: Network, +) -> OneTimeDestination { + let dash_pubkey = DashPublicKey::new(*one_time_public); + match rail { + PaymentRail::Core => OneTimeDestination::Core(Address::p2pkh(&dash_pubkey, network)), + PaymentRail::Platform => { + let hash = dash_pubkey.pubkey_hash(); + OneTimeDestination::Platform(PlatformAddress::P2pkh(hash.to_byte_array())) + } + } +} + +/// Payer: derive the one-time destination for output `n` to a recipient whose +/// published detection keys are `scan_public` / `spend_public`, using a fresh +/// ephemeral secret `ephemeral_secret`. +/// +/// Returns the destination together with the ephemeral public key `R` that +/// must be published in the payment notification so the recipient can detect +/// and spend the output. The same `ephemeral_secret` (hence `R`) is shared +/// across every output `n` of one payment. +pub fn derive_one_time_destination( + ephemeral_secret: &SecretKey, + scan_public: &PublicKey, + spend_public: &PublicKey, + rail: PaymentRail, + n: u32, + network: Network, +) -> Result<(OneTimeDestination, PublicKey), PlatformWalletError> { + let secp = Secp256k1::new(); + let ephemeral_public = PublicKey::from_secret_key(&secp, ephemeral_secret); + let shared = stealth_shared_point(&secp, ephemeral_secret, scan_public)?; + let one_time_public = one_time_public_key( + &secp, + spend_public, + &shared, + &ephemeral_public, + rail.into(), + n, + )?; + Ok(( + destination_from_public_key(&one_time_public, rail, network), + ephemeral_public, + )) +} + +/// Receiver / watch service: re-derive the one-time destination for output `n` +/// given the payer's published `R`, the recipient's scan secret, and the +/// recipient's spend public key. Compare the result against the referenced +/// on-chain output. Requires no spend secret. +pub fn recognize_one_time_destination( + scan_secret: &SecretKey, + spend_public: &PublicKey, + ephemeral_public: &PublicKey, + rail: PaymentRail, + n: u32, + network: Network, +) -> Result { + let secp = Secp256k1::new(); + let shared = stealth_shared_point(&secp, scan_secret, ephemeral_public)?; + let one_time_public = one_time_public_key( + &secp, + spend_public, + &shared, + ephemeral_public, + rail.into(), + n, + )?; + Ok(destination_from_public_key(&one_time_public, rail, network)) +} + +/// Spender: derive the one-time secret key controlling output `n`, given the +/// payer's published `R`, the recipient's scan secret, and the recipient's +/// spend secret. +pub fn derive_one_time_secret_key( + scan_secret: &SecretKey, + spend_secret: &SecretKey, + ephemeral_public: &PublicKey, + rail: PaymentRail, + n: u32, +) -> Result { + let secp = Secp256k1::new(); + let shared = stealth_shared_point(&secp, scan_secret, ephemeral_public)?; + Ok(one_time_secret_key( + spend_secret, + &shared, + ephemeral_public, + rail.into(), + n, + )?) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn sk(byte: u8) -> SecretKey { + SecretKey::from_slice(&[byte; 32]).expect("valid scalar") + } + + /// End-to-end: what the payer derives on each rail is exactly what the + /// receiver recognizes and what the spender's secret key controls. + #[test] + fn payer_receiver_spender_agree_on_both_rails() { + let secp = Secp256k1::new(); + let scan_secret = sk(0x11); + let spend_secret = sk(0x22); + let ephemeral_secret = sk(0x33); + let scan_public = PublicKey::from_secret_key(&secp, &scan_secret); + let spend_public = PublicKey::from_secret_key(&secp, &spend_secret); + + for rail in [PaymentRail::Core, PaymentRail::Platform] { + for n in 0..3 { + let (payer_dest, r) = derive_one_time_destination( + &ephemeral_secret, + &scan_public, + &spend_public, + rail, + n, + Network::Testnet, + ) + .expect("payer derivation"); + + let receiver_dest = recognize_one_time_destination( + &scan_secret, + &spend_public, + &r, + rail, + n, + Network::Testnet, + ) + .expect("receiver derivation"); + assert_eq!(payer_dest, receiver_dest, "payer and receiver must agree"); + + let one_time_secret = + derive_one_time_secret_key(&scan_secret, &spend_secret, &r, rail, n) + .expect("spender derivation"); + let one_time_public = PublicKey::from_secret_key(&secp, &one_time_secret); + let spend_dest = + destination_from_public_key(&one_time_public, rail, Network::Testnet); + assert_eq!( + payer_dest, spend_dest, + "the one-time secret must control the destination" + ); + } + } + } + + /// The two rails must yield different destinations for identical inputs + /// (the rail byte in the tweak domain-separates them). + #[test] + fn rails_produce_distinct_destinations() { + let secp = Secp256k1::new(); + let scan_secret = sk(0x44); + let spend_secret = sk(0x55); + let ephemeral_secret = sk(0x66); + let scan_public = PublicKey::from_secret_key(&secp, &scan_secret); + let spend_public = PublicKey::from_secret_key(&secp, &spend_secret); + + let (core_dest, _) = derive_one_time_destination( + &ephemeral_secret, + &scan_public, + &spend_public, + PaymentRail::Core, + 0, + Network::Testnet, + ) + .expect("core"); + let (platform_dest, _) = derive_one_time_destination( + &ephemeral_secret, + &scan_public, + &spend_public, + PaymentRail::Platform, + 0, + Network::Testnet, + ) + .expect("platform"); + + let core_hash: [u8; 20] = match core_dest { + OneTimeDestination::Core(addr) => addr + .script_pubkey() + .p2pkh_public_key_hash_bytes() + .expect("p2pkh output") + .try_into() + .expect("20-byte hash"), + _ => panic!("expected core"), + }; + let platform_hash = match platform_dest { + OneTimeDestination::Platform(PlatformAddress::P2pkh(h)) => h, + _ => panic!("expected platform p2pkh"), + }; + assert_ne!(core_hash, platform_hash); + } + + /// The derivation path is fully hardened and shaped m/9'/coin'/33'/a'/c'/i'. + #[test] + fn derivation_path_is_hardened_and_well_formed() { + let path = payment_detection_derivation_path(Network::Mainnet, 0, KEY_CLASS_SCAN, 0) + .expect("path"); + let segments: Vec = path.into_iter().copied().collect(); + assert_eq!(segments.len(), 6); + assert_eq!(segments[0], ChildNumber::from_hardened_idx(9).unwrap()); + assert_eq!(segments[1], ChildNumber::from_hardened_idx(5).unwrap()); + assert_eq!(segments[2], ChildNumber::from_hardened_idx(33).unwrap()); + assert!(segments.iter().all(|c| c.is_hardened())); + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/DPP/DPPIdentity.swift b/packages/swift-sdk/Sources/SwiftDashSDK/DPP/DPPIdentity.swift index 9271358c6a7..14b19cde113 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/DPP/DPPIdentity.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/DPP/DPPIdentity.swift @@ -35,6 +35,8 @@ public enum KeyPurpose: UInt8, CaseIterable, Codable, Sendable, CustomStringConv case system = 4 case voting = 5 case owner = 6 + case paymentScan = 7 + case paymentSpend = 8 public var name: String { switch self { @@ -45,6 +47,8 @@ public enum KeyPurpose: UInt8, CaseIterable, Codable, Sendable, CustomStringConv case .system: return "System" case .voting: return "Voting" case .owner: return "Owner" + case .paymentScan: return "Payment Scan" + case .paymentSpend: return "Payment Spend" } } @@ -57,6 +61,8 @@ public enum KeyPurpose: UInt8, CaseIterable, Codable, Sendable, CustomStringConv case .system: return "System level operations" case .voting: return "Used for voting (masternodes)" case .owner: return "Owner key (masternodes)" + case .paymentScan: return "Used to detect DIP-33 stealth payments" + case .paymentSpend: return "Used to spend DIP-33 stealth payments" } } diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift index b8b1bcde6cb..5fd010638e6 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift @@ -2161,9 +2161,16 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { if let existing = try? backgroundContext.fetch(descriptor).first { row = existing } else { - let purposeEnum = KeyPurpose(rawValue: entry.purpose) ?? .authentication - let levelEnum = SecurityLevel(rawValue: entry.securityLevel) ?? .high - let keyTypeEnum = KeyType(rawValue: entry.keyType) ?? .ecdsaSecp256k1 + // Never coerce an unknown discriminant: the stored raw value is + // sent back to Rust on cold-start restore, so persisting a + // substitute silently changes the key's meaning. Skip instead; + // the key re-arrives once the enum mirror learns the value. + guard let purposeEnum = KeyPurpose(rawValue: entry.purpose), + let levelEnum = SecurityLevel(rawValue: entry.securityLevel), + let keyTypeEnum = KeyType(rawValue: entry.keyType) else { + print("⚠️ persistIdentityKeys: skipped key \(entry.keyId) for identity \(identityHex.prefix(8))… — unsupported purpose/level/type discriminant (\(entry.purpose)/\(entry.securityLevel)/\(entry.keyType))") + continue + } row = PersistentPublicKey( keyId: targetKeyId, purpose: purposeEnum, diff --git a/packages/wasm-dpp/src/errors/consensus/consensus_error.rs b/packages/wasm-dpp/src/errors/consensus/consensus_error.rs index 9701a5407d2..5380daca0a4 100644 --- a/packages/wasm-dpp/src/errors/consensus/consensus_error.rs +++ b/packages/wasm-dpp/src/errors/consensus/consensus_error.rs @@ -65,7 +65,7 @@ use wasm_bindgen::{JsError, JsValue}; use dpp::consensus::basic::data_contract::{ContestedUniqueIndexOnMutableDocumentTypeError, ContestedUniqueIndexWithUniqueIndexError, DataContractTokenConfigurationUpdateError, DecimalsOverLimitError, DuplicateKeywordsError, GroupExceedsMaxMembersError, GroupHasTooFewMembersError, GroupMemberHasPowerOfZeroError, GroupMemberHasPowerOverLimitError, GroupNonUnilateralMemberPowerHasLessThanRequiredPowerError, GroupPositionDoesNotExistError, GroupRequiredPowerIsInvalidError, GroupTotalPowerLessThanRequiredError, InvalidDescriptionLengthError, InvalidDocumentTypeRequiredSecurityLevelError, InvalidKeywordCharacterError, InvalidKeywordLengthError, InvalidTokenBaseSupplyError, InvalidTokenDistributionFunctionDivideByZeroError, InvalidTokenDistributionFunctionIncoherenceError, InvalidTokenDistributionFunctionInvalidParameterError, InvalidTokenDistributionFunctionInvalidParameterTupleError, InvalidTokenLanguageCodeError, InvalidTokenNameCharacterError, InvalidTokenNameLengthError, MainGroupIsNotDefinedError, NewTokensDestinationIdentityOptionRequiredError, NonContiguousContractGroupPositionsError, NonContiguousContractTokenPositionsError, RedundantDocumentPaidForByTokenWithContractId, TokenPaymentByBurningOnlyAllowedOnInternalTokenError, TooManyKeywordsError, UnknownDocumentActionTokenEffectError, UnknownDocumentCreationRestrictionModeError, UnknownGasFeesPaidByError, UnknownSecurityLevelError, UnknownStorageKeyRequirementsError, UnknownTradeModeError, UnknownTransferableTypeError}; use dpp::consensus::basic::document::{ContestedDocumentsTemporarilyNotAllowedError, DocumentCreationNotAllowedError, DocumentFieldMaxSizeExceededError, MaxDocumentsTransitionsExceededError, MissingPositionsInDocumentTypePropertiesError}; use dpp::consensus::basic::group::GroupActionNotAllowedOnTransitionError; -use dpp::consensus::basic::identity::{DataContractBoundsNotPresentError, DisablingKeyIdAlsoBeingAddedInSameTransitionError, InvalidIdentityCreditWithdrawalTransitionAmountError, InvalidIdentityUpdateTransitionDisableKeysError, InvalidIdentityUpdateTransitionEmptyError, InvalidKeyPurposeForContractBoundsError, TooManyMasterPublicKeyError, WithdrawalOutputScriptNotAllowedWhenSigningWithOwnerKeyError}; +use dpp::consensus::basic::identity::{DataContractBoundsNotPresentError, DisablingKeyIdAlsoBeingAddedInSameTransitionError, InvalidIdentityCreditWithdrawalTransitionAmountError, InvalidIdentityUpdateTransitionDisableKeysError, InvalidIdentityUpdateTransitionEmptyError, InvalidKeyPurposeForContractBoundsError, InvalidKeyPurposeKeyTypeError, TooManyMasterPublicKeyError, TooManyPublicKeysOfPurposeError, WithdrawalOutputScriptNotAllowedWhenSigningWithOwnerKeyError}; use dpp::consensus::basic::overflow_error::OverflowError; use dpp::consensus::basic::token::{ChoosingTokenMintRecipientNotAllowedError, ContractHasNoTokensError, DestinationIdentityForTokenMintingNotSetError, InvalidActionIdError, InvalidTokenAmountError, InvalidTokenConfigUpdateNoChangeError, InvalidTokenIdError, InvalidTokenNoteTooBigError, InvalidTokenPositionError, MissingDefaultLocalizationError, TokenNoteOnlyAllowedWhenProposerError, TokenPricingScheduleEmptyError, TokenTransferToOurselfError, InvalidTokenDistributionTimeIntervalNotMinuteAlignedError, InvalidTokenDistributionTimeIntervalTooShortError, InvalidTokenDistributionBlockIntervalTooShortError}; use dpp::consensus::state::data_contract::data_contract_not_found_error::DataContractNotFoundError; @@ -894,6 +894,12 @@ fn from_basic_error(basic_error: &BasicError) -> JsValue { BasicError::InvalidKeyPurposeForContractBoundsError(e) => { generic_consensus_error!(InvalidKeyPurposeForContractBoundsError, e).into() } + BasicError::InvalidKeyPurposeKeyTypeError(e) => { + generic_consensus_error!(InvalidKeyPurposeKeyTypeError, e).into() + } + BasicError::TooManyPublicKeysOfPurposeError(e) => { + generic_consensus_error!(TooManyPublicKeysOfPurposeError, e).into() + } BasicError::StateTransitionNotActiveError(e) => { generic_consensus_error!(StateTransitionNotActiveError, e).into() } diff --git a/packages/wasm-dpp/src/identity/identity_public_key/purpose.rs b/packages/wasm-dpp/src/identity/identity_public_key/purpose.rs index 33503d61790..9c3c9b5c736 100644 --- a/packages/wasm-dpp/src/identity/identity_public_key/purpose.rs +++ b/packages/wasm-dpp/src/identity/identity_public_key/purpose.rs @@ -2,6 +2,7 @@ use dpp::identity::Purpose; use wasm_bindgen::prelude::*; #[wasm_bindgen(js_name = KeyPurpose)] +#[allow(non_camel_case_types)] pub enum PurposeWasm { /// at least one authentication key must be registered for all security levels AUTHENTICATION = 0, @@ -17,6 +18,10 @@ pub enum PurposeWasm { VOTING = 5, /// this key is only for masternode owners OWNER = 6, + /// this key detects stealth payments and decrypts payment notifications (DIP-33) + PAYMENT_SCAN = 7, + /// this key is the spend base for stealth one-time payment addresses (DIP-33) + PAYMENT_SPEND = 8, } impl From for PurposeWasm { @@ -29,6 +34,8 @@ impl From for PurposeWasm { Purpose::SYSTEM => PurposeWasm::SYSTEM, Purpose::VOTING => PurposeWasm::VOTING, Purpose::OWNER => PurposeWasm::OWNER, + Purpose::PAYMENT_SCAN => PurposeWasm::PAYMENT_SCAN, + Purpose::PAYMENT_SPEND => PurposeWasm::PAYMENT_SPEND, } } } diff --git a/packages/wasm-dpp2/src/enums/keys/purpose.rs b/packages/wasm-dpp2/src/enums/keys/purpose.rs index 7a250a65bcf..9cf5c488920 100644 --- a/packages/wasm-dpp2/src/enums/keys/purpose.rs +++ b/packages/wasm-dpp2/src/enums/keys/purpose.rs @@ -8,7 +8,7 @@ const TS_TYPES: &str = r#" /** * Flexible input type for Purpose - accepts the enum, string name, or numeric value. */ -export type PurposeLike = Purpose | "authentication" | "encryption" | "decryption" | "transfer" | "system" | "voting" | "owner" | 0 | 1 | 2 | 3 | 4 | 5 | 6; +export type PurposeLike = Purpose | "authentication" | "encryption" | "decryption" | "transfer" | "system" | "voting" | "owner" | "paymentScan" | "paymentSpend" | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8; "#; #[wasm_bindgen] @@ -36,6 +36,7 @@ impl TryFrom for Purpose { } #[wasm_bindgen(js_name = "Purpose")] +#[allow(non_camel_case_types)] pub enum PurposeWasm { AUTHENTICATION = 0, ENCRYPTION = 1, @@ -44,6 +45,8 @@ pub enum PurposeWasm { SYSTEM = 4, VOTING = 5, OWNER = 6, + PAYMENT_SCAN = 7, + PAYMENT_SPEND = 8, } impl TryFrom<&JsValue> for PurposeWasm { @@ -59,6 +62,10 @@ impl TryFrom<&JsValue> for PurposeWasm { "system" => Ok(PurposeWasm::SYSTEM), "voting" => Ok(PurposeWasm::VOTING), "owner" => Ok(PurposeWasm::OWNER), + // both the camelCase PurposeLike names (lowercased) and the + // SCREAMING_SNAKE getter output (lowercased) must round-trip + "paymentscan" | "payment_scan" => Ok(PurposeWasm::PAYMENT_SCAN), + "paymentspend" | "payment_spend" => Ok(PurposeWasm::PAYMENT_SPEND), _ => Err(WasmDppError::invalid_argument(format!( "unsupported purpose value ({})", enum_val @@ -67,6 +74,17 @@ impl TryFrom<&JsValue> for PurposeWasm { } if let Some(enum_val) = value.as_f64() { + // reject NaN/fractional/out-of-range numbers instead of letting the + // `as u8` cast truncate them onto a valid discriminant + if !enum_val.is_finite() + || enum_val.fract() != 0.0 + || !(0.0..=255.0).contains(&enum_val) + { + return Err(WasmDppError::invalid_argument(format!( + "unsupported purpose value ({})", + enum_val + ))); + } return match enum_val as u8 { 0 => Ok(PurposeWasm::AUTHENTICATION), 1 => Ok(PurposeWasm::ENCRYPTION), @@ -75,6 +93,8 @@ impl TryFrom<&JsValue> for PurposeWasm { 4 => Ok(PurposeWasm::SYSTEM), 5 => Ok(PurposeWasm::VOTING), 6 => Ok(PurposeWasm::OWNER), + 7 => Ok(PurposeWasm::PAYMENT_SCAN), + 8 => Ok(PurposeWasm::PAYMENT_SPEND), _ => Err(WasmDppError::invalid_argument(format!( "unsupported purpose value ({})", enum_val @@ -106,6 +126,8 @@ impl From for String { PurposeWasm::SYSTEM => String::from("SYSTEM"), PurposeWasm::VOTING => String::from("VOTING"), PurposeWasm::OWNER => String::from("OWNER"), + PurposeWasm::PAYMENT_SCAN => String::from("PAYMENT_SCAN"), + PurposeWasm::PAYMENT_SPEND => String::from("PAYMENT_SPEND"), } } } @@ -120,6 +142,8 @@ impl From for Purpose { PurposeWasm::SYSTEM => Purpose::SYSTEM, PurposeWasm::VOTING => Purpose::VOTING, PurposeWasm::OWNER => Purpose::OWNER, + PurposeWasm::PAYMENT_SCAN => Purpose::PAYMENT_SCAN, + PurposeWasm::PAYMENT_SPEND => Purpose::PAYMENT_SPEND, } } } @@ -134,6 +158,8 @@ impl From for PurposeWasm { Purpose::SYSTEM => PurposeWasm::SYSTEM, Purpose::VOTING => PurposeWasm::VOTING, Purpose::OWNER => PurposeWasm::OWNER, + Purpose::PAYMENT_SCAN => PurposeWasm::PAYMENT_SCAN, + Purpose::PAYMENT_SPEND => PurposeWasm::PAYMENT_SPEND, } } }