From 1898d5f0e97b7f074da07ab8744ae2132ee959e3 Mon Sep 17 00:00:00 2001 From: pasta Date: Sat, 22 Aug 2026 12:25:14 -0500 Subject: [PATCH 1/3] refactor(sdk): separate DPNS and DashPay document assembly from networked flows register_dpns_name and create_contact_request were interleaving document assembly (id derivation, salted-domain-hash commitment, property maps, size validation) with fetching, ECDH, and broadcasting. The assembly halves become pure functions - build_dpns_preorder_and_domain_documents and build_contact_request_document - that take caller-supplied entropy/salt/ciphertexts and touch no network or randomness. The networked flows now call them; ids, properties, size-validation bounds, and error messages are unchanged. --- .../src/platform/dashpay/contact_request.rs | 234 +++++++++++------ packages/rs-sdk/src/platform/dashpay/mod.rs | 8 +- .../rs-sdk/src/platform/dpns_usernames/mod.rs | 247 +++++++++++------- 3 files changed, 319 insertions(+), 170 deletions(-) diff --git a/packages/rs-sdk/src/platform/dashpay/contact_request.rs b/packages/rs-sdk/src/platform/dashpay/contact_request.rs index adf48b41150..5a67af99c4a 100644 --- a/packages/rs-sdk/src/platform/dashpay/contact_request.rs +++ b/packages/rs-sdk/src/platform/dashpay/contact_request.rs @@ -23,6 +23,134 @@ use platform_encryption::{ }; use std::collections::BTreeMap; +use dpp::data_contract::DataContract; + +/// Already-derived crypto material and metadata for a DIP-15 +/// `contactRequest` document. +/// +/// Everything here is plain data: the ECDH/encryption that produced +/// `encrypted_public_key` and `encrypted_account_label`, and the randomness +/// that produced `entropy`, happen in the caller. +#[derive(Debug, Clone)] +pub struct ContactRequestDocumentParams { + /// The sender's identity id (the document owner) + pub sender_id: Identifier, + /// The recipient's identity id (`toUserId`) + pub recipient_id: Identifier, + /// The sender's encryption key index used for ECDH + pub sender_key_index: u32, + /// The recipient's key index used for ECDH + pub recipient_key_index: u32, + /// Reference to the DashPay receiving account + pub account_reference: u32, + /// ECDH-encrypted extended public key: exactly 96 bytes + /// (16-byte IV + 80 bytes of encrypted DIP-15 compact xpub) + pub encrypted_public_key: Vec, + /// Optional encrypted account label: 48-80 bytes + /// (16-byte IV + 32-64 bytes of encrypted data) + pub encrypted_account_label: Option>, + /// Optional auto-accept proof (38-102 bytes) - not encrypted + pub auto_accept_proof: Option>, + /// The entropy that derives the document id; the same entropy must be + /// attached to the create transition, or platform consensus rejects it + /// with `InvalidDocumentTransitionIdError`. + pub entropy: [u8; 32], +} + +/// Validate the size of a DIP-15 `autoAcceptProof` (38-102 bytes). +pub fn validate_auto_accept_proof(proof: &[u8]) -> Result<(), Error> { + if proof.len() < 38 || proof.len() > 102 { + return Err(Error::Generic(format!( + "autoAcceptProof must be 38-102 bytes, got {}", + proof.len() + ))); + } + Ok(()) +} + +/// Build the id and property map of a DIP-15 `contactRequest` document from +/// already-derived crypto material. +/// +/// This is the pure document-assembly half of [`Sdk::create_contact_request`]: +/// the document id derives from `params.entropy`, and the property map +/// carries exactly the fields the DashPay contract defines (`toUserId`, +/// `encryptedPublicKey`, `senderKeyIndex`, `recipientKeyIndex`, +/// `accountReference`, plus the optional `encryptedAccountLabel` and +/// `autoAcceptProof`). +/// +/// Returns `(document_id, properties)`. +pub fn build_contact_request_document( + contract: &DataContract, + params: ContactRequestDocumentParams, +) -> Result<(Identifier, BTreeMap), Error> { + if let Some(ref proof) = params.auto_accept_proof { + validate_auto_accept_proof(proof)?; + } + + // Validate encrypted public key size (must be exactly 96 bytes: 16-byte IV + 80-byte encrypted data) + if params.encrypted_public_key.len() != 96 { + return Err(Error::Generic(format!( + "Encrypted public key size mismatch: expected 96 bytes, got {}", + params.encrypted_public_key.len() + ))); + } + + // Validate encrypted label size (48-80 bytes: 16-byte IV + 32-64 byte encrypted data) + if let Some(ref label) = params.encrypted_account_label { + if label.len() < 48 || label.len() > 80 { + return Err(Error::Generic(format!( + "Encrypted account label size out of range: expected 48-80 bytes, got {}", + label.len() + ))); + } + } + + let contact_request_document_type = + contract + .document_type_for_name("contactRequest") + .map_err(|_| { + Error::Generic("DashPay contactRequest document type not found".to_string()) + })?; + + let document_id = Document::generate_document_id_v0( + &contract.id(), + ¶ms.sender_id, + contact_request_document_type.name(), + params.entropy.as_slice(), + ); + + let mut properties = BTreeMap::new(); + properties.insert( + "toUserId".to_string(), + Value::Identifier(params.recipient_id.to_buffer()), + ); + properties.insert( + "encryptedPublicKey".to_string(), + Value::Bytes(params.encrypted_public_key), + ); + properties.insert( + "senderKeyIndex".to_string(), + Value::U32(params.sender_key_index), + ); + properties.insert( + "recipientKeyIndex".to_string(), + Value::U32(params.recipient_key_index), + ); + properties.insert( + "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((document_id, properties)) +} + /// ECDH provider for contact request encryption /// /// Supports two modes: @@ -259,14 +387,11 @@ 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 builder + // validates again, but checking here first keeps the failure local — + // before the recipient fetch and ECDH work below. 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() - ))); - } + validate_auto_accept_proof(proof)?; } // Fetch recipient identity if only ID was provided @@ -362,90 +487,45 @@ impl Sdk { let mut xpub_iv = [0u8; 16]; rng.fill_bytes(&mut xpub_iv); - // Encrypt the extended public key (includes IV prepended) + // Encrypt the extended public key (includes IV prepended). The + // builder rejects any ciphertext that isn't exactly 96 bytes + // (16-byte IV + 80-byte encrypted data). 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 { + // Encrypt the account label if provided (includes IV prepended). The + // builder rejects any ciphertext outside 48-80 bytes + // (16-byte IV + 32-64 byte encrypted data). + let encrypted_account_label = input.account_label.as_ref().map(|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 - }; + encrypt_account_label(&shared_key, &label_iv, label) + }); // 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 + // Assemble the document in the pure builder above, keeping document + // assembly separate from this networked flow. 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)); - } + let (document_id, properties) = build_contact_request_document( + &dashpay_contract, + ContactRequestDocumentParams { + sender_id, + 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 essential fields for the contact request, including the // entropy that derived `document_id` so the broadcast path can reuse it. diff --git a/packages/rs-sdk/src/platform/dashpay/mod.rs b/packages/rs-sdk/src/platform/dashpay/mod.rs index 182edd8854b..1991150d643 100644 --- a/packages/rs-sdk/src/platform/dashpay/mod.rs +++ b/packages/rs-sdk/src/platform/dashpay/mod.rs @@ -7,9 +7,11 @@ mod contact_request; mod contact_request_queries; pub use contact_request::{ - recipient_key_purpose_is_acceptable_on_receive, recipient_key_purpose_is_valid, - sender_key_purpose_is_acceptable_on_receive, ContactRequestInput, ContactRequestResult, - EcdhProvider, RecipientIdentity, SendContactRequestInput, SendContactRequestResult, + build_contact_request_document, recipient_key_purpose_is_acceptable_on_receive, + recipient_key_purpose_is_valid, sender_key_purpose_is_acceptable_on_receive, + validate_auto_accept_proof, ContactRequestDocumentParams, ContactRequestInput, + ContactRequestResult, EcdhProvider, RecipientIdentity, SendContactRequestInput, + SendContactRequestResult, }; pub use contact_request_queries::ContactRequestDocuments; diff --git a/packages/rs-sdk/src/platform/dpns_usernames/mod.rs b/packages/rs-sdk/src/platform/dpns_usernames/mod.rs index 2df034fad35..6a618e43825 100644 --- a/packages/rs-sdk/src/platform/dpns_usernames/mod.rs +++ b/packages/rs-sdk/src/platform/dpns_usernames/mod.rs @@ -15,6 +15,7 @@ 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::data_contract::DataContract; use dpp::document::{DocumentV0, DocumentV0Getters}; use dpp::identity::accessors::IdentityGettersV0; use dpp::identity::signer::Signer; @@ -53,6 +54,152 @@ fn hash_double(data: Vec) -> [u8; 32] { hash.to_byte_array() } +/// Build the DPNS `preorder` and `domain` documents that register +/// `label`.dash for `identity_id`, exactly as platform consensus expects +/// them. +/// +/// This is the pure document-assembly half of [`Sdk::register_dpns_name`]: +/// no networking, and no randomness — the caller supplies the `entropy` +/// that derives both document ids (the same entropy must later be attached +/// to both create transitions) and the preorder `salt`, whose double-SHA256 +/// over `salt ‖ ".dash"` becomes the preorder's +/// `saltedDomainHash`. +/// +/// The raw label is stored +/// in the domain document's `label` property while its +/// [homograph-safe](convert_to_homograph_safe_chars) form is stored in +/// `normalizedLabel`. +/// +/// # Salt secrecy and reveal order +/// +/// The preorder/domain split is DPNS's front-running protection: the +/// preorder commits to `saltedDomainHash` without revealing which name +/// is being registered, and only the later domain document discloses +/// the `label` and the `preorderSalt` that tie it to the commitment. +/// That protection holds only if the caller upholds what +/// [`Sdk::register_dpns_name`] does automatically: +/// +/// - generate a **fresh 32-byte salt from a CSPRNG** for every +/// registration attempt (the SDK draws it from +/// `StdRng::from_entropy()`). A reused or predictable salt lets an +/// observer precompute `sha256d(salt ‖ ".dash")` for +/// candidate labels and identify — then front-run — the name from +/// the preorder alone; +/// - keep the salt, the label, and the assembled domain document +/// **private until the preorder create transition is confirmed** +/// (the SDK submits the preorder and waits for its response before +/// broadcasting the domain document). Revealing them earlier +/// discloses the name while it is still unclaimed, defeating the +/// commitment. +/// +/// Callers driving their own flow inherit both obligations — this +/// builder takes `salt` as an argument precisely because it has no +/// randomness of its own and cannot enforce either one. +/// +/// Returns `(preorder_document, domain_document)`. +pub fn build_dpns_preorder_and_domain_documents( + contract: &DataContract, + identity_id: Identifier, + label: &str, + entropy: [u8; 32], + salt: [u8; 32], +) -> Result<(Document, Document), Error> { + let preorder_document_type = contract + .document_type_for_name("preorder") + .map_err(|_| Error::Generic("DPNS preorder document type not found".to_string()))?; + + let domain_document_type = contract + .document_type_for_name("domain") + .map_err(|_| Error::Generic("DPNS domain document type not found".to_string()))?; + + let preorder_id = Document::generate_document_id_v0( + &contract.id(), + &identity_id, + preorder_document_type.name(), + entropy.as_slice(), + ); + let domain_id = Document::generate_document_id_v0( + &contract.id(), + &identity_id, + domain_document_type.name(), + entropy.as_slice(), + ); + + // Create salted domain hash for preorder + let normalized_label = convert_to_homograph_safe_chars(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); + + 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, + }); + + 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(label.to_string())), + ("normalizedLabel".to_string(), Value::Text(normalized_label)), + ("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, + }); + + Ok((preorder_document, domain_document)) +} + /// Callback type for preorder document pub type PreorderCallback = Box; @@ -164,97 +311,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 + // Assemble both documents in the pure builder above, keeping + // document assembly separate from this networked flow. + let (preorder_document, domain_document) = build_dpns_preorder_and_domain_documents( + &dpns_contract, + input.identity.id().to_owned(), + &input.label, + entropy.0, + salt, + )?; + 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 1a57beec269ddb69d4af58ffe2835e9043742416 Mon Sep 17 00:00:00 2001 From: pasta Date: Tue, 25 Aug 2026 17:14:37 +0200 Subject: [PATCH 2/3] refactor(sdk): return the assembled document from build_contact_request_document Return a finished Document from the builder instead of (Identifier, BTreeMap), matching build_dpns_preorder_and_domain_documents. This removes the hand-rolled DocumentV0 literal in send_contact_request and makes it impossible for callers of the public builder to pair the derived id with a mismatched owner_id or revision, which platform would reject with InvalidDocumentTransitionIdError after fees. ContactRequestResult now carries the document plus the entropy that derived its id. --- .../src/platform/dashpay/contact_request.rs | 110 +++++++++--------- 1 file changed, 55 insertions(+), 55 deletions(-) diff --git a/packages/rs-sdk/src/platform/dashpay/contact_request.rs b/packages/rs-sdk/src/platform/dashpay/contact_request.rs index 5a67af99c4a..218874d28c3 100644 --- a/packages/rs-sdk/src/platform/dashpay/contact_request.rs +++ b/packages/rs-sdk/src/platform/dashpay/contact_request.rs @@ -68,21 +68,28 @@ pub fn validate_auto_accept_proof(proof: &[u8]) -> Result<(), Error> { Ok(()) } -/// Build the id and property map of a DIP-15 `contactRequest` document from -/// already-derived crypto material. +/// Build a DIP-15 `contactRequest` document from already-derived crypto +/// material, exactly as platform consensus expects it. /// /// This is the pure document-assembly half of [`Sdk::create_contact_request`]: -/// the document id derives from `params.entropy`, and the property map -/// carries exactly the fields the DashPay contract defines (`toUserId`, -/// `encryptedPublicKey`, `senderKeyIndex`, `recipientKeyIndex`, -/// `accountReference`, plus the optional `encryptedAccountLabel` and -/// `autoAcceptProof`). +/// the document id derives from `params.entropy`, the owner is +/// `params.sender_id`, and the property map carries exactly the fields the +/// DashPay contract defines (`toUserId`, `encryptedPublicKey`, +/// `senderKeyIndex`, `recipientKeyIndex`, `accountReference`, plus the +/// optional `encryptedAccountLabel` and `autoAcceptProof`). /// -/// Returns `(document_id, properties)`. +/// Consensus recomputes the document id from +/// `(contract, owner_id, "contactRequest", entropy)` and rejects the create +/// transition on any mismatch, so the id/owner/entropy relation is fixed +/// here rather than left to callers to assemble consistently. Broadcast the +/// returned document with the same `params.entropy` attached to the create +/// transition. +/// +/// Returns the assembled `contactRequest` [`Document`]. pub fn build_contact_request_document( contract: &DataContract, params: ContactRequestDocumentParams, -) -> Result<(Identifier, BTreeMap), Error> { +) -> Result { if let Some(ref proof) = params.auto_accept_proof { validate_auto_accept_proof(proof)?; } @@ -148,7 +155,23 @@ pub fn build_contact_request_document( properties.insert("autoAcceptProof".to_string(), Value::Bytes(proof)); } - Ok((document_id, properties)) + Ok(Document::V0(DocumentV0 { + contract_version: None, + id: document_id, + owner_id: params.sender_id, + 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, + })) } /// ECDH provider for contact request encryption @@ -233,13 +256,10 @@ 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 submitted to the + /// platform. Its id derives from `entropy` and its owner is the sender. + 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 @@ -511,11 +531,10 @@ impl Sdk { // Assemble the document in the pure builder above, keeping document // assembly separate from this networked flow. - let sender_id = input.sender_identity.id().to_owned(); - let (document_id, properties) = build_contact_request_document( + let document = build_contact_request_document( &dashpay_contract, ContactRequestDocumentParams { - sender_id, + 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, @@ -527,14 +546,9 @@ impl Sdk { }, )?; - // 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, - }) + // 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 @@ -596,30 +610,12 @@ 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 + // Reuse the entropy that derived the document id during creation. + // Platform consensus recomputes the id from this entropy and rejects + // the create transition unless it matches, so a freshly generated // entropy here would always be rejected (InvalidDocumentTransitionIdError). 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 @@ -648,6 +644,7 @@ mod tests { use super::*; use dpp::dashcore::secp256k1::rand::{self, RngCore}; use dpp::dashcore::secp256k1::Secp256k1; + use dpp::document::DocumentV0Getters; #[test] fn test_ecdh_encryption_produces_correct_size() { @@ -738,9 +735,11 @@ mod tests { ); let result = ContactRequestResult { - id, - owner_id, - properties: BTreeMap::new(), + document: Document::V0(DocumentV0 { + id, + owner_id, + ..Default::default() + }), entropy, }; @@ -748,12 +747,13 @@ mod tests { // exact id that was returned at creation time. let regenerated = Document::generate_document_id_v0( &contract_id, - &result.owner_id, + &result.document.owner_id(), "contactRequest", result.entropy.as_slice(), ); assert_eq!( - regenerated, result.id, + regenerated, + result.document.id(), "entropy carried in ContactRequestResult must derive the returned document id" ); } From 5302e3f6279973d9f618aec5d942b782e2643e4a Mon Sep 17 00:00:00 2001 From: pasta Date: Tue, 25 Aug 2026 17:59:08 +0200 Subject: [PATCH 3/3] docs(sdk): condense builder doc comments to match module style Trim the 43-line salt-secrecy essay on build_dpns_preorder_and_domain_documents and the expanded build_contact_request_document docs down to the module's usual concise rustdoc, keeping the salt/entropy obligations callers actually need. --- .../src/platform/dashpay/contact_request.rs | 17 ++----- .../rs-sdk/src/platform/dpns_usernames/mod.rs | 47 +++++-------------- 2 files changed, 16 insertions(+), 48 deletions(-) diff --git a/packages/rs-sdk/src/platform/dashpay/contact_request.rs b/packages/rs-sdk/src/platform/dashpay/contact_request.rs index 218874d28c3..d2dffc52e5a 100644 --- a/packages/rs-sdk/src/platform/dashpay/contact_request.rs +++ b/packages/rs-sdk/src/platform/dashpay/contact_request.rs @@ -69,21 +69,14 @@ pub fn validate_auto_accept_proof(proof: &[u8]) -> Result<(), Error> { } /// Build a DIP-15 `contactRequest` document from already-derived crypto -/// material, exactly as platform consensus expects it. +/// material. /// /// This is the pure document-assembly half of [`Sdk::create_contact_request`]: /// the document id derives from `params.entropy`, the owner is -/// `params.sender_id`, and the property map carries exactly the fields the -/// DashPay contract defines (`toUserId`, `encryptedPublicKey`, -/// `senderKeyIndex`, `recipientKeyIndex`, `accountReference`, plus the -/// optional `encryptedAccountLabel` and `autoAcceptProof`). -/// -/// Consensus recomputes the document id from -/// `(contract, owner_id, "contactRequest", entropy)` and rejects the create -/// transition on any mismatch, so the id/owner/entropy relation is fixed -/// here rather than left to callers to assemble consistently. Broadcast the -/// returned document with the same `params.entropy` attached to the create -/// transition. +/// `params.sender_id`, and the properties are exactly the fields the DashPay +/// contract defines. Broadcast the returned document with the same +/// `params.entropy` attached to the create transition, or platform consensus +/// rejects it with `InvalidDocumentTransitionIdError`. /// /// Returns the assembled `contactRequest` [`Document`]. pub fn build_contact_request_document( diff --git a/packages/rs-sdk/src/platform/dpns_usernames/mod.rs b/packages/rs-sdk/src/platform/dpns_usernames/mod.rs index 6a618e43825..9755f739e59 100644 --- a/packages/rs-sdk/src/platform/dpns_usernames/mod.rs +++ b/packages/rs-sdk/src/platform/dpns_usernames/mod.rs @@ -55,46 +55,21 @@ fn hash_double(data: Vec) -> [u8; 32] { } /// Build the DPNS `preorder` and `domain` documents that register -/// `label`.dash for `identity_id`, exactly as platform consensus expects -/// them. +/// `label`.dash for `identity_id`. /// /// This is the pure document-assembly half of [`Sdk::register_dpns_name`]: -/// no networking, and no randomness — the caller supplies the `entropy` -/// that derives both document ids (the same entropy must later be attached -/// to both create transitions) and the preorder `salt`, whose double-SHA256 +/// no networking, and no randomness. The caller supplies the `entropy` that +/// derives both document ids (the same entropy must later be attached to +/// both create transitions) and the preorder `salt`, whose double-SHA256 /// over `salt ‖ ".dash"` becomes the preorder's -/// `saltedDomainHash`. +/// `saltedDomainHash`. The raw label is stored in the domain document's +/// `label` property; its [homograph-safe](convert_to_homograph_safe_chars) +/// form in `normalizedLabel`. /// -/// The raw label is stored -/// in the domain document's `label` property while its -/// [homograph-safe](convert_to_homograph_safe_chars) form is stored in -/// `normalizedLabel`. -/// -/// # Salt secrecy and reveal order -/// -/// The preorder/domain split is DPNS's front-running protection: the -/// preorder commits to `saltedDomainHash` without revealing which name -/// is being registered, and only the later domain document discloses -/// the `label` and the `preorderSalt` that tie it to the commitment. -/// That protection holds only if the caller upholds what -/// [`Sdk::register_dpns_name`] does automatically: -/// -/// - generate a **fresh 32-byte salt from a CSPRNG** for every -/// registration attempt (the SDK draws it from -/// `StdRng::from_entropy()`). A reused or predictable salt lets an -/// observer precompute `sha256d(salt ‖ ".dash")` for -/// candidate labels and identify — then front-run — the name from -/// the preorder alone; -/// - keep the salt, the label, and the assembled domain document -/// **private until the preorder create transition is confirmed** -/// (the SDK submits the preorder and waits for its response before -/// broadcasting the domain document). Revealing them earlier -/// discloses the name while it is still unclaimed, defeating the -/// commitment. -/// -/// Callers driving their own flow inherit both obligations — this -/// builder takes `salt` as an argument precisely because it has no -/// randomness of its own and cannot enforce either one. +/// Callers driving their own flow (rather than [`Sdk::register_dpns_name`]) +/// must draw `salt` fresh from a CSPRNG and keep it, the label, and the +/// domain document private until the preorder is confirmed — otherwise the +/// preorder's front-running protection is lost. /// /// Returns `(preorder_document, domain_document)`. pub fn build_dpns_preorder_and_domain_documents(