From 47b05e38aed5edca044395206c406dab8a301941 Mon Sep 17 00:00:00 2001 From: pasta Date: Mon, 7 Sep 2026 18:29:34 -0500 Subject: [PATCH] feat(sdk): pure DPNS and DashPay document builders shared with embedders dash-platform-queries gains build_dpns_preorder_document / build_dpns_domain_document / salted_domain_hash (dpns_usernames) and build_contact_request_document (new dashpay module): the document-assembly halves of dash-sdk's register_dpns_name and create_contact_request as pure functions that take caller-supplied entropy, salt and ciphertexts and touch no network or randomness. dash-sdk's networked flows now call them, so an embedder that assembles its own transitions (the Dash Core platform GUI) and the SDK share one implementation. The builders lean on what the codebase already has rather than re-deriving it: normalization is dpp's consensus convert_to_homograph_safe_chars (the crate's ASCII-only copy, whose non-ASCII behaviour differed from the data trigger's, is replaced by a re-export); the preorder commitment uses dpp::util::hash::hash_double; property names come from the dpns-contract / dashpay-contract constants; and the DashPay byte-array bounds (96 / 48-80 / 38-102) are read from the contract schema instead of being hard-coded. The DPNS domain builder rejects a label the contract's pattern refuses before a preorder is paid for. The entropy/document-id consistency check moves into dpp's DocumentCreateTransitionV0::from_document, where every create-transition caller (SDK, wasm, FFI, embedders) inherits it instead of having to remember an opt-in helper; it refuses with the same InvalidDocumentTransitionIdError Drive would return after the nonce bump. dash-sdk's private ensure_entropy_matches_document_id and its tests are removed in favour of that. API shape (unreleased v4.2-dev): ContactRequestResult now carries the assembled document plus entropy instead of id/owner_id/properties; send_contact_request no longer hand-rebuilds a DocumentV0. --- packages/dash-platform-queries/Cargo.toml | 2 + packages/dash-platform-queries/src/dashpay.rs | 190 +++++++++++++++ .../src/dpns_usernames.rs | 227 ++++++++++++++++-- packages/dash-platform-queries/src/error.rs | 7 + packages/dash-platform-queries/src/lib.rs | 1 + .../v0/from_document.rs | 18 ++ .../document_create_transition/v0/mod.rs | 57 +++++ .../src/platform/dashpay/contact_request.rs | 206 +++++----------- packages/rs-sdk/src/platform/dashpay/mod.rs | 3 + .../rs-sdk/src/platform/dpns_usernames/mod.rs | 113 +-------- .../src/platform/transition/put_document.rs | 101 +------- 11 files changed, 568 insertions(+), 357 deletions(-) create mode 100644 packages/dash-platform-queries/src/dashpay.rs 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();