Skip to content
Merged
10 changes: 10 additions & 0 deletions src/backend_task/dashpay/contact_requests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,16 @@ pub async fn send_contact_request_with_proof(
account_label: Option<String>,
qr_auto_accept: Option<AutoAcceptProofData>,
) -> Result<BackendTaskSuccessResult, TaskError> {
if let Some(label) = account_label.as_deref()
&& let Err(error) = crate::model::dashpay::validate_account_label(label)
{
return Err(DashPayError::AccountLabelTooLong {
length: error.actual,
max: error.max,
}
.into());
}

// Step 1: Resolve the recipient identity
let to_username_or_id = to_username_or_id.trim().to_string();

Expand Down
5 changes: 5 additions & 0 deletions src/backend_task/dashpay/payments.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ use crate::backend_task::error::TaskError;
use crate::context::AppContext;
use crate::model::dashpay::{
PaymentDirection as StoredPaymentDirection, PaymentStatus as StoredPaymentStatus,
validate_payment_memo,
};
use crate::model::dashpay_derivation::derive_payment_address;
use crate::model::qualified_identity::QualifiedIdentity;
Expand Down Expand Up @@ -232,6 +233,10 @@ pub async fn send_payment_to_contact(
use crate::backend_task::core::{CoreTask, PaymentRecipient, WalletPaymentRequest};
use dash_sdk::dpp::identity::accessors::IdentityGettersV0;

if let Some(memo) = memo.as_deref() {
validate_payment_memo(memo).map_err(|source| TaskError::DashPayMemoTooLong { source })?;
}

// Get a wallet from the identity's associated wallets
let wallet = from_identity
.associated_wallets
Expand Down
50 changes: 42 additions & 8 deletions src/backend_task/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1187,6 +1187,35 @@ pub enum TaskError {
source_error: Box<SdkError>,
},

/// A DPNS label failed the shared registration-format validator.
#[error(
"The DPNS name format is not valid. Use 3 to 63 letters, numbers, or hyphens, without a hyphen at either end."
)]
InvalidDpnsName {
validation: crate::model::dpns::DpnsNameValidationResult,
},

/// A DashPay memo exceeded the shared character limit.
#[error("The payment memo is too long. Use 100 characters or fewer and try again.")]
DashPayMemoTooLong {
#[source]
source: crate::model::validation::TextLengthError,
},

/// A searchable contract keyword fell outside the shared character range.
#[error("A contract keyword has an invalid length. Use 3 to 50 characters and try again.")]
InvalidContractKeywordLength {
#[source]
source: crate::model::validation::TextLengthError,
},

/// A wallet alias exceeded the shared character limit.
#[error("The wallet name is too long. Use 64 characters or fewer and try again.")]
InvalidWalletAliasLength {
#[source]
source: crate::model::validation::TextLengthError,
},

/// A document's unique values conflict with an existing entry.
#[error(
"This request conflicts with an existing entry. Please use different values and try again."
Expand Down Expand Up @@ -2614,9 +2643,8 @@ pub fn is_empty_tree_proof(error: &SdkError) -> bool {
leaf.is_some_and(|s| s.to_lowercase().contains(EMPTY_TREE_PROOF_MARKER))
}

// TODO: Replace string parsing with a pre-check on amount + fee > spendable
// before calling the SDK builder, or wait for upstream to add a typed
// ProtocolError variant (currently ProtocolError::ShieldedBuildError(String)).
// TODO: workaround — replace with a typed shielded-build error or a local
// amount-plus-fee pre-check when the SDK exposes one (see issue #714).
Comment thread
Claudius-Maginificent marked this conversation as resolved.

