Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ scripts/safe-cargo.sh +nightly fmt --all
* Screen constructors handle errors internally via `MessageBanner` and return `Self` with degraded state. Keep `create_screen()` clean — no error handling at callsites.
* **i18n-ready strings**: All user-facing strings (labels, messages, tooltips, errors) must be simple, complete sentences. Avoid concatenating fragments, positional assumptions, or grammar that breaks in other languages. Each string should be extractable as a single translation unit with named placeholders for dynamic values and no logic in the text itself. Current code uses standard Rust format specifiers (`{name}`, `{max}`). When i18n extraction happens later, these will become Fluent-style placeholders (`{ $name }`, `{ $max }`).
* **Never parse error strings** to extract information. Always use the typed error chain (downcast, match on variants, access structured fields). If no typed variant exists for the information you need, define a new `TaskError` variant or extend the existing error type. String parsing is fragile, breaks on message changes, and bypasses the type system.
* **Validation placement**: Pure input validation (format, length, character sets) lives in `model/` as stateless functions — single source of truth, unit-testable, no dependencies on `AppContext` or `Sdk`. Backend tasks are the authoritative enforcement layer: they call model validators for format checks AND perform stateful validation that requires network or database (existence checks, uniqueness, business rules). UI screens may call model validators for instant user feedback, but must never implement their own validation logic — always delegate to the model function.

### Error messages

