Skip to content
Merged
Show file tree
Hide file tree
Changes from 13 commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
c77594f
fix(dashpay): use DPNS homograph-safe normalization for profile search
lklimek Apr 1, 2026
134c534
Merge remote-tracking branch 'origin/v1.0-dev' into fix/dpns-search-n…
lklimek Apr 1, 2026
09cfd40
fix(dashpay): use homograph-safe normalization in contact username re…
lklimek Apr 1, 2026
0ea3a64
fix(dashpay): trim whitespace and strip .dash suffix in DPNS lookups
lklimek Apr 1, 2026
3ec57cd
fix(dashpay): add missing normalizedParentDomainName filter in userna…
lklimek Apr 1, 2026
51efd07
fix(dashpay): accept all key types when signing contact info documents
lklimek Apr 1, 2026
c5596b6
fix(dashpay): case-insensitive .dash suffix stripping in DPNS lookups
lklimek Apr 1, 2026
22939f3
fix(dashpay): case-insensitive .dash suffix detection in contact request
lklimek Apr 1, 2026
adaa917
refactor(model): extract DPNS normalization into model::dpns helper
lklimek Apr 1, 2026
64d7af6
fix(dashpay): pad contactInfo privateData to meet contract minimum
lklimek Apr 1, 2026
4ba8209
fix(dashpay): use random padding for contactInfo privateData minimum …
lklimek Apr 1, 2026
a9c0762
fix(dashpay): add 0x00 sentinel before random padding in privateData
lklimek Apr 1, 2026
cb4652e
fix: address PR #810 review comments
lklimek Apr 1, 2026
1e6b018
fix(model): use safe UTF-8 slicing in DPNS helpers, fix API asymmetry
lklimek Apr 1, 2026
d7fea39
Merge branch 'v1.0-dev' into fix/dpns-search-normalization
lklimek Apr 1, 2026
2a01faa
fix(dashpay): validate username format and contact info size before n…
lklimek Apr 8, 2026
142a042
fix(dashpay): use cached DPNS contract and records.identity in userna…
lklimek Apr 8, 2026
dfa1506
fix(dpns): centralize username input validation, fix case-sensitive .…
lklimek Apr 8, 2026
5cc5046
docs: add validation placement rule to CLAUDE.md
lklimek Apr 8, 2026
0e98779
fix(dashpay): reject self-contact request before broadcasting
lklimek Apr 8, 2026
cd90c21
fix(dashpay): guard u8 overflow in serialize and wire error variants
lklimek Apr 9, 2026
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
26 changes: 24 additions & 2 deletions src/backend_task/dashpay/contact_info.rs
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,12 @@ impl ContactInfoPrivateData {
Self::default()
}

/// Minimum plaintext size so that IV (16) + AES-CBC ciphertext ≥ 48 bytes
/// (the `privateData` field's `minItems` in the DashPay contract).
/// PKCS7 pads 16 bytes to 32 (adds a full padding block when input is
/// block-aligned), so 16 plaintext → 32 ciphertext → 48 with IV.
const MIN_PLAINTEXT_SIZE: usize = 16;

