Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
1791fe5
fix(platform-wallet): accept legacy dashj key purposes on inbound con…
jeanpierreroma Aug 11, 2026
982f107
perf(platform-wallet): keep the contact fetch off the drain's repeati…
jeanpierreroma Aug 11, 2026
289b8fb
refactor(platform-wallet): source the recipient-key cohort from the s…
jeanpierreroma Aug 11, 2026
afec36d
fix(platform-wallet): don't charge a legacy-cohort decrypt failure to…
jeanpierreroma Aug 11, 2026
d822704
Merge branch 'fix/dashpay-legacy-key-purpose' into fix/dashpay-drain-…
jeanpierreroma Aug 11, 2026
608dac5
fix(platform-wallet): make the split validation's mixed-failure polic…
jeanpierreroma Aug 11, 2026
38e6c91
fix(platform-wallet): classify sender-side widening as legacy too
jeanpierreroma Aug 11, 2026
b8c2a83
Merge branch 'fix/dashpay-legacy-key-purpose' into fix/dashpay-drain-…
jeanpierreroma Aug 11, 2026
ee984ad
feat(platform-wallet): make a failed legacy external build self-diagn…
jeanpierreroma Aug 11, 2026
94f10e3
Merge branch 'fix/dashpay-legacy-key-purpose' into fix/dashpay-drain-…
jeanpierreroma Aug 11, 2026
abe80c6
perf(platform-wallet): move purpose-mismatch reasons into the drain s…
jeanpierreroma Aug 11, 2026
8415a50
Merge remote-tracking branch 'origin/v4.2-dev' into fix/dashpay-drain…
jeanpierreroma Aug 11, 2026
d9afc99
fix(platform-wallet): state the decrypt diagnostics as likelihoods, n…
jeanpierreroma Aug 11, 2026
fea6e95
fix(platform-wallet): pool the same funding sources for a contact pay…
romchornyi Aug 11, 2026
0eac01e
fix(platform-wallet): only an immutable fault may permanently break a…
jeanpierreroma Aug 11, 2026
52fe0d9
Merge remote-tracking branch 'origin/fix/dashpay-drain-retry-cost' in…
jeanpierreroma Aug 11, 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 packages/rs-platform-wallet/src/wallet/core/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,5 +10,6 @@ pub mod wallet;
pub use balance::WalletBalance;
pub use balance_handler::BalanceUpdateHandler;
pub use generation::WalletGeneration;
pub(crate) use transaction::resolve_source_accounts;
pub use transaction::{SignedCoreTransaction, ASSET_LOCK_FUNDING_SOURCES, SEND_FUNDING_SOURCES};
pub use wallet::CoreWallet;
Original file line number Diff line number Diff line change
Expand Up @@ -266,7 +266,7 @@ pub const ASSET_LOCK_FUNDING_SOURCES: [AccountTypePreference; 3] = SEND_FUNDING_
/// DashPay source. A set selector matching nothing resolves to an empty list,
/// not an error — a wallet with no contacts still sends from its standard
/// accounts.
fn resolve_source_accounts(
pub(crate) fn resolve_source_accounts(
accounts: &key_wallet::account::ManagedAccountCollection,
preference: AccountTypePreference,
source_index: u32,
Expand Down
142 changes: 133 additions & 9 deletions packages/rs-platform-wallet/src/wallet/identity/crypto/validation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,14 @@ impl ContactRequestValidation {
self.hard_error = true;
}

/// Add an ABSENT-KEY error: the referenced key id does not exist on the
/// identity *today*. Sets `is_valid = false` but NOT `hard_error`, because
/// identities gain keys — see [`is_permanent`](Self::is_permanent).
pub fn add_absent_key_error(&mut self, error: String) {
self.errors.push(error);
self.is_valid = false;
}

/// Add a key-PURPOSE error: sets `is_valid = false` AND flags
/// `purpose_mismatch` so callers can downgrade a *purpose-only* failure
/// to a non-permanent skip rather than a permanent broken-channel mark.
Expand All @@ -89,11 +97,31 @@ impl ContactRequestValidation {
/// Whether the *sole* cause of invalidity is a key-purpose mismatch —
/// the only case that may be downgraded to a non-permanent skip.
/// A purpose mismatch that co-occurs with a hard error (disabled /
/// missing / wrong-type key) is NOT purpose-only and must stay permanent.
/// wrong-type key) is NOT purpose-only and must stay permanent.
pub fn is_purpose_only(&self) -> bool {
self.purpose_mismatch && !self.hard_error
}
Comment on lines 97 to 103

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: Align the public retryability gate with absent-key classification

add_absent_key_error intentionally leaves hard_error false because an absent key is retryable, but the public documentation still says missing keys are permanent and directs callers to is_purpose_only() for skip-versus-break decisions. That predicate returns false for a lone absent-key failure, while returning true for a purpose mismatch combined with an absent key even though purpose is not then the sole cause. The in-crate drain correctly uses is_permanent(), but downstream callers following the advertised public contract can retain the obsolete permanent treatment. Make is_permanent() the documented canonical disposition predicate and deprecate or explicitly restrict is_purpose_only() to diagnostics, or track absent-key failures separately so its stated sole-cause semantics remain exact. Add coverage for lone-absent and purpose-plus-absent results.

source: ['codex']


/// Whether this failure can never resolve on its own — the only kind that
/// may permanently break a contact's payment channel.
///
/// The distinction is not "did validation fail" but "can the world change
/// such that it stops failing". A `contactRequest` clears consensus without
/// consensus checking anything about the keys it names, so a document can
/// reference a key id our identity does not have *yet*: identities gain
/// keys (that is what the DashPay enablement flow does, and what
/// dashwallet-ios#981 exists to notice when it happened on another device).
/// Recording that as permanent turns a temporary gap into a relationship
/// the user cannot repair — only a fresh request from the CONTACT clears
/// the flag.
///
/// So an absent key is retryable, alongside a purpose mismatch. What stays
/// permanent is what immutable facts make impossible: a key whose *type*
/// cannot do ECDH, and a key we have deliberately disabled.
pub fn is_permanent(&self) -> bool {
self.hard_error
}

/// Merge another validation result into this one.
pub fn merge(&mut self, other: ContactRequestValidation) {
self.errors.extend(other.errors);
Expand Down Expand Up @@ -165,12 +193,28 @@ pub fn validate_contact_request(
sender_key_index: u32,
recipient_identity: &Identity,
recipient_key_index: u32,
) -> ContactRequestValidation {
let mut validation = validate_sender_key(sender_identity, sender_key_index);
validation.merge(validate_recipient_key(
recipient_identity,
recipient_key_index,
));
validation
}

/// The sender half of [`validate_contact_request`] — the checks that need the
/// **counterparty's** identity.
///
/// Crate-private: external callers go through the complete
/// [`validate_contact_request`] contract. Split out so the deferred-crypto
/// drain can run the recipient half first — see [`validate_recipient_key`] for
/// why that ordering matters, and what it changes for a mixed failure.
pub(crate) fn validate_sender_key(
sender_identity: &Identity,
sender_key_index: u32,
) -> ContactRequestValidation {
let mut validation = ContactRequestValidation::new();

// -----------------------------------------------------------------------
// Sender key validation
// -----------------------------------------------------------------------
match sender_identity.get_public_key_by_id(sender_key_index) {
Some(key) => {
// Must be ECDSA_SECP256K1 for ECDH.
Expand Down Expand Up @@ -205,17 +249,52 @@ pub fn validate_contact_request(
}
}
None => {
validation.add_error(format!(
validation.add_absent_key_error(format!(
"Sender key index {} not found on identity {}",
sender_key_index,
sender_identity.id(),
));
}
}

// -----------------------------------------------------------------------
// Recipient key validation
// -----------------------------------------------------------------------
validation
}

/// The recipient half of [`validate_contact_request`] — the checks that need
/// only **our own** identity, which is always already resident.
///
/// Split out because the deferred-crypto drain would otherwise pay a Platform
/// round trip (`Identity::fetch` of the contact) before it could discover that
/// the request is unusable for a reason it could have known locally. A
/// purpose-rejected entry stays queued by design — the policy, not the
/// immutable document, is what might change — so that fetch was repeating on
/// every sweep, forever. Mainnet logs from one wallet show 27 contacts and 396
/// such fetch-then-reject cycles in a single session. Running this half first
/// costs nothing and removes the network entirely from that loop.
///
/// # What this changes for a MIXED failure
///
/// Deciding on this half alone is a real policy change, not just a reordering.
/// When our key is purpose-rejected AND the sender's key carries a hard fault
/// (missing / disabled / wrong type), the composed [`validate_contact_request`]
/// would merge both, see `hard_error`, and mark the channel permanently
/// broken. Stopping here classifies it purpose-only and leaves it queued.
///
/// That is the intended outcome. The `hard_error` precedence exists to stop a
/// genuinely permanent fault from becoming a retry-forever loop — but the
/// forever-loop it guards against was expensive precisely because each retry
/// fetched. With the fetch gone, a purpose-rejected entry costs a map lookup
/// per sweep, while marking the channel broken is unappealable by the user:
/// only a fresh request from the CONTACT clears it. Deferring the broken mark
/// until the fault is one we can see locally trades a cheap retry for an
/// irreversible one. A sender-side hard fault still marks the channel broken
/// the moment our own key stops being the blocker.
pub(crate) fn validate_recipient_key(
recipient_identity: &Identity,
recipient_key_index: u32,
) -> ContactRequestValidation {
let mut validation = ContactRequestValidation::new();

match recipient_identity.get_public_key_by_id(recipient_key_index) {
Some(key) => {
// Must be an ECDSA variant for ECDH compatibility.
Expand Down Expand Up @@ -268,7 +347,7 @@ pub fn validate_contact_request(
}
}
None => {
validation.add_error(format!(
validation.add_absent_key_error(format!(
"Recipient key index {} not found on identity {}",
recipient_key_index,
recipient_identity.id(),
Expand Down Expand Up @@ -576,6 +655,51 @@ mod tests {
assert!(!result.purpose_mismatch);
}

/// A key id the identity does not have **yet** must not be permanent.
///
/// Identities gain keys — that is what the DashPay enablement flow does,
/// and dashwallet-ios#981 exists to notice it happening on another device.
/// A `contactRequest` clears consensus without consensus checking anything
/// about the keys it names, and it can never be re-minted, so recording
/// "we have no key 5 today" as a permanent verdict ends a relationship over
/// a gap that may close on its own — and only the CONTACT can clear the
/// flag, so the user cannot appeal it.
#[test]
fn an_absent_key_is_not_a_permanent_fault() {
let sender = make_identity(vec![make_key(
0,
KeyType::ECDSA_SECP256K1,
Purpose::ENCRYPTION,
)]);
let recipient = make_identity(vec![]);

let result = validate_contact_request(&sender, 0, &recipient, 0);
assert!(!result.is_valid, "an absent key still fails validation");
assert!(
!result.is_permanent(),
"but it must be retryable: the identity can gain the key later"
);
}

/// A key type that cannot do ECDH is permanent — a key's type is fixed for
/// its lifetime, so no future state makes this request usable.
#[test]
fn a_non_ecdh_key_type_is_a_permanent_fault() {
let sender = make_identity(vec![make_key(
0,
KeyType::ECDSA_SECP256K1,
Purpose::ENCRYPTION,
)]);
let recipient = make_identity(vec![make_key(0, KeyType::BLS12_381, Purpose::ENCRYPTION)]);

let result = validate_contact_request(&sender, 0, &recipient, 0);
assert!(!result.is_valid);
assert!(
result.is_permanent(),
"a BLS key can never do secp256k1 ECDH, so this one may break the channel"
);
}

/// The node-operational purposes are the ones still refused for a
/// recipient key — and they must stay a non-permanent purpose mismatch, so
/// a future evidence-driven widening can still pick those contacts up
Expand Down
Loading
Loading