Expand Down
73 changes: 64 additions & 9 deletions src/backend_task/dashpay/contact_info.rs
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,14 @@ 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> {
pub fn serialize(&self) -> Result<Vec<u8>, DashPayError> {
let mut bytes = Vec::new();

// Version (4 bytes)
Expand All @@ -49,7 +55,13 @@ impl ContactInfoPrivateData {
// Alias name (length + string)
if let Some(alias) = &self.alias_name {
let alias_bytes = alias.as_bytes();
bytes.push(alias_bytes.len() as u8);
let alias_len = alias_bytes.len();
if alias_len > u8::MAX as usize {
return Err(DashPayError::ContactInfoValidationFailed {
errors: vec![format!("Nickname too long ({alias_len} bytes, max 255)")],
});
}
bytes.push(alias_len as u8);
bytes.extend_from_slice(alias_bytes);
} else {
bytes.push(0u8);
Expand All @@ -58,7 +70,13 @@ impl ContactInfoPrivateData {
// Note (length + string)
if let Some(note) = &self.note {
let note_bytes = note.as_bytes();
bytes.push(note_bytes.len() as u8);
let note_len = note_bytes.len();
if note_len > u8::MAX as usize {
return Err(DashPayError::ContactInfoValidationFailed {
errors: vec![format!("Note too long ({note_len} bytes, max 255)")],
});
}
bytes.push(note_len as u8);
bytes.extend_from_slice(note_bytes);
} else {
bytes.push(0u8);
Expand All @@ -68,12 +86,35 @@ impl ContactInfoPrivateData {
bytes.push(if self.display_hidden { 1 } else { 0 });

// Accepted accounts (length + array)
bytes.push(self.accepted_accounts.len() as u8);
let accounts_len = self.accepted_accounts.len();
if accounts_len > u8::MAX as usize {
return Err(DashPayError::ContactInfoValidationFailed {
errors: vec![format!(
"Too many accepted accounts ({accounts_len}, max 255)"
)],
});
}
bytes.push(accounts_len as u8);
for account in &self.accepted_accounts {
bytes.extend_from_slice(&account.to_le_bytes());
}

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);
}
}

Ok(bytes)
}
}

Expand Down Expand Up @@ -384,10 +425,24 @@ pub async fn create_or_update_contact_info(
private_data.accepted_accounts = accepted_accounts;

// Encrypt private data
let encrypted_private_data = encrypt_private_data(&private_data.serialize(), &private_data_key)
.map_err(|e| TaskError::EncryptionError { detail: e })?;
let encrypted_private_data =
encrypt_private_data(&private_data.serialize()?, &private_data_key)
.map_err(|e| TaskError::EncryptionError { detail: e })?;

let validation = crate::backend_task::dashpay::validation::validate_contact_info_field_sizes(
&encrypted_user_id,
&encrypted_private_data,
);
if !validation.is_valid {
return Err(TaskError::DashPay(
DashPayError::ContactInfoValidationFailed {
errors: validation.errors,
},
));
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// Get signing key
// Get signing key — accept any key type (BLS, ECDSA, EDDSA) since
// Platform accepts all for document state transitions.
let signing_key = identity
.identity
.get_first_public_key_matching(
Expand All @@ -397,7 +452,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
131 changes: 80 additions & 51 deletions src/backend_task/dashpay/contact_requests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -188,9 +188,17 @@ 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();

if let Err(input) = crate::model::dpns::validate_dpns_input(&to_username_or_id) {
return Err(TaskError::DashPay(DashPayError::InvalidUsername {
username: input,
}));
}

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?
resolve_username_to_identity(app_context, sdk, &to_username_or_id).await?
} else {
Comment thread
lklimek marked this conversation as resolved.
// Try to parse as identity ID first
match Identifier::from_string_try_encodings(
Expand All @@ -205,15 +213,19 @@ 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(app_context, sdk, &to_username_or_id).await?
}
}
};

let to_identity_id = to_identity.id();

// Step 2: Check if a contact request already exists
// Step 2: Reject self-contact (Platform would reject with code 40500 anyway)
if to_identity_id == identity.identity.id() {
return Err(TaskError::DashPay(DashPayError::CannotContactSelf));
}

// Step 3: Check if a contact request already exists
let dashpay_contract = app_context.dashpay_contract.clone();
let mut existing_query = DocumentQuery::new(dashpay_contract.clone(), "contactRequest")
.map_err(|e| DashPayError::QueryCreation {
Expand Down Expand Up @@ -517,57 +529,74 @@ 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(),
})
})?;

// Query DPNS for the username
let dpns_contract_id = Identifier::from_string(
"GWRSAVFMjXx8HpQFaNJMqBV7MBgMK4br5UESsB4S31Ec",
Encoding::Base58,
)
.map_err(|e| TaskError::IdentifierParsingError {
input: format!("DPNS contract ID: {}", e),
})?;

let dpns_contract = dash_sdk::platform::DataContract::fetch(sdk, dpns_contract_id)
.await?
.ok_or(TaskError::DataContractNotFound)?;

let mut query = DocumentQuery::new(Arc::new(dpns_contract), "domain").map_err(|e| {
DashPayError::QueryCreation {
query_target: "DPNS domain",
source: Box::new(e),
}
})?;
async fn resolve_username_to_identity(
app_context: &Arc<AppContext>,
sdk: &Sdk,
username: &str,
) -> Result<Identity, TaskError> {
let normalized = crate::model::dpns::normalize_dpns_label(username);

// Use the cached DPNS contract from AppContext instead of fetching from network
let domain_query = DocumentQuery {
data_contract: app_context.dpns_contract.clone(),
document_type_name: "domain".to_string(),
where_clauses: vec![
WhereClause {
field: "normalizedParentDomainName".to_string(),
operator: WhereOperator::Equal,
value: Value::Text("dash".to_string()),
},
WhereClause {
field: "normalizedLabel".to_string(),
operator: WhereOperator::Equal,
value: Value::Text(normalized),
},
],
order_by_clauses: vec![],
limit: 1,
start: None,
};

query = query.with_where(WhereClause {
field: "normalizedLabel".to_string(),
operator: WhereOperator::Equal,
value: Value::Text(name.to_lowercase()),
});
query.limit = 1;
let results = Document::fetch_many(sdk, domain_query).await?;

let results = Document::fetch_many(sdk, query).await?;
let document = results
.values()
.filter_map(|maybe_doc| maybe_doc.as_ref())
.next()
.ok_or_else(|| {
TaskError::DashPay(DashPayError::UsernameResolutionFailed {
username: username.to_string(),
})
})?;

let (_, document) = results.into_iter().next().ok_or_else(|| {
TaskError::DashPay(DashPayError::UsernameResolutionFailed {
username: username.to_string(),
// Extract the identity ID from records.identity — this is the authoritative
// identity reference, which may differ from owner_id() after name transfers.
let identity_id = document
.get("records")
.and_then(|records| {
if let Value::Map(map) = records {
map.iter()
.find(|(k, _)| matches!(k, Value::Text(key) if key == "identity"))
.map(|(_, v)| v.clone())
} else {
None
}
})
})?;

let document = document.ok_or_else(|| {
TaskError::DashPay(DashPayError::InvalidDocument {
reason: format!("Invalid DPNS document for '{}'", username),
.and_then(|id_value| {
if let Value::Identifier(id_bytes) = id_value {
Some(Identifier::from(id_bytes))
} else {
None
}
})
})?;

// Get the identity ID from the DPNS document
let identity_id = document.owner_id();
.ok_or_else(|| {
TaskError::DashPay(DashPayError::InvalidDocument {
reason: format!(
"DPNS document for '{}' is missing records.identity field",
username
),
})
})?;
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// Fetch the identity
Identity::fetch(sdk, identity_id)
Expand Down
16 changes: 16 additions & 0 deletions src/backend_task/dashpay/errors.rs
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,9 @@ pub enum DashPayError {
},

// User Input Errors
#[error("You cannot send a contact request to yourself.")]
CannotContactSelf,

#[error("The username format is not valid. Usernames must end with '.dash'.")]
InvalidUsername { username: String },

Expand Down Expand Up @@ -170,6 +173,10 @@ pub enum DashPayError {
/// A contact request has already been sent to this recipient.
#[error("You have already sent a contact request to '{to}'. Please wait for them to respond.")]
ContactRequestAlreadySent { to: String },

/// Encrypted contact info fields exceed DashPay contract limits.
#[error("Contact info is too large to save. Try shortening your nickname or note.")]
ContactInfoValidationFailed { errors: Vec<String> },
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

impl DashPayError {
Expand Down Expand Up @@ -227,6 +234,13 @@ impl DashPayError {
DashPayError::MissingDecryptionKey => {
"Your identity is missing a decryption key required for contacts. Please add a compatible decryption key.".to_string()
}
DashPayError::ContactInfoValidationFailed { .. } => {
"Contact info is too large to save. Try shortening your nickname or note."
.to_string()
}
DashPayError::CannotContactSelf => {
"You cannot send a contact request to yourself.".to_string()
}
_ => "An error occurred. Please try again.".to_string(),
}
}
Expand Down Expand Up @@ -256,6 +270,8 @@ impl DashPayError {
| DashPayError::MissingField { .. }
| DashPayError::MissingEncryptionKey
| DashPayError::MissingDecryptionKey
| DashPayError::ContactInfoValidationFailed { .. }
| DashPayError::CannotContactSelf
)
}
}
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
Loading
Loading