/// Parse the "amount + fee exceeds spendable" pattern from DPP builder errors.
///
Expand All @@ -2627,18 +2655,22 @@ pub fn is_empty_tree_proof(error: &SdkError) -> bool {
///
/// Returns `(amount, fee, spendable)` on match.
fn parse_fee_exceeds_spendable(detail: &str) -> Option<(u64, u64, u64)> {
const AMOUNT_MARKER: &str = "amount ";
const FEE_MARKER: &str = "fee ";
const SPENDABLE_MARKER: &str = "exceeds total spendable value ";

// Pattern: "{type} amount {A} + fee {F} = {sum} exceeds total spendable value {S}"
let amount_start = detail.find("amount ")? + 7;
let amount_start = detail.find(AMOUNT_MARKER)? + AMOUNT_MARKER.len();
let amount_end = detail[amount_start..].find(' ')? + amount_start;
let amount: u64 = detail[amount_start..amount_end].parse().ok()?;

let fee_marker = detail.find("fee ")?;
let fee_start = fee_marker + 4;
let fee_marker = detail.find(FEE_MARKER)?;
let fee_start = fee_marker + FEE_MARKER.len();
let fee_end = detail[fee_start..].find(' ')? + fee_start;
let fee: u64 = detail[fee_start..fee_end].parse().ok()?;

let spendable_marker = detail.find("exceeds total spendable value ")?;
let spendable_start = spendable_marker + 30;
let spendable_marker = detail.find(SPENDABLE_MARKER)?;
let spendable_start = spendable_marker + SPENDABLE_MARKER.len();
let spendable: u64 = detail[spendable_start..].trim().parse().ok()?;

Some((amount, fee, spendable))
Expand All @@ -2657,6 +2689,8 @@ pub fn shielded_build_error(detail: String) -> TaskError {
fee,
spendable,
}
// TODO: workaround — replace this upstream wording match with a typed
// shielded anchor error when the SDK exposes one (see issue #714).
} else if detail.contains("AnchorMismatch") {
TaskError::ShieldedAnchorMismatch { detail }
} else {
Expand Down
6 changes: 6 additions & 0 deletions src/backend_task/identity/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ mod refresh_identity;
mod refresh_loaded_identities_dpns_names;
mod register_dpns_name;
mod register_identity;
mod remove_identity;
mod top_up_identity;
mod transfer;
mod withdraw_from_identity;
Expand Down Expand Up @@ -508,6 +509,10 @@ pub enum IdentityTask {
key_id: Option<KeyID>,
},
RegisterDpnsName(RegisterDpnsNameInput),
/// Remove a local identity and its associated voter identity, if present.
RemoveIdentity {
identity_id: Identifier,
},
RefreshIdentity(QualifiedIdentity),
RefreshLoadedIdentitiesOwnedDPNSNames,
}
Expand Down Expand Up @@ -851,6 +856,7 @@ impl AppContext {
IdentityTask::RegisterDpnsName(input) => {
Ok(self.register_dpns_name(sdk, input).await?)
}
IdentityTask::RemoveIdentity { identity_id } => self.remove_identity(identity_id),
IdentityTask::RefreshIdentity(qualified_identity) => {
self.refresh_identity(sdk, qualified_identity, sender).await
}
Expand Down
15 changes: 13 additions & 2 deletions src/backend_task/identity/register_dpns_name.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,13 @@ use std::collections::BTreeMap;

use crate::backend_task::FeeResult;
use crate::backend_task::error::TaskError;
use crate::{context::AppContext, model::qualified_identity::DPNSNameInfo};
use crate::{
context::AppContext,
model::{
dpns::{DpnsNameValidationResult, validate_dpns_name},
qualified_identity::DPNSNameInfo,
},
};
use bip39::rand::{Rng, SeedableRng, rngs::StdRng};
use dash_sdk::{
Sdk,
Expand Down Expand Up @@ -37,6 +43,11 @@ impl AppContext {
sdk: &Sdk,
input: RegisterDpnsNameInput,
) -> Result<BackendTaskSuccessResult, TaskError> {
let validation = validate_dpns_name(&input.name_input);
if validation != DpnsNameValidationResult::Valid {
return Err(TaskError::InvalidDpnsName { validation });
}

let mut rng = StdRng::from_entropy();
let dpns_contract = self.dpns_contract.clone();

Expand Down Expand Up @@ -217,7 +228,7 @@ impl AppContext {
qualified_identity.dpns_names = owned_dpns_names;

if qualified_identity.alias.is_none() {
qualified_identity.alias = Some(format!("{}.dash", input.name_input));
qualified_identity.alias = Some(format!("{name}.dash", name = input.name_input));
}

let refreshed_identity = dash_sdk::platform::Identity::fetch_by_identifier(
Expand Down
44 changes: 44 additions & 0 deletions src/backend_task/identity/remove_identity.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
use crate::backend_task::{BackendTaskSuccessResult, TaskError};
use crate::context::AppContext;
use dash_sdk::dpp::identity::accessors::IdentityGettersV0;
use dash_sdk::platform::Identifier;

impl AppContext {
pub(super) fn remove_identity(
&self,
identity_id: Identifier,
) -> Result<BackendTaskSuccessResult, TaskError> {
let associated_voter_identity_id = self
.load_local_qualified_identities()?
.into_iter()
.find(|identity| identity.identity.id() == identity_id)
.and_then(|identity| {
identity
.associated_voter_identity
.map(|(voter_identity, _)| voter_identity.id())
});

self.delete_local_qualified_identity(&identity_id)?;

let mut removed_identity_ids = vec![identity_id];
let mut associated_cleanup_failed = false;
if let Some(voter_id) = associated_voter_identity_id.filter(|id| *id != identity_id) {
match self.delete_local_qualified_identity(&voter_id) {
Ok(()) => removed_identity_ids.push(voter_id),
Err(error) => {
associated_cleanup_failed = true;
tracing::warn!(
?error,
voter_identity_id = %voter_id,
"Associated voter identity cleanup failed"
);
}
}
}

Ok(BackendTaskSuccessResult::RemovedIdentities {
identity_ids: removed_identity_ids,
associated_cleanup_failed,
})
}
}
2 changes: 1 addition & 1 deletion src/backend_task/migration/finish_unwire.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2195,7 +2195,7 @@ fn migrate_wallet_meta_rows(app_context: &Arc<AppContext>) -> Result<(), TaskErr
let view = backend.wallet_meta();
let outcome = migrate_wallet_meta_rows_from_conn(
&conn,
|seed_hash, meta| view.set(app_context.network, &seed_hash, &meta),
|seed_hash, meta| view.set_migrated(app_context.network, &seed_hash, &meta),
app_context.network,
)?;
tracing::info!(
Expand Down
4 changes: 4 additions & 0 deletions src/backend_task/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -669,6 +669,10 @@ pub enum BackendTaskSuccessResult {
TransferredCredits(FeeResult),
WithdrewFromIdentity(FeeResult),
RegisteredDpnsName(FeeResult),
RemovedIdentities {
identity_ids: Vec<Identifier>,
associated_cleanup_failed: bool,
},
RefreshedIdentity(QualifiedIdentity),
LoadedIdentity(QualifiedIdentity),
/// This identity's keys were sealed under a password (opt-in).
Expand Down
5 changes: 5 additions & 0 deletions src/backend_task/tokens/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -258,6 +258,11 @@ impl AppContext {
signing_key,
params,
} => {
params
.contract_keywords
.iter()
.try_for_each(|keyword| crate::model::token::validate_contract_keyword(keyword))
.map_err(|source| TaskError::InvalidContractKeywordLength { source })?;
let alias = params.token_names[0].0.clone();
let data_contract = self
.build_data_contract_v1_with_one_token(identity.identity.id(), *params)
Expand Down
25 changes: 25 additions & 0 deletions src/model/dashpay.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,23 @@ use dash_sdk::dpp::document::DocumentV0Getters;
use dash_sdk::platform::{Document, Identifier};
use serde::{Deserialize, Serialize};

use super::validation::{TextLengthError, validate_char_count};

/// Maximum number of characters stored in a DashPay payment memo.
pub const MAX_PAYMENT_MEMO_CHARS: usize = 100;
/// Maximum number of characters stored in a contact-request account label.
pub const MAX_ACCOUNT_LABEL_CHARS: usize = 100;

/// Validate an optional DashPay payment memo.
pub fn validate_payment_memo(memo: &str) -> Result<(), TextLengthError> {
validate_char_count(memo, 0, MAX_PAYMENT_MEMO_CHARS)
}

/// Validate a DashPay contact-request account label.
pub fn validate_account_label(label: &str) -> Result<(), TextLengthError> {
validate_char_count(label, 0, MAX_ACCOUNT_LABEL_CHARS)
}

/// The recipient (`toUserId`) of a DashPay `contactRequest` document.
///
/// Returns `None` when the field is absent or does not hold a readable
Expand Down Expand Up @@ -543,4 +560,12 @@ mod tests {
fn avatar_url_scheme_check_ignores_surrounding_whitespace() {
assert!(validate_profile_fields("", "", " https://example.com/a.png ").is_empty());
}

#[test]
fn dashpay_text_limits_count_characters() {
assert!(validate_payment_memo(&"é".repeat(100)).is_ok());
assert!(validate_payment_memo(&"m".repeat(101)).is_err());
assert!(validate_account_label(&"é".repeat(100)).is_ok());
assert!(validate_account_label(&"l".repeat(101)).is_err());
}
}
79 changes: 79 additions & 0 deletions src/model/dpns.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,55 @@ use dash_sdk::platform::{Document, Identifier};
/// The `.dash` parent domain suffix (case-insensitive match target).
const DASH_SUFFIX: &str = ".dash";

/// Result of validating a bare label for DPNS registration.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DpnsNameValidationResult {
Valid,
TooShort,
TooLong,
InvalidCharacter(char),
StartsWithHyphen,
EndsWithHyphen,
}

/// Validate the format of a bare DPNS label before registration.
pub fn validate_dpns_name(name: &str) -> DpnsNameValidationResult {
if name.len() < 3 {
return DpnsNameValidationResult::TooShort;
}
if name.len() > 63 {
return DpnsNameValidationResult::TooLong;
}
if name.starts_with('-') {
return DpnsNameValidationResult::StartsWithHyphen;
}
if name.ends_with('-') {
return DpnsNameValidationResult::EndsWithHyphen;
}
for character in name.chars() {
if !character.is_ascii_alphanumeric() && character != '-' {
return DpnsNameValidationResult::InvalidCharacter(character);
}
}
DpnsNameValidationResult::Valid
}

impl DpnsNameValidationResult {
/// Return user guidance for an invalid label.
pub fn error_message(self) -> Option<String> {
match self {
Self::Valid => None,
Self::TooShort => Some("Name must be at least 3 characters long.".to_string()),
Self::TooLong => Some("Name must be no more than 63 characters long.".to_string()),
Self::InvalidCharacter(character) => Some(format!(
"The character '{character}' is not allowed. Use only letters, numbers, and hyphens."
)),
Self::StartsWithHyphen => Some("Name cannot start with a hyphen.".to_string()),
Self::EndsWithHyphen => Some("Name cannot end with a hyphen.".to_string()),
}
}
}

/// Extract the bare label from a DPNS input and apply homograph-safe normalization.
///
/// Handles all common user inputs:
Expand Down Expand Up @@ -179,4 +228,34 @@ mod tests {
Err(NonDashDomainError)
);
}

#[test]
fn dpns_registration_name_accepts_boundary_lengths() {
assert_eq!(validate_dpns_name("abc"), DpnsNameValidationResult::Valid);
assert_eq!(
validate_dpns_name(&"a".repeat(63)),
DpnsNameValidationResult::Valid
);
}

#[test]
fn dpns_registration_name_rejects_invalid_formats() {
assert_eq!(validate_dpns_name("ab"), DpnsNameValidationResult::TooShort);
assert_eq!(
validate_dpns_name(&"a".repeat(64)),
DpnsNameValidationResult::TooLong
);
assert_eq!(
validate_dpns_name("-alice"),
DpnsNameValidationResult::StartsWithHyphen
);
assert_eq!(
validate_dpns_name("alice-"),
DpnsNameValidationResult::EndsWithHyphen
);
assert_eq!(
validate_dpns_name("ali_ce"),
DpnsNameValidationResult::InvalidCharacter('_')
);
}
}
Loading
Loading