diff --git a/packages/dash-platform-queries/Cargo.toml b/packages/dash-platform-queries/Cargo.toml index f3d76e7c747..7c1bf7ef225 100644 --- a/packages/dash-platform-queries/Cargo.toml +++ b/packages/dash-platform-queries/Cargo.toml @@ -24,6 +24,8 @@ dapi-grpc = { path = "../dapi-grpc", default-features = false, features = [ dash-context-provider = { path = "../rs-context-provider", default-features = false } dash-platform-macros = { path = "../rs-dash-platform-macros" } dpp = { path = "../rs-dpp", default-features = false, features = [ + "dashpay-contract", + "dpns-contract", "platform-value-cbor", "state-transitions", "state-transition-validation", diff --git a/packages/dash-platform-queries/src/dashpay.rs b/packages/dash-platform-queries/src/dashpay.rs new file mode 100644 index 00000000000..eb6e10de168 --- /dev/null +++ b/packages/dash-platform-queries/src/dashpay.rs @@ -0,0 +1,190 @@ +//! Transport-free DashPay document assembly. +//! +//! The Sdk-bound DashPay surface (ECDH, encryption, fetching the recipient, +//! broadcasting) lives in `dash-sdk`; this is the pure DIP-15 document +//! assembly it shares with embedders that hold the encrypted material +//! themselves. Field size bounds come from the DashPay contract schema, so +//! the builder cannot drift from what the contract accepts. + +use crate::dpns_usernames::new_document; +use crate::Error; +use dpp::data_contract::accessors::v0::DataContractV0Getters; +use dpp::data_contract::document_type::accessors::DocumentTypeV0Getters; +use dpp::data_contract::document_type::DocumentTypeRef; +use dpp::data_contract::errors::DataContractError; +use dpp::document::Document; +use dpp::platform_value::Value; +use dpp::prelude::{DataContract, Identifier}; +use dpp::system_data_contracts::dashpay_contract::v1::document_types::contact_request; +use std::collections::BTreeMap; + +/// Inputs of a DIP-15 `contactRequest` document. The encrypted fields are +/// supplied already encrypted (ECDH, AES-CBC with a fresh IV prepended); +/// see `dash-sdk`'s `create_contact_request` for the encryption itself. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ContactRequestDocumentParams { + /// Identity sending the request; becomes the document owner. + pub sender_id: Identifier, + /// Identity receiving the request (`toUserId`). + pub recipient_id: Identifier, + /// Index of the sender's encryption key used for ECDH. + pub sender_key_index: u32, + /// Index of the recipient's key used for ECDH. + pub recipient_key_index: u32, + /// DashPay receiving-account reference. + pub account_reference: u32, + /// Encrypted DIP-15 compact extended public key (IV ‖ ciphertext). + pub encrypted_public_key: Vec, + /// Encrypted account label (IV ‖ ciphertext), if any. + pub encrypted_account_label: Option>, + /// Unencrypted auto-accept proof, if any. + pub auto_accept_proof: Option>, + /// Entropy the document id derives from; reuse it on the create + /// transition. + pub entropy: [u8; 32], +} + +/// Assemble a `contactRequest` document, checking every byte-array field +/// against the size bounds the contract schema declares for it. +pub fn build_contact_request_document( + contract: &DataContract, + params: ContactRequestDocumentParams, +) -> Result { + let document_type = contract.document_type_for_name(contact_request::NAME)?; + + check_byte_field( + document_type, + "encryptedPublicKey", + ¶ms.encrypted_public_key, + )?; + if let Some(label) = ¶ms.encrypted_account_label { + check_byte_field(document_type, "encryptedAccountLabel", label)?; + } + if let Some(proof) = ¶ms.auto_accept_proof { + check_byte_field(document_type, "autoAcceptProof", proof)?; + } + + let mut properties = BTreeMap::from([ + ( + contact_request::properties::TO_USER_ID.to_string(), + Value::Identifier(params.recipient_id.to_buffer()), + ), + ( + "encryptedPublicKey".to_string(), + Value::Bytes(params.encrypted_public_key), + ), + ( + "senderKeyIndex".to_string(), + Value::U32(params.sender_key_index), + ), + ( + "recipientKeyIndex".to_string(), + Value::U32(params.recipient_key_index), + ), + ( + "accountReference".to_string(), + Value::U32(params.account_reference), + ), + ]); + if let Some(label) = params.encrypted_account_label { + properties.insert("encryptedAccountLabel".to_string(), Value::Bytes(label)); + } + if let Some(proof) = params.auto_accept_proof { + properties.insert("autoAcceptProof".to_string(), Value::Bytes(proof)); + } + + Ok(new_document( + contract, + document_type.name(), + params.sender_id, + params.entropy, + properties, + )) +} + +/// Check `bytes` against the `minItems`/`maxItems` the contract declares for +/// the byte-array property `field`. +fn check_byte_field( + document_type: DocumentTypeRef, + field: &str, + bytes: &[u8], +) -> Result<(), Error> { + let property = document_type.properties().get(field).ok_or_else(|| { + DataContractError::DocumentTypeFieldNotFound(format!( + "{} has no property {field}", + document_type.name() + )) + })?; + let min = property.property_type.min_size().unwrap_or(0) as usize; + let max = property.property_type.max_size().unwrap_or(u16::MAX) as usize; + if bytes.len() < min || bytes.len() > max { + return Err(Error::Config(format!( + "{field} must be {min}-{max} bytes, got {}", + bytes.len() + ))); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use dpp::document::DocumentV0Getters; + use dpp::system_data_contracts::{load_system_data_contract, SystemDataContract}; + use dpp::version::PlatformVersion; + + fn params(encrypted_public_key: Vec) -> ContactRequestDocumentParams { + ContactRequestDocumentParams { + sender_id: Identifier::from([1u8; 32]), + recipient_id: Identifier::from([2u8; 32]), + sender_key_index: 1, + recipient_key_index: 2, + account_reference: 3, + encrypted_public_key, + encrypted_account_label: None, + auto_accept_proof: None, + entropy: [7u8; 32], + } + } + + fn contract() -> DataContract { + load_system_data_contract(SystemDataContract::Dashpay, PlatformVersion::latest()) + .expect("dashpay contract") + } + + #[test] + fn should_derive_the_document_id_from_the_entropy() { + let contract = contract(); + let document = build_contact_request_document(&contract, params(vec![0u8; 96])) + .expect("valid contact request"); + assert_eq!( + document.id(), + Document::generate_document_id_v0( + &contract.id(), + &Identifier::from([1u8; 32]), + contact_request::NAME, + &[7u8; 32] + ) + ); + assert_eq!(document.owner_id(), Identifier::from([1u8; 32])); + assert_eq!( + document.get("toUserId"), + Some(&Value::Identifier([2u8; 32])) + ); + } + + #[test] + fn should_enforce_the_contract_byte_bounds() { + let contract = contract(); + build_contact_request_document(&contract, params(vec![0u8; 95])) + .expect_err("encryptedPublicKey below the schema's 96 bytes"); + let mut with_label = params(vec![0u8; 96]); + with_label.encrypted_account_label = Some(vec![0u8; 81]); + build_contact_request_document(&contract, with_label) + .expect_err("encryptedAccountLabel above the schema's 80 bytes"); + let mut with_proof = params(vec![0u8; 96]); + with_proof.auto_accept_proof = Some(vec![0u8; 37]); + build_contact_request_document(&contract, with_proof) + .expect_err("autoAcceptProof below the schema's 38 bytes"); + } +} diff --git a/packages/dash-platform-queries/src/dpns_usernames.rs b/packages/dash-platform-queries/src/dpns_usernames.rs index 3f452519b29..87840f3e742 100644 --- a/packages/dash-platform-queries/src/dpns_usernames.rs +++ b/packages/dash-platform-queries/src/dpns_usernames.rs @@ -2,21 +2,27 @@ //! //! The Sdk-bound DPNS surface (registration, availability checks, name //! resolution) lives in `dash-sdk`; these free functions are pure string -//! validation/normalization shared with embedders. +//! validation/normalization and document assembly shared with embedders. +//! Normalization is dpp's consensus implementation +//! ([`convert_to_homograph_safe_chars`]), the same one the DPNS data trigger +//! checks `normalizedLabel` against. -/// Convert a string to homograph-safe characters by replacing 'o', 'i', and 'l' -/// with '0', '1', and '1' respectively to prevent homograph attacks -pub fn convert_to_homograph_safe_chars(input: &str) -> String { - input - .chars() - .map(|c| match c { - 'o' | 'O' => '0', - 'i' | 'I' => '1', - 'l' | 'L' => '1', - _ => c.to_ascii_lowercase(), - }) - .collect() -} +use crate::Error; +use dpp::data_contract::accessors::v0::DataContractV0Getters; +use dpp::data_contract::document_type::accessors::DocumentTypeV0Getters; +use dpp::document::{Document, DocumentV0}; +use dpp::platform_value::Value; +use dpp::prelude::{DataContract, Identifier}; +use dpp::system_data_contracts::dpns_contract::v1::document_types::domain; +use dpp::util::hash::hash_double; +use std::collections::BTreeMap; + +/// Document type name of the DPNS preorder document. +pub const PREORDER_DOCUMENT_TYPE: &str = "preorder"; +/// The only parent domain names can currently be registered under. +pub const DASH_PARENT_DOMAIN: &str = "dash"; + +pub use dpp::util::strings::convert_to_homograph_safe_chars; /// Check if a username is valid according to DPNS rules /// @@ -97,10 +103,203 @@ pub fn is_contested_username(label: &str) -> bool { .all(|c| matches!(c, 'a'..='z' | '0' | '1' | '-')) } +/// The DPNS `preorder` document that blinds `label`.dash behind `salt`. +/// +/// `saltedDomainHash` is `sha256d(salt ‖ ".dash")` — the +/// same preimage the DPNS data trigger recomputes when the paired `domain` +/// document is created. The document id derives from `entropy`, which must +/// be reused on the create transition. +/// +/// Callers driving their own registration must draw `salt` from a CSPRNG and +/// keep it, the label, and the domain document private until the preorder is +/// confirmed, or the preorder's front-running protection is lost. +pub fn build_dpns_preorder_document( + contract: &DataContract, + owner_id: Identifier, + label: &str, + salt: [u8; 32], + entropy: [u8; 32], +) -> Result { + let document_type = contract.document_type_for_name(PREORDER_DOCUMENT_TYPE)?; + let properties = BTreeMap::from([( + "saltedDomainHash".to_string(), + Value::Bytes32(salted_domain_hash(label, salt)), + )]); + Ok(new_document( + contract, + document_type.name(), + owner_id, + entropy, + properties, + )) +} + +/// The DPNS `domain` document registering `label`.dash for `owner_id`, with +/// the identity record pointing at the owner and subdomains disallowed. +/// +/// Rejects a label the DPNS contract's consensus pattern would refuse, so an +/// invalid name fails before a preorder is paid for. `normalizedLabel` is the +/// consensus normalization of `label`. +pub fn build_dpns_domain_document( + contract: &DataContract, + owner_id: Identifier, + label: &str, + salt: [u8; 32], + entropy: [u8; 32], +) -> Result { + if !is_valid_username(label) { + return Err(Error::Config(format!( + "DPNS label {label:?} does not match the contract's label pattern" + ))); + } + let document_type = contract.document_type_for_name(domain::NAME)?; + let properties = BTreeMap::from([ + ( + domain::properties::PARENT_DOMAIN_NAME.to_string(), + Value::Text(DASH_PARENT_DOMAIN.to_string()), + ), + ( + domain::properties::NORMALIZED_PARENT_DOMAIN_NAME.to_string(), + Value::Text(DASH_PARENT_DOMAIN.to_string()), + ), + ( + domain::properties::LABEL.to_string(), + Value::Text(label.to_string()), + ), + ( + domain::properties::NORMALIZED_LABEL.to_string(), + Value::Text(convert_to_homograph_safe_chars(label)), + ), + ( + domain::properties::PREORDER_SALT.to_string(), + Value::Bytes32(salt), + ), + ( + domain::properties::RECORDS.to_string(), + Value::Map(vec![( + Value::Text(domain::properties::IDENTITY.to_string()), + Value::Identifier(owner_id.to_buffer()), + )]), + ), + ( + "subdomainRules".to_string(), + Value::Map(vec![( + Value::Text("allowSubdomains".to_string()), + Value::Bool(false), + )]), + ), + ]); + Ok(new_document( + contract, + document_type.name(), + owner_id, + entropy, + properties, + )) +} + +/// `sha256d(salt ‖ ".dash")`: the preorder commitment the +/// DPNS data trigger recomputes from the domain document. +pub fn salted_domain_hash(label: &str, salt: [u8; 32]) -> [u8; 32] { + let mut preimage = salt.to_vec(); + preimage.extend_from_slice(convert_to_homograph_safe_chars(label).as_bytes()); + preimage.extend_from_slice(b"."); + preimage.extend_from_slice(DASH_PARENT_DOMAIN.as_bytes()); + hash_double(preimage) +} + +/// A fresh document whose id derives from `entropy`, with every +/// chain-assigned field left unset (they never enter a create transition). +pub(crate) fn new_document( + contract: &DataContract, + document_type_name: &str, + owner_id: Identifier, + entropy: [u8; 32], + properties: BTreeMap, +) -> Document { + Document::V0(DocumentV0 { + id: Document::generate_document_id_v0( + &contract.id(), + &owner_id, + document_type_name, + &entropy, + ), + owner_id, + properties, + ..Default::default() + }) +} + #[cfg(test)] mod tests { use super::*; + #[test] + fn should_build_preorder_and_domain_documents_that_agree() { + use dpp::document::DocumentV0Getters; + use dpp::system_data_contracts::{load_system_data_contract, SystemDataContract}; + use dpp::version::PlatformVersion; + + let contract = + load_system_data_contract(SystemDataContract::DPNS, PlatformVersion::latest()) + .expect("dpns contract"); + let owner = Identifier::from([1u8; 32]); + let salt = [5u8; 32]; + let entropy = [9u8; 32]; + + let preorder = build_dpns_preorder_document(&contract, owner, "Alice", salt, entropy) + .expect("preorder"); + let domain = + build_dpns_domain_document(&contract, owner, "Alice", salt, entropy).expect("domain"); + + // The commitment in the preorder is the one the DPNS data trigger + // recomputes from the domain document's salt and normalized label. + assert_eq!( + preorder.get("saltedDomainHash"), + Some(&Value::Bytes32(salted_domain_hash("Alice", salt))) + ); + assert_eq!( + domain.get(domain::properties::NORMALIZED_LABEL), + Some(&Value::Text("a11ce".to_string())) + ); + assert_eq!( + domain.get(domain::properties::LABEL), + Some(&Value::Text("Alice".to_string())) + ); + assert_eq!( + domain.get(domain::properties::PREORDER_SALT), + Some(&Value::Bytes32(salt)) + ); + assert_eq!( + preorder.id(), + Document::generate_document_id_v0( + &contract.id(), + &owner, + PREORDER_DOCUMENT_TYPE, + &entropy + ) + ); + assert_ne!(preorder.id(), domain.id()); + } + + #[test] + fn should_refuse_a_label_the_contract_pattern_rejects() { + use dpp::system_data_contracts::{load_system_data_contract, SystemDataContract}; + use dpp::version::PlatformVersion; + + let contract = + load_system_data_contract(SystemDataContract::DPNS, PlatformVersion::latest()) + .expect("dpns contract"); + build_dpns_domain_document( + &contract, + Identifier::from([1u8; 32]), + "-bad", + [0; 32], + [0; 32], + ) + .expect_err("leading hyphen violates the label pattern"); + } + #[test] fn test_convert_to_homograph_safe_chars() { assert_eq!(convert_to_homograph_safe_chars("alice"), "a11ce"); diff --git a/packages/dash-platform-queries/src/error.rs b/packages/dash-platform-queries/src/error.rs index 0d8727763ce..ecfad70370b 100644 --- a/packages/dash-platform-queries/src/error.rs +++ b/packages/dash-platform-queries/src/error.rs @@ -1,6 +1,7 @@ //! Errors produced by the transport-free query core. use dpp::consensus::ConsensusError; +use dpp::data_contract::errors::DataContractError; use dpp::validation::SimpleConsensusValidationResult; use dpp::ProtocolError; @@ -23,6 +24,12 @@ pub enum Error { Protocol(#[from] ProtocolError), } +impl From for Error { + fn from(value: DataContractError) -> Self { + Self::Protocol(ProtocolError::DataContractError(value)) + } +} + impl From for Error { fn from(value: ConsensusError) -> Self { Self::Protocol(ProtocolError::ConsensusError(Box::new(value))) diff --git a/packages/dash-platform-queries/src/lib.rs b/packages/dash-platform-queries/src/lib.rs index 9da47cf6435..29b71b8f434 100644 --- a/packages/dash-platform-queries/src/lib.rs +++ b/packages/dash-platform-queries/src/lib.rs @@ -13,6 +13,7 @@ #![allow(clippy::result_large_err)] pub mod block_info_from_metadata; +pub mod dashpay; pub mod documents; pub mod dpns_usernames; pub mod error; diff --git a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/document_create_transition/v0/from_document.rs b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/document_create_transition/v0/from_document.rs index 126062a1659..cf705c19272 100644 --- a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/document_create_transition/v0/from_document.rs +++ b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/document_create_transition/v0/from_document.rs @@ -1,3 +1,5 @@ +use crate::consensus::basic::document::InvalidDocumentTransitionIdError; +use crate::data_contract::document_type::accessors::DocumentTypeV0Getters; use crate::data_contract::document_type::methods::DocumentTypeV0Methods; use crate::data_contract::document_type::DocumentTypeRef; use crate::document::{Document, DocumentV0Getters}; @@ -18,6 +20,22 @@ impl DocumentCreateTransitionV0 { platform_version: &PlatformVersion, base_feature_version: Option, ) -> Result { + // Drive recomputes the document id from the entropy during + // advanced-structure validation and rejects a mismatch with + // InvalidDocumentTransitionIdError, after the identity contract nonce + // has already been bumped. Refuse locally so no caller can assemble a + // create transition that will only fail once it has been paid for. + let expected_id = Document::generate_document_id_v0( + &document_type.data_contract_id(), + &document.owner_id(), + document_type.name(), + &entropy, + ); + if document.id() != expected_id { + return Err(ProtocolError::ConsensusError(Box::new( + InvalidDocumentTransitionIdError::new(expected_id, document.id()).into(), + ))); + } let prefunded_voting_balance = document_type.prefunded_voting_balance_for_document(&document, platform_version)?; Ok(DocumentCreateTransitionV0 { diff --git a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/document_create_transition/v0/mod.rs b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/document_create_transition/v0/mod.rs index 876fc29bc10..3037766d817 100644 --- a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/document_create_transition/v0/mod.rs +++ b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/document_create_transition/v0/mod.rs @@ -600,6 +600,63 @@ mod test { ) } + /// Drive recomputes the document id from the create transition's entropy + /// and rejects a mismatch only after the identity contract nonce was + /// bumped; `from_document` must refuse the same mismatch locally. + #[test] + fn from_document_refuses_an_id_the_entropy_does_not_derive() { + use crate::consensus::basic::BasicError; + use crate::consensus::ConsensusError; + use crate::data_contract::accessors::v0::DataContractV0Getters; + use crate::document::{Document, DocumentV0}; + + let data_contract = data_contract_with_dynamic_properties(); + let document_type = data_contract + .document_type_for_name("test") + .expect("test document type"); + let owner_id = Identifier::from([2_u8; 32]); + let entropy = [7_u8; 32]; + let derived_id = + Document::generate_document_id_v0(&data_contract.id(), &owner_id, "test", &entropy); + let document = |id: Identifier| { + Document::V0(DocumentV0 { + id, + owner_id, + ..Default::default() + }) + }; + + DocumentCreateTransitionV0::from_document( + document(derived_id), + document_type, + entropy, + None, + 1, + LATEST_PLATFORM_VERSION, + None, + ) + .expect("an id derived from the entropy is accepted"); + + let error = DocumentCreateTransitionV0::from_document( + document(Identifier::from([9_u8; 32])), + document_type, + entropy, + None, + 1, + LATEST_PLATFORM_VERSION, + None, + ) + .expect_err("an id the entropy does not derive is refused"); + assert!( + matches!( + error, + ProtocolError::ConsensusError(ref boxed) + if matches!(**boxed, ConsensusError::BasicError(BasicError::InvalidDocumentTransitionIdError(_))) + ), + "unexpected error: {error:?}" + ); + } + #[test] #[cfg(feature = "json-conversion")] fn convert_to_json_with_dynamic_binary_paths() { diff --git a/packages/rs-sdk/src/platform/dashpay/contact_request.rs b/packages/rs-sdk/src/platform/dashpay/contact_request.rs index adf48b41150..bc26966e577 100644 --- a/packages/rs-sdk/src/platform/dashpay/contact_request.rs +++ b/packages/rs-sdk/src/platform/dashpay/contact_request.rs @@ -5,23 +5,23 @@ use crate::platform::transition::put_document::PutDocument; use crate::platform::Document; use crate::{Error, Sdk}; +use dash_platform_queries::dashpay::{ + build_contact_request_document, ContactRequestDocumentParams, +}; use dpp::dashcore::secp256k1::rand::rngs::StdRng; use dpp::dashcore::secp256k1::rand::{RngCore, SeedableRng}; use dpp::dashcore::secp256k1::{PublicKey, SecretKey}; use dpp::data_contract::accessors::v0::DataContractV0Getters; -use dpp::data_contract::document_type::accessors::DocumentTypeV0Getters; -use dpp::document::DocumentV0; use dpp::identity::accessors::IdentityGettersV0; use dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; use dpp::identity::identity_public_key::Purpose; use dpp::identity::signer::Signer; use dpp::identity::{Identity, IdentityPublicKey}; -use dpp::platform_value::{Bytes32, Value}; +use dpp::platform_value::Bytes32; use dpp::prelude::Identifier; use platform_encryption::{ derive_shared_key_ecdh, encrypt_account_label, encrypt_extended_public_key, COMPACT_XPUB_LEN, }; -use std::collections::BTreeMap; /// ECDH provider for contact request encryption /// @@ -105,13 +105,9 @@ pub struct ContactRequestInput { /// Result of creating a contact request document #[derive(Debug)] pub struct ContactRequestResult { - /// The document ID - pub id: Identifier, - /// The owner ID (sender identity ID) - pub owner_id: Identifier, - /// The document properties - pub properties: BTreeMap, - /// The entropy used to derive `id`. + /// The assembled `contactRequest` document (not yet broadcast). + pub document: Document, + /// The entropy used to derive the document id. /// /// This must be reused when broadcasting the document so that the /// document id computed at creation matches the id platform consensus @@ -259,16 +255,6 @@ impl Sdk { H: FnOnce(u32) -> Hut, Hut: std::future::Future, Error>>, { - // Validate auto accept proof size if provided - if let Some(ref proof) = input.auto_accept_proof { - if proof.len() < 38 || proof.len() > 102 { - return Err(Error::Generic(format!( - "autoAcceptProof must be 38-102 bytes, got {}", - proof.len() - ))); - } - } - // Fetch recipient identity if only ID was provided let recipient_identity = match input.recipient { RecipientIdentity::Identity(identity) => identity, @@ -366,27 +352,12 @@ impl Sdk { let encrypted_public_key = encrypt_extended_public_key(&shared_key, &xpub_iv, &extended_public_key); - // Validate encrypted public key size (must be exactly 96 bytes: 16-byte IV + 80-byte encrypted data) - if encrypted_public_key.len() != 96 { - return Err(Error::Generic(format!( - "Encrypted public key size mismatch: expected 96 bytes, got {}", - encrypted_public_key.len() - ))); - } - // Encrypt the account label if provided (includes IV prepended) let encrypted_account_label = if let Some(ref label) = input.account_label { let mut label_iv = [0u8; 16]; rng.fill_bytes(&mut label_iv); let encrypted = encrypt_account_label(&shared_key, &label_iv, label); - // Validate encrypted label size (48-80 bytes: 16-byte IV + 32-64 byte encrypted data) - if encrypted.len() < 48 || encrypted.len() > 80 { - return Err(Error::Generic(format!( - "Encrypted account label size out of range: expected 48-80 bytes, got {}", - encrypted.len() - ))); - } Some(encrypted) } else { None @@ -395,66 +366,28 @@ impl Sdk { // Fetch DashPay contract let dashpay_contract = self.fetch_dashpay_contract().await?; - // Get contactRequest document type - let contact_request_document_type = dashpay_contract - .document_type_for_name("contactRequest") - .map_err(|_| { - Error::Generic("DashPay contactRequest document type not found".to_string()) - })?; - // Generate entropy for document ID let mut rng = StdRng::from_entropy(); let entropy = Bytes32::random_with_rng(&mut rng); - // Generate document ID - let sender_id = input.sender_identity.id().to_owned(); - let document_id = Document::generate_document_id_v0( - &dashpay_contract.id(), - &sender_id, - contact_request_document_type.name(), - entropy.as_slice(), - ); - - // Build document properties - let mut properties = BTreeMap::new(); - let recipient_id = recipient_identity.id().to_owned(); - properties.insert( - "toUserId".to_string(), - Value::Identifier(recipient_id.to_buffer()), - ); - properties.insert( - "encryptedPublicKey".to_string(), - Value::Bytes(encrypted_public_key), - ); - properties.insert( - "senderKeyIndex".to_string(), - Value::U32(input.sender_key_index), - ); - properties.insert( - "recipientKeyIndex".to_string(), - Value::U32(input.recipient_key_index), - ); - properties.insert( - "accountReference".to_string(), - Value::U32(input.account_reference), - ); - - // Add optional fields - if let Some(label) = encrypted_account_label { - properties.insert("encryptedAccountLabel".to_string(), Value::Bytes(label)); - } - if let Some(proof) = input.auto_accept_proof { - properties.insert("autoAcceptProof".to_string(), Value::Bytes(proof)); - } - - // Return the essential fields for the contact request, including the - // entropy that derived `document_id` so the broadcast path can reuse it. - Ok(ContactRequestResult { - id: document_id, - owner_id: sender_id, - properties, - entropy, - }) + let document = build_contact_request_document( + &dashpay_contract, + ContactRequestDocumentParams { + sender_id: input.sender_identity.id().to_owned(), + recipient_id: recipient_identity.id().to_owned(), + sender_key_index: input.sender_key_index, + recipient_key_index: input.recipient_key_index, + account_reference: input.account_reference, + encrypted_public_key, + encrypted_account_label, + auto_accept_proof: input.auto_accept_proof, + entropy: entropy.0, + }, + )?; + + // Return the assembled document together with the entropy that + // derived its id so the broadcast path can reuse it. + Ok(ContactRequestResult { document, entropy }) } /// Send a contact request to the platform @@ -516,30 +449,10 @@ impl Sdk { Error::Generic("DashPay contactRequest document type not found".to_string()) })?; - // Reuse the entropy that derived result.id during creation. Platform - // consensus recomputes the document id from this entropy and rejects the - // create transition unless it matches result.id, so a freshly generated - // entropy here would always be rejected (InvalidDocumentTransitionIdError). + // Reuse the entropy that derived the document id during creation: + // consensus recomputes the id from it and rejects a mismatch. let entropy = result.entropy; - - // Create the document from the result - let document = Document::V0(DocumentV0 { - contract_version: None, - id: result.id, - owner_id: result.owner_id, - properties: result.properties, - revision: None, - created_at: None, - updated_at: None, - transferred_at: None, - created_at_block_height: None, - updated_at_block_height: None, - transferred_at_block_height: None, - created_at_core_block_height: None, - updated_at_core_block_height: None, - transferred_at_core_block_height: None, - creator_id: None, - }); + let document = result.document; // Submit the document to the platform let platform_document = document @@ -634,47 +547,48 @@ mod tests { #[test] fn contact_request_result_entropy_derives_returned_id() { - // Regression for G2 entropy mismatch: the document id returned by - // create_contact_request must be derivable from the entropy carried in - // ContactRequestResult. send_contact_request reuses ContactRequestResult::entropy - // when broadcasting, and platform consensus rejects the create transition - // (InvalidDocumentTransitionIdError) unless - // generate_document_id_v0(contract, owner, "contactRequest", entropy) == base.id. - // - // Without the `entropy` field on ContactRequestResult, - // send_contact_request would generate fresh entropy E2 != E1 and this - // invariant could not even be expressed. This test pins it. + // send_contact_request reuses ContactRequestResult::entropy when + // broadcasting; consensus recomputes the document id from it and + // rejects the create transition on mismatch. Pin that the shared + // builder derives the document id from exactly that entropy. + use dpp::document::DocumentV0Getters; + use dpp::system_data_contracts::{load_system_data_contract, SystemDataContract}; + use dpp::version::PlatformVersion; + let mut rng = StdRng::seed_from_u64(0x6732_4732); // deterministic, no network let entropy = Bytes32::random_with_rng(&mut rng); - - let contract_id = Identifier::from([1u8; 32]); + let contract = + load_system_data_contract(SystemDataContract::Dashpay, PlatformVersion::latest()) + .expect("dashpay contract"); let owner_id = Identifier::from([2u8; 32]); - let id = Document::generate_document_id_v0( - &contract_id, - &owner_id, - "contactRequest", - entropy.as_slice(), - ); - - let result = ContactRequestResult { - id, - owner_id, - properties: BTreeMap::new(), - entropy, - }; + let document = build_contact_request_document( + &contract, + ContactRequestDocumentParams { + sender_id: owner_id, + recipient_id: Identifier::from([3u8; 32]), + sender_key_index: 0, + recipient_key_index: 0, + account_reference: 0, + encrypted_public_key: vec![0u8; 96], + encrypted_account_label: None, + auto_accept_proof: None, + entropy: entropy.0, + }, + ) + .expect("assemble contact request"); + let result = ContactRequestResult { document, entropy }; - // The entropy that send_contact_request will broadcast must regenerate the - // exact id that was returned at creation time. let regenerated = Document::generate_document_id_v0( - &contract_id, - &result.owner_id, + &contract.id(), + &result.document.owner_id(), "contactRequest", result.entropy.as_slice(), ); assert_eq!( - regenerated, result.id, - "entropy carried in ContactRequestResult must derive the returned document id" + regenerated, + result.document.id(), + "entropy carried in ContactRequestResult must derive the document id" ); } diff --git a/packages/rs-sdk/src/platform/dashpay/mod.rs b/packages/rs-sdk/src/platform/dashpay/mod.rs index 182edd8854b..0a771e3ed63 100644 --- a/packages/rs-sdk/src/platform/dashpay/mod.rs +++ b/packages/rs-sdk/src/platform/dashpay/mod.rs @@ -12,6 +12,9 @@ pub use contact_request::{ EcdhProvider, RecipientIdentity, SendContactRequestInput, SendContactRequestResult, }; pub use contact_request_queries::ContactRequestDocuments; +pub use dash_platform_queries::dashpay::{ + build_contact_request_document, ContactRequestDocumentParams, +}; use crate::platform::Fetch; use crate::{Error, Sdk}; diff --git a/packages/rs-sdk/src/platform/dpns_usernames/mod.rs b/packages/rs-sdk/src/platform/dpns_usernames/mod.rs index f7831f29c4b..d1681b34c4a 100644 --- a/packages/rs-sdk/src/platform/dpns_usernames/mod.rs +++ b/packages/rs-sdk/src/platform/dpns_usernames/mod.rs @@ -3,7 +3,8 @@ mod queries; pub use contested_queries::ContestedDpnsUsername; pub use dash_platform_queries::dpns_usernames::{ - convert_to_homograph_safe_chars, is_contested_username, is_valid_username, + build_dpns_domain_document, build_dpns_preorder_document, convert_to_homograph_safe_chars, + is_contested_username, is_valid_username, salted_domain_hash, }; pub use queries::DpnsUsername; @@ -14,14 +15,12 @@ use dash_context_provider::ContextProvider; use dpp::dashcore::secp256k1::rand::rngs::StdRng; use dpp::dashcore::secp256k1::rand::{Rng, SeedableRng}; use dpp::data_contract::accessors::v0::DataContractV0Getters; -use dpp::data_contract::document_type::accessors::DocumentTypeV0Getters; -use dpp::document::{DocumentV0, DocumentV0Getters}; +use dpp::document::DocumentV0Getters; use dpp::identity::accessors::IdentityGettersV0; use dpp::identity::signer::Signer; use dpp::identity::{Identity, IdentityPublicKey}; use dpp::platform_value::{Bytes32, Value}; use dpp::prelude::Identifier; -use std::collections::BTreeMap; use std::sync::Arc; fn extract_dpns_label(name: &str) -> &str { @@ -45,14 +44,6 @@ fn normalize_dpns_label(input: &str) -> String { convert_to_homograph_safe_chars(extract_dpns_label(input)) } -/// Hash a buffer twice using SHA256 (double SHA256) -fn hash_double(data: Vec) -> [u8; 32] { - use dpp::dashcore::hashes::{sha256d, Hash}; - // sha256d already does double SHA256 - let hash = sha256d::Hash::hash(&data); - hash.to_byte_array() -} - /// Callback type for preorder document pub type PreorderCallback = Box; @@ -164,97 +155,17 @@ impl Sdk { let entropy = Bytes32::random_with_rng(&mut rng); let salt: [u8; 32] = rng.gen(); - // Generate document IDs let identity_id = input.identity.id().to_owned(); - let preorder_id = Document::generate_document_id_v0( - &dpns_contract.id(), - &identity_id, - preorder_document_type.name(), - entropy.as_slice(), - ); - let domain_id = Document::generate_document_id_v0( - &dpns_contract.id(), - &identity_id, - domain_document_type.name(), - entropy.as_slice(), - ); - - // Create salted domain hash for preorder + let preorder_document = build_dpns_preorder_document( + &dpns_contract, + identity_id, + &input.label, + salt, + entropy.0, + )?; + let domain_document = + build_dpns_domain_document(&dpns_contract, identity_id, &input.label, salt, entropy.0)?; let normalized_label = convert_to_homograph_safe_chars(&input.label); - let mut salted_domain_buffer: Vec = vec![]; - salted_domain_buffer.extend(salt); - salted_domain_buffer.extend((normalized_label.clone() + ".dash").as_bytes()); - let salted_domain_hash = hash_double(salted_domain_buffer); - - // Create preorder document - let preorder_document = Document::V0(DocumentV0 { - contract_version: None, - id: preorder_id, - owner_id: identity_id, - properties: BTreeMap::from([( - "saltedDomainHash".to_string(), - Value::Bytes32(salted_domain_hash), - )]), - revision: None, - created_at: None, - updated_at: None, - transferred_at: None, - created_at_block_height: None, - updated_at_block_height: None, - transferred_at_block_height: None, - created_at_core_block_height: None, - updated_at_core_block_height: None, - transferred_at_core_block_height: None, - creator_id: None, - }); - - // Create domain document - let domain_document = Document::V0(DocumentV0 { - contract_version: None, - id: domain_id, - owner_id: identity_id, - properties: BTreeMap::from([ - ( - "parentDomainName".to_string(), - Value::Text("dash".to_string()), - ), - ( - "normalizedParentDomainName".to_string(), - Value::Text("dash".to_string()), - ), - ("label".to_string(), Value::Text(input.label.clone())), - ( - "normalizedLabel".to_string(), - Value::Text(normalized_label.clone()), - ), - ("preorderSalt".to_string(), Value::Bytes32(salt)), - ( - "records".to_string(), - Value::Map(vec![( - Value::Text("identity".to_string()), - Value::Identifier(identity_id.to_buffer()), - )]), - ), - ( - "subdomainRules".to_string(), - Value::Map(vec![( - Value::Text("allowSubdomains".to_string()), - Value::Bool(false), - )]), - ), - ]), - revision: None, - created_at: None, - updated_at: None, - transferred_at: None, - created_at_block_height: None, - updated_at_block_height: None, - transferred_at_block_height: None, - created_at_core_block_height: None, - updated_at_core_block_height: None, - transferred_at_core_block_height: None, - creator_id: None, - }); // Submit preorder document first let platform_preorder_document = preorder_document diff --git a/packages/rs-sdk/src/platform/transition/put_document.rs b/packages/rs-sdk/src/platform/transition/put_document.rs index fa85a30a0dc..e932c0a5a4c 100644 --- a/packages/rs-sdk/src/platform/transition/put_document.rs +++ b/packages/rs-sdk/src/platform/transition/put_document.rs @@ -11,7 +11,6 @@ use dpp::data_contract::document_type::DocumentType; use dpp::document::{Document, DocumentV0Getters, DocumentV0Setters, INITIAL_REVISION}; use dpp::identity::signer::Signer; use dpp::identity::IdentityPublicKey; -use dpp::prelude::Identifier; use dpp::state_transition::batch_transition::methods::v0::DocumentsBatchTransitionMethodsV0; use dpp::state_transition::batch_transition::BatchTransition; use dpp::state_transition::StateTransition; @@ -88,21 +87,10 @@ impl> PutDocument for Document { } else { let (document, document_state_transition_entropy) = match document_state_transition_entropy { - Some(entropy) => { - // A caller-supplied entropy must derive the document's own id. - // Platform consensus recomputes generate_document_id_v0 from the - // transition entropy and rejects the create with - // InvalidDocumentTransitionIdError on mismatch, so guard here - // before broadcasting to fail locally (no wasted nonce/fee). - ensure_entropy_matches_document_id( - &document_type.data_contract_id(), - &document.owner_id(), - document_type.name(), - &entropy, - document.id(), - )?; - (document, entropy) - } + // A caller-supplied entropy must derive the document's own id; + // dpp's DocumentCreateTransition::from_document refuses a + // mismatch locally, before a nonce is bumped. + Some(entropy) => (document, entropy), None => { let mut rng = StdRng::from_entropy(); let mut document = document; @@ -171,45 +159,13 @@ fn prepare_document_for_transition(document: &Document, document_type: &Document document } -/// Ensures a caller-supplied `entropy` derives the same document id already set -/// on a create document. -/// -/// A document-create state transition carries both the document id and the -/// entropy, and Drive recomputes the id from the entropy during -/// `advanced_structure` validation, rejecting the transition with -/// `InvalidDocumentTransitionIdError` when they disagree. Because -/// [`PutDocument::put_to_platform`] trusts the caller's id verbatim in the -/// `Some(entropy)` arm, a two-phase caller whose id and entropy have drifted -/// would only discover the mismatch after paying (a bumped identity-contract -/// nonce). This check surfaces the mismatch locally before broadcasting. -fn ensure_entropy_matches_document_id( - contract_id: &Identifier, - owner_id: &Identifier, - document_type_name: &str, - entropy: &[u8; 32], - document_id: Identifier, -) -> Result<(), Error> { - let expected_id = Document::generate_document_id_v0( - contract_id, - owner_id, - document_type_name, - entropy.as_slice(), - ); - if expected_id != document_id { - return Err(Error::Generic(format!( - "document id {document_id} does not match the id {expected_id} derived from the \ - supplied entropy; the entropy must be the one used to generate the document id" - ))); - } - Ok(()) -} - #[cfg(test)] mod tests { use super::*; use dpp::data_contract::config::DataContractConfig; use dpp::document::DocumentV0; use dpp::platform_value::{platform_value, Value}; + use dpp::prelude::Identifier; use dpp::version::PlatformVersion; use std::collections::BTreeMap; @@ -221,53 +177,6 @@ mod tests { Identifier::from([2u8; 32]) } - #[test] - fn matching_entropy_and_id_pass() { - let entropy = [7u8; 32]; - let id = Document::generate_document_id_v0( - &contract_id(), - &owner_id(), - "contactRequest", - entropy.as_slice(), - ); - - ensure_entropy_matches_document_id( - &contract_id(), - &owner_id(), - "contactRequest", - &entropy, - id, - ) - .expect("id derived from the supplied entropy must be accepted"); - } - - #[test] - fn mismatched_entropy_and_id_error_before_broadcast() { - // The id was derived from E1, but the caller passes E2 != E1 (mirroring - // the very drift consensus rejects with InvalidDocumentTransitionIdError). - let entropy_used = [1u8; 32]; - let id = Document::generate_document_id_v0( - &contract_id(), - &owner_id(), - "contactRequest", - entropy_used.as_slice(), - ); - - let different_entropy = [2u8; 32]; - let result = ensure_entropy_matches_document_id( - &contract_id(), - &owner_id(), - "contactRequest", - &different_entropy, - id, - ); - - assert!( - matches!(result, Err(Error::Generic(_))), - "a document id derived from a different entropy must be rejected locally" - ); - } - #[test] fn should_normalize_wasm_uint8_array_property_without_mutating_caller_document() { let platform_version = PlatformVersion::latest();