From edb833b218f74971e11f63aed5cac53c0ffec9fd Mon Sep 17 00:00:00 2001 From: pasta Date: Mon, 7 Sep 2026 18:29:34 -0500 Subject: [PATCH 1/2] feat(sdk)!: pure DPNS and DashPay document builders shared with embedders MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. This is a move, not a rewrite: the documents these builders produce are byte for byte what the inline SDK code produced. 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 two agree on every label the contract's ASCII-only pattern admits); 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 to the same numbers. Deliberately not changed here, to keep this reviewable as a pure extraction: - No new label validation. An earlier draft rejected labels via is_valid_username before the preorder was paid for, but that helper is stricter than the DPNS contract (it also refuses consecutive hyphens, which the contract's pattern admits), so it would have refused names Platform accepts. Failing early on a bad label is worth doing on its own, against the contract's actual pattern; it is not this PR. - No entropy/document-id consistency check in dpp. dash-sdk keeps its existing private ensure_entropy_matches_document_id. Hoisting that into DocumentCreateTransitionV0::from_document so every caller inherits it is a good change, but it adds an error path to a shared crate and is separable. The autoAcceptProof bound is still checked in create_contact_request before the recipient lookup: the shared builder re-checks it against the schema, but that field is raw caller input and the lookup is a network round trip. The two checks on the SDK's own encryption output are dropped as dead code — the pre-existing COMPACT_XPUB_LEN guard forces the encrypted xpub to 96 bytes and fit_account_label bounds the encrypted label to 48-80 — and the builder covers both for embedders that do their own encryption. 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 | 254 ++++++++++++++++++ .../src/dpns_usernames.rs | 205 +++++++++++++- packages/dash-platform-queries/src/error.rs | 7 + packages/dash-platform-queries/src/lib.rs | 1 + .../src/platform/dashpay/contact_request.rs | 201 +++++--------- packages/rs-sdk/src/platform/dashpay/mod.rs | 3 + .../rs-sdk/src/platform/dpns_usernames/mod.rs | 113 +------- 8 files changed, 534 insertions(+), 252 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..5662a15af51 --- /dev/null +++ b/packages/dash-platform-queries/src/dashpay.rs @@ -0,0 +1,254 @@ +//! 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() + )) + })?; + // Only `Array`/`VariableTypeArray` report no size, and dpp refuses those + // at contract creation ("only byte arrays are supported now"), so every + // property reachable here is sized and the defaults never apply. + 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"); + } + + /// The optional fields are the ones a wire-format slip would silently + /// drop, so pin every property the document carries when both are set. + #[test] + fn should_carry_every_property_when_the_optional_fields_are_present() { + let contract = contract(); + let mut with_optionals = params(vec![1u8; 96]); + with_optionals.encrypted_account_label = Some(vec![2u8; 64]); + with_optionals.auto_accept_proof = Some(vec![3u8; 40]); + + let document = build_contact_request_document(&contract, with_optionals) + .expect("valid contact request"); + + assert_eq!( + document.get("toUserId"), + Some(&Value::Identifier([2u8; 32])) + ); + assert_eq!( + document.get("encryptedPublicKey"), + Some(&Value::Bytes(vec![1u8; 96])) + ); + assert_eq!(document.get("senderKeyIndex"), Some(&Value::U32(1))); + assert_eq!(document.get("recipientKeyIndex"), Some(&Value::U32(2))); + assert_eq!(document.get("accountReference"), Some(&Value::U32(3))); + assert_eq!( + document.get("encryptedAccountLabel"), + Some(&Value::Bytes(vec![2u8; 64])) + ); + assert_eq!( + document.get("autoAcceptProof"), + Some(&Value::Bytes(vec![3u8; 40])) + ); + } + + /// A contract that declares no `contactRequest` surfaces dpp's + /// `DataContractError` through our `Error`, rather than panicking. + #[test] + fn should_refuse_a_contract_without_a_contact_request_type() { + let dpns = load_system_data_contract(SystemDataContract::DPNS, PlatformVersion::latest()) + .expect("dpns contract"); + let error = build_contact_request_document(&dpns, params(vec![0u8; 96])) + .expect_err("dpns declares no contactRequest document type"); + assert!( + matches!(error, Error::Protocol(_)), + "unexpected error: {error:?}" + ); + } + + #[test] + fn should_refuse_a_field_the_document_type_does_not_declare() { + let contract = contract(); + let document_type = contract + .document_type_for_name(contact_request::NAME) + .expect("contactRequest document type"); + let error = check_byte_field(document_type, "notAProperty", &[]) + .expect_err("contactRequest declares no such property"); + assert!( + matches!(error, Error::Protocol(_)), + "unexpected error: {error:?}" + ); + } +} diff --git a/packages/dash-platform-queries/src/dpns_usernames.rs b/packages/dash-platform-queries/src/dpns_usernames.rs index 3f452519b29..7e2f8a9f6f8 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,181 @@ 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. +/// +/// `normalizedLabel` is the consensus normalization of `label`. The label is +/// not validated here; the contract's label pattern is enforced by consensus +/// when the domain document is created (see [`is_valid_username`] for a +/// client-side pre-check). +pub fn build_dpns_domain_document( + contract: &DataContract, + owner_id: Identifier, + label: &str, + salt: [u8; 32], + entropy: [u8; 32], +) -> Result { + 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 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-sdk/src/platform/dashpay/contact_request.rs b/packages/rs-sdk/src/platform/dashpay/contact_request.rs index adf48b41150..791c4e7c256 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,7 +255,10 @@ impl Sdk { H: FnOnce(u32) -> Hut, Hut: std::future::Future, Error>>, { - // Validate auto accept proof size if provided + // Validate auto accept proof size if provided. The shared builder + // re-checks this against the contract schema, but `autoAcceptProof` is + // raw caller input and the fetch below is a network round trip, so keep + // rejecting it up front rather than after paying for the lookup. if let Some(ref proof) = input.auto_accept_proof { if proof.len() < 38 || proof.len() > 102 { return Err(Error::Generic(format!( @@ -366,27 +365,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 +379,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 +462,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 +560,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 From 796a86b80a6292d1e9e4fd1858a9e9be33cb46c6 Mon Sep 17 00:00:00 2001 From: pasta Date: Thu, 10 Sep 2026 15:40:03 -0500 Subject: [PATCH 2/2] ci: run dash-platform-queries tests in the workspace coverage job MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The coverage phase of tests-rs-workspace.yml drives nextest from an explicit package allowlist, and dash-platform-queries was never added to it when the crate was split out of dash-sdk. The crate still reaches the report as a dependency of dash-sdk, so llvm-cov instruments its lines — but its own test binaries are never run, and every line it owns is recorded as a miss. Two consequences: the crate's unit tests have not executed in CI since the split, and any PR touching it is charged for uncovered lines that its tests do in fact cover, which no amount of added testing can fix from the PR side. Adding the package runs those tests and makes the reported coverage reflect them. It only adds hits, since the lines were already in the denominator. --- .github/workflows/tests-rs-workspace.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/tests-rs-workspace.yml b/.github/workflows/tests-rs-workspace.yml index 622cd5ab76d..0ec008a617c 100644 --- a/.github/workflows/tests-rs-workspace.yml +++ b/.github/workflows/tests-rs-workspace.yml @@ -347,6 +347,7 @@ jobs: --package dpp \ --package drive-abci \ --package dash-sdk \ + --package dash-platform-queries \ --package dash-async \ --package platform-value \ --package rs-dapi \