// Serialize to bytes for encryption
pub fn serialize(&self) -> Vec<u8> {
let mut bytes = Vec::new();
Expand Down Expand Up @@ -73,6 +79,21 @@ impl ContactInfoPrivateData {
bytes.extend_from_slice(&account.to_le_bytes());
}

// Pad to minimum plaintext size so the encrypted output (IV + ciphertext)
// meets the DashPay contract's privateData minItems (48 bytes).
// First padding byte is 0x00 as a sentinel so deserializers can
// distinguish real data from padding. Remaining bytes are random.
if bytes.len() < Self::MIN_PLAINTEXT_SIZE {
use bip39::rand::RngCore;
bytes.push(0x00); // sentinel: marks start of padding
let remaining = Self::MIN_PLAINTEXT_SIZE - bytes.len();
if remaining > 0 {
let mut pad = vec![0u8; remaining];
StdRng::from_entropy().fill_bytes(&mut pad);
bytes.extend_from_slice(&pad);
}
}

bytes
}
}
Expand Down Expand Up @@ -387,7 +408,8 @@ pub async fn create_or_update_contact_info(
let encrypted_private_data = encrypt_private_data(&private_data.serialize(), &private_data_key)
.map_err(|e| TaskError::EncryptionError { detail: e })?;

// Get signing key
// Get signing key — accept any key type (BLS, ECDSA, EDDSA) since
Comment thread
lklimek marked this conversation as resolved.
Resolved
// Platform accepts all for document state transitions.
let signing_key = identity
.identity
.get_first_public_key_matching(
Expand All @@ -397,7 +419,7 @@ pub async fn create_or_update_contact_info(
SecurityLevel::HIGH,
SecurityLevel::MEDIUM,
]),
HashSet::from([KeyType::ECDSA_SECP256K1]),
KeyType::all_key_types().into(),
false,
)
.ok_or_else(|| TaskError::DashPay(DashPayError::MissingAuthenticationKey))?;
Expand Down
29 changes: 15 additions & 14 deletions src/backend_task/dashpay/contact_requests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -188,7 +188,8 @@ pub async fn send_contact_request_with_proof(
qr_auto_accept: Option<AutoAcceptProofData>,
) -> Result<BackendTaskSuccessResult, TaskError> {
// Step 1: Resolve the recipient identity
let to_identity = if to_username_or_id.ends_with(".dash") {
let to_username_or_id = to_username_or_id.trim().to_string();
let to_identity = if crate::model::dpns::has_dash_suffix(&to_username_or_id) {
// It's a complete username, resolve via DPNS
resolve_username_to_identity(sdk, &to_username_or_id).await?
} else {
Comment thread
lklimek marked this conversation as resolved.
Resolved
Expand All @@ -205,8 +206,7 @@ pub async fn send_contact_request_with_proof(
}
Err(_) => {
// Not a valid ID format, assume it's a username without .dash suffix
let username_with_suffix = format!("{}.dash", to_username_or_id);
resolve_username_to_identity(sdk, &username_with_suffix).await?
resolve_username_to_identity(sdk, &to_username_or_id).await?
}
}
};
Expand Down Expand Up @@ -518,12 +518,7 @@ pub async fn send_contact_request_with_proof(
}

async fn resolve_username_to_identity(sdk: &Sdk, username: &str) -> Result<Identity, TaskError> {
// Parse username (e.g., "alice.dash" -> "alice")
let name = username.split('.').next().ok_or_else(|| {
TaskError::DashPay(DashPayError::InvalidUsername {
username: username.to_string(),
})
})?;
let normalized = crate::model::dpns::normalize_dpns_label(username);

// Query DPNS for the username
let dpns_contract_id = Identifier::from_string(
Expand All @@ -545,11 +540,17 @@ async fn resolve_username_to_identity(sdk: &Sdk, username: &str) -> Result<Ident
}
})?;

query = query.with_where(WhereClause {
field: "normalizedLabel".to_string(),
operator: WhereOperator::Equal,
value: Value::Text(name.to_lowercase()),
});
query = query
.with_where(WhereClause {
field: "normalizedParentDomainName".to_string(),
operator: WhereOperator::Equal,
value: Value::Text("dash".to_string()),
})
.with_where(WhereClause {
field: "normalizedLabel".to_string(),
operator: WhereOperator::Equal,
value: Value::Text(normalized),
});
query.limit = 1;

let results = Document::fetch_many(sdk, query).await?;
Expand Down
3 changes: 1 addition & 2 deletions src/backend_task/dashpay/profile.rs
Original file line number Diff line number Diff line change
Expand Up @@ -451,8 +451,7 @@ pub async fn search_profiles(
));
}

// Normalize the search query (DPNS uses lowercase normalized labels)
let normalized_query = query_trimmed.to_lowercase();
let normalized_query = crate::model::dpns::normalize_dpns_label(query_trimmed);

// Search DPNS for usernames starting with the query
let mut dpns_query =
Expand Down
4 changes: 1 addition & 3 deletions src/backend_task/identity/load_identity_by_dpns_name.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ use crate::model::wallet::WalletSeedHash;
use dash_sdk::Sdk;
use dash_sdk::dpp::document::DocumentV0Getters;
use dash_sdk::dpp::platform_value::Value;
use dash_sdk::dpp::util::strings::convert_to_homograph_safe_chars;
use dash_sdk::drive::query::{WhereClause, WhereOperator};
use dash_sdk::platform::{Document, DocumentQuery, Fetch, FetchMany, Identifier, Identity};

