diff --git a/packages/rs-sdk/src/platform/dashpay/contact_request.rs b/packages/rs-sdk/src/platform/dashpay/contact_request.rs index adf48b41150..d2dffc52e5a 100644 --- a/packages/rs-sdk/src/platform/dashpay/contact_request.rs +++ b/packages/rs-sdk/src/platform/dashpay/contact_request.rs @@ -23,6 +23,150 @@ 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 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`, the owner is +/// `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( + contract: &DataContract, + params: ContactRequestDocumentParams, +) -> Result { + 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::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 /// /// Supports two modes: @@ -105,13 +249,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 @@ -259,14 +400,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,99 +500,48 @@ 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 - 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, - }) + // Assemble the document in the pure builder above, keeping document + // assembly separate from this networked flow. + 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 +603,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 @@ -568,6 +637,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() { @@ -658,9 +728,11 @@ mod tests { ); let result = ContactRequestResult { - id, - owner_id, - properties: BTreeMap::new(), + document: Document::V0(DocumentV0 { + id, + owner_id, + ..Default::default() + }), entropy, }; @@ -668,12 +740,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" ); } 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..9755f739e59 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,127 @@ 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`. +/// +/// 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; its [homograph-safe](convert_to_homograph_safe_chars) +/// form in `normalizedLabel`. +/// +/// 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( + 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 +286,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