Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
315 changes: 194 additions & 121 deletions packages/rs-sdk/src/platform/dashpay/contact_request.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<u8>,
/// Optional encrypted account label: 48-80 bytes
/// (16-byte IV + 32-64 bytes of encrypted data)
pub encrypted_account_label: Option<Vec<u8>>,
/// Optional auto-accept proof (38-102 bytes) - not encrypted
pub auto_accept_proof: Option<Vec<u8>>,
/// 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<Document, 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(),
&params.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,
}))
}
Comment on lines +82 to +168

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: The extracted contact-request builder has no direct regression test

No test invokes build_contact_request_document. The entropy test manually constructs a DocumentV0, so it remains green if the builder derives the ID from the wrong input, assigns the wrong owner, changes a property name or value, or mishandles optional fields. Add deterministic tests using a fixed DashPay contract, entropy, identifiers, and ciphertexts that assert the generated ID and complete property map with and without optional fields, plus the ciphertext and proof boundary errors.

source: ['codex']


/// ECDH provider for contact request encryption
///
/// Supports two modes:
Expand Down Expand Up @@ -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<String, Value>,
/// 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
Expand Down Expand Up @@ -259,14 +400,11 @@ impl Sdk {
H: FnOnce(u32) -> Hut,
Hut: std::future::Future<Output = Result<Vec<u8>, 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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -658,22 +728,25 @@ mod tests {
);

let result = ContactRequestResult {
id,
owner_id,
properties: BTreeMap::new(),
document: Document::V0(DocumentV0 {
id,
owner_id,
..Default::default()
}),
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,
&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"
);
}
Expand Down
Loading
Loading