Expand All @@ -20,8 +19,7 @@ impl AppContext {
dpns_name: String,
selected_wallet_seed_hash: Option<WalletSeedHash>,
) -> Result<BackendTaskSuccessResult, TaskError> {
// Normalize the name (convert to lowercase and handle homoglyphs)
let normalized_name = convert_to_homograph_safe_chars(&dpns_name);
let normalized_name = crate::model::dpns::normalize_dpns_label(&dpns_name);

// Query the DPNS contract for the domain document
let domain_query = DocumentQuery {
Expand Down
100 changes: 100 additions & 0 deletions src/model/dpns.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
//! DPNS name normalization helpers.
Comment thread
lklimek marked this conversation as resolved.
Resolved
Comment thread
lklimek marked this conversation as resolved.
Resolved
//!
//! Centralizes the "trim → strip `.dash` suffix → homograph-safe normalize"
//! pipeline so every DPNS lookup uses the same logic.

use dash_sdk::dpp::util::strings::convert_to_homograph_safe_chars;

/// The `.dash` parent domain suffix (case-insensitive match target).
const DASH_SUFFIX: &str = ".dash";

/// Extract the bare label from a DPNS input and apply homograph-safe normalization.
///
/// Handles all common user inputs:
/// - `"alice"` → `"a11ce"`
/// - `"alice.dash"` → `"a11ce"`
/// - `" Alice.DASH "` → `"a11ce"`
/// - `"Alice"` → `"a11ce"`
///
/// The returned string is ready for use as a `normalizedLabel` query value.
pub fn normalize_dpns_label(input: &str) -> String {
let trimmed = input.trim();
let label = strip_dash_suffix(trimmed);
convert_to_homograph_safe_chars(label)
}

/// Strip the `.dash` parent domain suffix (case-insensitive).
///
/// Returns the bare label portion, or the full input if no suffix is present.
pub fn strip_dash_suffix(input: &str) -> &str {
if input.len() > DASH_SUFFIX.len()
&& input[input.len() - DASH_SUFFIX.len()..].eq_ignore_ascii_case(DASH_SUFFIX)
{
&input[..input.len() - DASH_SUFFIX.len()]
} else {
input
}
Comment thread
lklimek marked this conversation as resolved.
Resolved
}

/// Check whether the input looks like a full DPNS name (ends with `.dash`,
/// case-insensitive) rather than a bare label or identity ID.
pub fn has_dash_suffix(input: &str) -> bool {
let trimmed = input.trim();
trimmed.len() > DASH_SUFFIX.len()
&& trimmed[trimmed.len() - DASH_SUFFIX.len()..].eq_ignore_ascii_case(DASH_SUFFIX)
Comment thread
lklimek marked this conversation as resolved.
Resolved
}
Comment thread
lklimek marked this conversation as resolved.
Resolved
Comment thread
lklimek marked this conversation as resolved.
Resolved

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn normalize_bare_label() {
assert_eq!(normalize_dpns_label("alice"), "a11ce");
}

#[test]
fn normalize_with_dash_suffix() {
assert_eq!(normalize_dpns_label("alice.dash"), "a11ce");
}

#[test]
fn normalize_case_insensitive_suffix() {
assert_eq!(normalize_dpns_label("Alice.DASH"), "a11ce");
assert_eq!(normalize_dpns_label("alice.Dash"), "a11ce");
}

#[test]
fn normalize_trims_whitespace() {
assert_eq!(normalize_dpns_label(" alice.dash "), "a11ce");
}

#[test]
fn normalize_homograph_chars() {
// o→0, i→1, l→1
assert_eq!(normalize_dpns_label("olivia"), "011v1a");
assert_eq!(
normalize_dpns_label("supertestingnameabc123"),
"supertest1ngnameabc123"
);
}

#[test]
fn has_suffix_detection() {
assert!(has_dash_suffix("alice.dash"));
assert!(has_dash_suffix("Alice.DASH"));
assert!(has_dash_suffix("alice.Dash"));
assert!(!has_dash_suffix("alice"));
assert!(!has_dash_suffix("dash")); // too short
assert!(!has_dash_suffix(".dash")); // just the suffix, no label
}

#[test]
fn strip_suffix_cases() {
assert_eq!(strip_dash_suffix("alice.dash"), "alice");
assert_eq!(strip_dash_suffix("alice.DASH"), "alice");
assert_eq!(strip_dash_suffix("alice"), "alice");
assert_eq!(strip_dash_suffix("a.dash"), "a"); // valid: label "a"
assert_eq!(strip_dash_suffix(".dash"), ".dash"); // no label, len == 5
}
}
1 change: 1 addition & 0 deletions src/model/mod.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
pub mod address;
pub mod amount;
pub mod contested_name;
pub mod dpns;
pub mod feature_gate;
pub mod fee_estimation;
pub mod grovestark_prover;
Expand Down
Loading