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
12 changes: 10 additions & 2 deletions src/backend_task/identity/register_dpns_name.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,10 @@ use crate::backend_task::FeeResult;
use crate::backend_task::error::TaskError;
use crate::{
context::AppContext,
model::{dpns::classify_dpns_registration_outcome, qualified_identity::DPNSNameInfo},
model::{
dpns::{DpnsNameValidationResult, classify_dpns_registration_outcome, validate_dpns_name},
qualified_identity::DPNSNameInfo,
},
};
use bip39::rand::{Rng, SeedableRng, rngs::StdRng};
use dash_sdk::{
Expand Down Expand Up @@ -40,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 @@ -228,7 +236,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
42 changes: 42 additions & 0 deletions src/backend_task/identity/remove_identity.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
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
.get_local_qualified_identity(&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 @@ -688,6 +688,10 @@ pub enum BackendTaskSuccessResult {
outcome: crate::model::dpns::DpnsRegistrationOutcome,
fee_result: 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
10 changes: 10 additions & 0 deletions src/context/wallet_lifecycle/registration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,16 @@ impl AppContext {
let seed_hash = wallet.seed_hash();
let uses_password = wallet.uses_password;

// 0. Reject an invalid alias FIRST — this is pure input validation and
// must fail before any secret-critical write. A rejection at the
// `write_wallet_meta` layer would land AFTER `write_seed_envelope`,
// orphaning the encrypted seed (no meta row → never hydrated, no
// cleanup path). Mirrors the single-key import path.
if let Some(alias) = wallet.alias.as_deref() {
crate::model::wallet::validate_wallet_alias(alias)
.map_err(|source| TaskError::InvalidWalletAliasLength { source })?;
}

// 1. Reject a duplicate import. The upstream `platform-wallet.sqlite`
// persistor is the system of record now; DET no longer writes the
// legacy `data.db.wallet` row (the fresh-install schema gates that
Expand Down
42 changes: 42 additions & 0 deletions src/context/wallet_lifecycle/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2428,6 +2428,48 @@ async fn register_wallet_fails_closed_when_wallet_meta_write_fails() {
);
}

/// An overlong alias is pure input validation and MUST be rejected BEFORE any
/// secret-critical write. Otherwise a meta-write-time rejection lands AFTER
/// `write_seed_envelope`, orphaning the encrypted seed (no meta row → never
/// hydrated, no cleanup path). Mirrors the single-key import path, which
/// already validates before writing.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn register_wallet_rejects_overlong_alias_before_seed_write() {
let temp_dir = tempfile::tempdir().expect("tempdir");
let (ctx, _sender) = offline_testnet_context_at(temp_dir.path());

let seed = [0x5Au8; 64];
let wallet = crate::model::wallet::Wallet::new_from_seed(
seed,
Network::Testnet,
Some("w".repeat(65)),
None,
)
.expect("build wallet");
let seed_hash = wallet.seed_hash();

let result = ctx.register_wallet(wallet, &seed, WalletOrigin::Fresh);
assert!(
matches!(result, Err(TaskError::InvalidWalletAliasLength { .. })),
"an overlong alias must be rejected before any seed write"
);
assert!(
WalletSeedView::new(&ctx.secret_store())
.get_raw(&seed_hash)
.expect("read raw seed")
.is_none(),
"no seed material must survive a rejected HD registration (orphaned secret)"
);
assert!(
!ctx.wallets.read_recover().contains_key(&seed_hash),
"a rejected wallet must not be kept in memory"
);
assert!(
!ctx.has_wallet.load(Ordering::Relaxed),
"has_wallet must not flip true when registration is rejected"
);
}

/// Build a valid BIP44 account-0 master xpub (testnet) for a legacy wallet row.
fn legacy_master_epk_bytes(seed: &[u8; 64]) -> Vec<u8> {
crate::database::test_helpers::legacy_master_epk_bytes(seed, Network::Testnet)
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());
}
}
Loading
Loading