From 5fa471483ac9605bb04524a73d6f2a11c586ca88 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Sun, 8 Nov 2026 10:35:59 +0700 Subject: [PATCH 001/117] test(sdk): open an entry with the first of a wallet's prepared keys The prepared-keys test also opens an entry with the first key of the set, so a scan skipping earlier keys would fail it. Co-Authored-By: Claude Opus 5.5 --- .../src/private_document_envelope.rs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/packages/rs-platform-encryption/src/private_document_envelope.rs b/packages/rs-platform-encryption/src/private_document_envelope.rs index 7ecfb08d198..d90dc674b6a 100644 --- a/packages/rs-platform-encryption/src/private_document_envelope.rs +++ b/packages/rs-platform-encryption/src/private_document_envelope.rs @@ -904,6 +904,13 @@ mod tests { "prepared keys open the entry as the function does" ); } + // The first key opens an entry too: the scan tries every key, from the first. + let first_key = PrivateDocumentRecipientKeys::new(&[secret_key(2), secret_key(3)]); + let opened = first_key + .open(&entries[0], &target(), &RECIPIENT_IDENTITY) + .expect("well formed") + .expect("for the first key"); + assert_eq!(opened.recipient_key_index(), Some(0)); let others = PrivateDocumentRecipientKeys::new(&[secret_key(4)]); assert_eq!( others From 1549627da60afaa809a2c32ed0934d9006e002ea Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Sun, 8 Nov 2026 10:58:59 +0700 Subject: [PATCH 002/117] feat(sdk): send, scan and read private contact requests The SDK can now append entries to a private document type's store, paid from the shielded pool (type 29), and find a wallet's entries in it: - `PrivateDocumentCreateFromShieldedPool` builds, structure-checks and broadcasts the transition from a proven bundle, and waits for its nullifiers to be proven spent. - `PrivateDocumentScanKeys` and `Sdk::scan_private_document_page` sort a proved page of a store into what the wallet's identities received (their keys open it), what they sent (their outgoing viewing keys recover it) and what opened but is refused, with the cursor to scan from next. - DashPay: `PrivateContactRequest` with the schema's bounds, `seal_private_contact_request` (the sender's encryption key, a deniable MAC, and the outgoing viewing key derived from that key), and `Sdk::scan_private_contact_requests`, which decodes each request, checks its sender against the identity key it names and keeps the first request per sender, recipient and account reference. platform-encryption: prepared recipient keys check a sender with the key that opened the entry, so a scan never copies secret keys out. Co-Authored-By: Claude Opus 5.5 --- .../src/private_document_envelope.rs | 55 +- packages/rs-sdk/src/platform.rs | 1 + .../src/platform/dashpay/contact_request.rs | 2 +- packages/rs-sdk/src/platform/dashpay/mod.rs | 14 +- .../dashpay/private_contact_request.rs | 1257 +++++++++++++++++ .../src/platform/private_document_scan.rs | 548 +++++++ packages/rs-sdk/src/platform/transition.rs | 2 + ...vate_document_create_from_shielded_pool.rs | 157 ++ 8 files changed, 2033 insertions(+), 3 deletions(-) create mode 100644 packages/rs-sdk/src/platform/dashpay/private_contact_request.rs create mode 100644 packages/rs-sdk/src/platform/private_document_scan.rs create mode 100644 packages/rs-sdk/src/platform/transition/private_document_create_from_shielded_pool.rs diff --git a/packages/rs-platform-encryption/src/private_document_envelope.rs b/packages/rs-platform-encryption/src/private_document_envelope.rs index d90dc674b6a..4660e463cfc 100644 --- a/packages/rs-platform-encryption/src/private_document_envelope.rs +++ b/packages/rs-platform-encryption/src/private_document_envelope.rs @@ -60,7 +60,7 @@ use secp256k1::{Message, PublicKey, SecretKey}; use sha2::{Digest, Sha256}; /// Why an entry could not be built, opened, recovered or checked. -#[derive(Debug, PartialEq, Eq, thiserror::Error)] +#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)] pub enum PrivateDocumentEnvelopeError { #[error("a private document payload of {payload_len} bytes does not fit an entry that holds {capacity}")] PayloadTooLarge { payload_len: usize, capacity: usize }, @@ -476,6 +476,23 @@ impl PrivateDocumentRecipientKeys { } Ok(None) } + + /// [`OpenedPrivateDocumentEntry::verify_sender`] with the one of these keys that opened + /// `entry`, which a deniable MAC is checked with. An entry these keys did not open (one a + /// sender recovered, or that other keys opened) is refused with + /// `RecipientSecretKeyRequired`. + pub fn verify_sender( + &self, + entry: &OpenedPrivateDocumentEntry, + sender_public_key: &PublicKey, + ) -> Result<(), PrivateDocumentEnvelopeError> { + let (secret_key, _, _) = entry + .recipient_key_index + .and_then(|index| self.keys.get(index)) + .filter(|(_, public_key, _)| *public_key == entry.recipient_public_key) + .ok_or(PrivateDocumentEnvelopeError::RecipientSecretKeyRequired)?; + entry.verify_sender(sender_public_key, Some(secret_key)) + } } /// [`PrivateDocumentRecipientKeys::open`] with keys prepared for this one entry; a scan of many @@ -920,6 +937,42 @@ mod tests { ); } + /// Prepared keys check a deniable MAC with the key that opened the entry, without the caller + /// holding the secret key, and refuse an entry they did not open. + #[test] + fn should_check_the_sender_with_the_prepared_key_that_opened_the_entry() { + let sender_key = secret_key(1); + let entry = seal(&deniable_mac_sender(&sender_key), 9); + let prepared = PrivateDocumentRecipientKeys::new(&[secret_key(3), secret_key(2)]); + let opened = prepared + .open(&entry, &target(), &RECIPIENT_IDENTITY) + .expect("well formed") + .expect("for the second key"); + prepared + .verify_sender(&opened, &public_key(1)) + .expect("the sender's key wrote the MAC"); + assert_eq!( + prepared.verify_sender(&opened, &public_key(5)), + Err(PrivateDocumentEnvelopeError::SenderNotAuthenticated) + ); + + // Other prepared keys do not check it, though they hold a key at the index it reports. + let others = PrivateDocumentRecipientKeys::new(&[secret_key(4), secret_key(5)]); + assert_eq!( + others.verify_sender(&opened, &public_key(1)), + Err(PrivateDocumentEnvelopeError::RecipientSecretKeyRequired) + ); + // Nor do they for an entry the sender recovered. + let recovered = + recover_sent_private_document_entry(&entry, &target(), &outgoing_viewing_key()) + .expect("well formed") + .expect("the sender built it"); + assert_eq!( + prepared.verify_sender(&recovered, &public_key(1)), + Err(PrivateDocumentEnvelopeError::RecipientSecretKeyRequired) + ); + } + /// Nearly every entry a wallet scans is for someone else: it is passed over, not refused. #[test] fn should_pass_over_an_entry_for_other_keys() { diff --git a/packages/rs-sdk/src/platform.rs b/packages/rs-sdk/src/platform.rs index 06d5dd53e4e..f951f159d7f 100644 --- a/packages/rs-sdk/src/platform.rs +++ b/packages/rs-sdk/src/platform.rs @@ -29,6 +29,7 @@ pub mod moderation_charters; pub mod private_document_entries; #[cfg(test)] mod private_document_envelope_tests; +pub mod private_document_scan; pub mod query; pub mod query_settings; #[cfg(feature = "shielded")] diff --git a/packages/rs-sdk/src/platform/dashpay/contact_request.rs b/packages/rs-sdk/src/platform/dashpay/contact_request.rs index 3f528a4bba6..2e3fe81e91c 100644 --- a/packages/rs-sdk/src/platform/dashpay/contact_request.rs +++ b/packages/rs-sdk/src/platform/dashpay/contact_request.rs @@ -154,7 +154,7 @@ pub struct SendContactRequestResult { /// /// Mint-side only — see [`sender_key_purpose_is_acceptable_on_receive`] for /// what we accept from documents already on chain. -fn sender_key_purpose_is_valid(purpose: Purpose) -> bool { +pub(crate) fn sender_key_purpose_is_valid(purpose: Purpose) -> bool { purpose == Purpose::ENCRYPTION } diff --git a/packages/rs-sdk/src/platform/dashpay/mod.rs b/packages/rs-sdk/src/platform/dashpay/mod.rs index da89e8eec52..7241dfb6cb7 100644 --- a/packages/rs-sdk/src/platform/dashpay/mod.rs +++ b/packages/rs-sdk/src/platform/dashpay/mod.rs @@ -2,11 +2,13 @@ //! //! This module provides helper functions for creating and sending DashPay contact requests //! according to DIP-15 specification, for creating, updating and fetching DashPay profiles, -//! and the DashPay invitation link codec and claim. +//! the DashPay invitation link codec and claim, and private contact requests (protocol +//! version 15). mod contact_request; mod contact_request_queries; mod invitation; +mod private_contact_request; mod profile; pub use contact_request::{ @@ -20,6 +22,16 @@ pub use invitation::{ wif_network_matches, InvitationClaimStatus, InvitationError, InviterInfo, ParsedInvitation, MAX_INVITATION_DUFFS, MAX_INVITATION_TTL_SECS, MIN_INVITATION_DUFFS, }; +pub use private_contact_request::{ + private_contact_request_outgoing_viewing_key, private_contact_request_senders, + private_contact_requests_from_scan, seal_private_contact_request, PrivateContactRequest, + PrivateContactRequestRejection, PrivateContactRequestScan, PrivateContactRequestSender, + ReceivedPrivateContactRequest, RejectedPrivateContactRequest, SentPrivateContactRequest, + PRIVATE_CONTACT_REQUEST_ACCOUNT_LABEL_MAX_BYTES, + PRIVATE_CONTACT_REQUEST_AUTO_ACCEPT_PROOF_MAX_LEN, + PRIVATE_CONTACT_REQUEST_AUTO_ACCEPT_PROOF_MIN_LEN, PRIVATE_CONTACT_REQUEST_DOCUMENT_TYPE_NAME, + PRIVATE_CONTACT_REQUEST_SHIELDED_ADDRESS_LEN, +}; pub use profile::ProfileFields; use crate::{Error, Sdk}; diff --git a/packages/rs-sdk/src/platform/dashpay/private_contact_request.rs b/packages/rs-sdk/src/platform/dashpay/private_contact_request.rs new file mode 100644 index 00000000000..4fb4d4aa3eb --- /dev/null +++ b/packages/rs-sdk/src/platform/dashpay/private_contact_request.rs @@ -0,0 +1,1257 @@ +//! DashPay private contact requests (`privateContactRequest`, DashPay v3, protocol version 15). +//! +//! A private contact request is an entry of the `privateContactRequest` store sealed to one of +//! the recipient's DashPay keys: only the recipient learns that it exists, who sent it and what +//! it holds. The sender pays for it from the shielded pool, so no identity appears on chain. +//! Inside the entry the sender names its identity and DashPay encryption key, with a deniable +//! MAC: the recipient learns who wrote it, but cannot prove that to anyone else. +//! +//! - Sending: [`seal_private_contact_request`] (or [`Sdk::seal_private_contact_request`]) builds +//! the entry, and a wallet appends it, paying from its notes, with +//! [`PrivateDocumentCreateFromShieldedPool`](crate::platform::transition::private_document_create_from_shielded_pool::PrivateDocumentCreateFromShieldedPool) +//! or the dpp builder. +//! - Receiving: [`Sdk::scan_private_contact_requests`] reads a page of the store, opens the +//! wallet's entries, checks each sender against its identity's key and reads the request. +//! +//! Each identity seals with the outgoing viewing key of its DashPay encryption key +//! ([`private_contact_request_outgoing_viewing_key`]), so a wallet restored from seed recovers +//! the requests it sent from the store alone. + +use super::contact_request::{recipient_key_purpose_is_valid, sender_key_purpose_is_valid}; +use crate::platform::private_document_scan::{ + PrivateDocumentScan, PrivateDocumentScanKeys, ReceivedPrivateDocumentEntry, +}; +use crate::platform::{Fetch, Identifier}; +use crate::{Error, Sdk}; +use dpp::data_contract::accessors::v0::DataContractV0Getters; +use dpp::data_contract::document_type::accessors::DocumentTypeV2Getters; +use dpp::data_contract::document_type::{ + decode_private_document_payload, encode_private_document_payload, DocumentTypeRef, +}; +use dpp::data_contract::DataContract; +use dpp::identity::accessors::IdentityGettersV0; +use dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; +use dpp::identity::{Identity, IdentityPublicKey, KeyID, KeyType}; +use dpp::platform_value::{BinaryData, Value}; +use dpp::secp256k1::rand::rngs::StdRng; +use dpp::secp256k1::rand::{RngCore, SeedableRng}; +use dpp::secp256k1::{PublicKey, SecretKey}; +use dpp::version::PlatformVersion; +use platform_encryption::{ + private_document_outgoing_viewing_key, seal_private_document_entry, + PrivateDocumentEnvelopeError, PrivateDocumentRecipient, PrivateDocumentSender, + PrivateDocumentTarget, COMPACT_XPUB_LEN, +}; +use std::collections::{BTreeMap, BTreeSet}; +use zeroize::Zeroize; + +/// The DashPay document type of private contact requests. +pub const PRIVATE_CONTACT_REQUEST_DOCUMENT_TYPE_NAME: &str = "privateContactRequest"; + +/// The most UTF-8 bytes of `accountLabel` (`maxBytes`). +pub const PRIVATE_CONTACT_REQUEST_ACCOUNT_LABEL_MAX_BYTES: usize = 63; +/// The fewest bytes of `autoAcceptProof` (`minItems`). +pub const PRIVATE_CONTACT_REQUEST_AUTO_ACCEPT_PROOF_MIN_LEN: usize = 38; +/// The most bytes of `autoAcceptProof` (`maxItems`). +pub const PRIVATE_CONTACT_REQUEST_AUTO_ACCEPT_PROOF_MAX_LEN: usize = 102; +/// The bytes of `shieldedAddress`: an Orchard diversifier (11) and transmission key (32). +pub const PRIVATE_CONTACT_REQUEST_SHIELDED_ADDRESS_LEN: usize = 43; + +const ACCOUNT_REFERENCE: &str = "accountReference"; +const EXTENDED_PUBLIC_KEY: &str = "extendedPublicKey"; +const ACCOUNT_LABEL: &str = "accountLabel"; +const AUTO_ACCEPT_PROOF: &str = "autoAcceptProof"; +const SHIELDED_ADDRESS: &str = "shieldedAddress"; + +/// What a private contact request carries, in the clear inside its entry. Unlike a public +/// `contactRequest`, nothing in it is encrypted again: the entry already is. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PrivateContactRequest { + /// The DIP-15 account reference of the sender's account the recipient pays to. + pub account_reference: u32, + /// The DIP-15 compact extended public key the recipient pays to: the parent fingerprint + /// (4 bytes), the chain code (32) and the public key (33). + pub extended_public_key: [u8; COMPACT_XPUB_LEN], + /// A label for the account, at most 63 UTF-8 bytes. + pub account_label: Option, + /// A DIP-15 auto-accept proof, 38 to 102 bytes. + pub auto_accept_proof: Option>, + /// The Orchard address the recipient pays this contact to privately. + pub shielded_address: Option<[u8; PRIVATE_CONTACT_REQUEST_SHIELDED_ADDRESS_LEN]>, +} + +impl PrivateContactRequest { + /// The request as the type's properties, refused when a value is out of the schema's + /// bounds. dpp's schema validation is not part of the SDK's features, so the bounds the + /// fields' types do not hold are checked here. + pub fn to_properties(&self) -> Result, Error> { + self.check_bounds()?; + let mut properties = BTreeMap::from([ + ( + ACCOUNT_REFERENCE.to_string(), + Value::U32(self.account_reference), + ), + ( + EXTENDED_PUBLIC_KEY.to_string(), + Value::Bytes(self.extended_public_key.to_vec()), + ), + ]); + if let Some(label) = &self.account_label { + properties.insert(ACCOUNT_LABEL.to_string(), Value::Text(label.clone())); + } + if let Some(proof) = &self.auto_accept_proof { + properties.insert(AUTO_ACCEPT_PROOF.to_string(), Value::Bytes(proof.clone())); + } + if let Some(address) = &self.shielded_address { + properties.insert(SHIELDED_ADDRESS.to_string(), Value::Bytes(address.to_vec())); + } + Ok(properties) + } + + /// The request a private contact request's properties hold, refused when one is missing, + /// unknown, of the wrong type or out of the schema's bounds. + pub fn from_properties(mut properties: BTreeMap) -> Result { + let invalid = + |message: String| Error::Generic(format!("private contact request: {message}")); + // An absent optional property may read back as null. + properties.retain(|_, value| !value.is_null()); + let mut take_bytes = |name: &str| -> Result>, Error> { + properties + .remove(name) + .map(|value| { + value + .into_binary_bytes() + .map_err(|error| invalid(format!("{name}: {error}"))) + }) + .transpose() + }; + let extended_public_key = take_bytes(EXTENDED_PUBLIC_KEY)? + .ok_or_else(|| invalid(format!("{EXTENDED_PUBLIC_KEY} is missing")))?; + let auto_accept_proof = take_bytes(AUTO_ACCEPT_PROOF)?; + let shielded_address = take_bytes(SHIELDED_ADDRESS)?; + let account_reference = properties + .remove(ACCOUNT_REFERENCE) + .ok_or_else(|| invalid(format!("{ACCOUNT_REFERENCE} is missing")))? + .to_integer::() + .map_err(|error| invalid(format!("{ACCOUNT_REFERENCE}: {error}")))?; + let account_label = properties + .remove(ACCOUNT_LABEL) + .map(|value| { + value + .into_text() + .map_err(|error| invalid(format!("{ACCOUNT_LABEL}: {error}"))) + }) + .transpose()?; + if let Some(name) = properties.keys().next() { + return Err(invalid(format!("unknown property {name}"))); + } + + let request = Self { + account_reference, + extended_public_key: extended_public_key.as_slice().try_into().map_err(|_| { + invalid(format!( + "{EXTENDED_PUBLIC_KEY} is {} bytes, not {COMPACT_XPUB_LEN}", + extended_public_key.len() + )) + })?, + account_label, + auto_accept_proof, + shielded_address: shielded_address + .map(|address| { + address.as_slice().try_into().map_err(|_| { + invalid(format!( + "{SHIELDED_ADDRESS} is {} bytes, not \ + {PRIVATE_CONTACT_REQUEST_SHIELDED_ADDRESS_LEN}", + address.len() + )) + }) + }) + .transpose()?, + }; + request.check_bounds()?; + Ok(request) + } + + fn check_bounds(&self) -> Result<(), Error> { + if let Some(label) = &self.account_label { + if label.len() > PRIVATE_CONTACT_REQUEST_ACCOUNT_LABEL_MAX_BYTES { + return Err(Error::Generic(format!( + "private contact request: {ACCOUNT_LABEL} is {} bytes, more than \ + {PRIVATE_CONTACT_REQUEST_ACCOUNT_LABEL_MAX_BYTES}", + label.len() + ))); + } + } + if let Some(proof) = &self.auto_accept_proof { + if !(PRIVATE_CONTACT_REQUEST_AUTO_ACCEPT_PROOF_MIN_LEN + ..=PRIVATE_CONTACT_REQUEST_AUTO_ACCEPT_PROOF_MAX_LEN) + .contains(&proof.len()) + { + return Err(Error::Generic(format!( + "private contact request: {AUTO_ACCEPT_PROOF} is {} bytes, not \ + {PRIVATE_CONTACT_REQUEST_AUTO_ACCEPT_PROOF_MIN_LEN} to \ + {PRIVATE_CONTACT_REQUEST_AUTO_ACCEPT_PROOF_MAX_LEN}", + proof.len() + ))); + } + } + Ok(()) + } +} + +/// The outgoing viewing key an identity seals its private contact requests with, derived from +/// the secret key of its DashPay encryption key: with it a wallet recovers the requests the +/// identity sent from the store alone, as after a restore from seed. An identity that changed +/// its encryption key scans with the key of each. +pub fn private_contact_request_outgoing_viewing_key(encryption_secret_key: &SecretKey) -> [u8; 32] { + let mut secret = encryption_secret_key.to_secret_bytes(); + let outgoing_viewing_key = private_document_outgoing_viewing_key(&secret); + secret.zeroize(); + outgoing_viewing_key +} + +/// The sender of a private contact request: its identity, and the id and secret key of the +/// DashPay encryption key it names in the entry. +pub struct PrivateContactRequestSender<'a> { + /// The sending identity. + pub identity: &'a Identity, + /// The id of its enabled `ECDSA_SECP256K1` `ENCRYPTION` key. + pub encryption_key_id: KeyID, + /// That key's secret key. + pub encryption_secret_key: &'a SecretKey, +} + +/// Seals `request` from `sender` to the key `recipient_key_id` of `recipient`: an entry of the +/// `privateContactRequest` store of `dashpay_contract`, of the type's entry size. The sender +/// key must be the identity's enabled `ECDSA_SECP256K1` `ENCRYPTION` key whose secret key is +/// given; the recipient key an enabled `ECDSA_SECP256K1` `DECRYPTION` or `ENCRYPTION` key. +/// +/// `esk_seed` must be 32 fresh random bytes for every entry, never reused, not even on a retry +/// ([`Sdk::seal_private_contact_request`] draws one). +pub fn seal_private_contact_request( + dashpay_contract: &DataContract, + sender: &PrivateContactRequestSender, + recipient: &Identity, + recipient_key_id: KeyID, + request: &PrivateContactRequest, + esk_seed: &[u8; 32], +) -> Result { + let document_type = private_contact_request_type(dashpay_contract)?; + let entry_size = document_type + .private_document() + .map(|config| config.entry_size()) + .ok_or_else(|| { + Error::Generic(format!( + "DashPay {PRIVATE_CONTACT_REQUEST_DOCUMENT_TYPE_NAME} is not a private type" + )) + })?; + + let sender_key = sender + .identity + .get_public_key_by_id(sender.encryption_key_id) + .ok_or_else(|| { + Error::Generic(format!( + "the sender identity {} has no key {}", + sender.identity.id(), + sender.encryption_key_id + )) + })?; + if !sender_key_purpose_is_valid(sender_key.purpose()) { + return Err(Error::Generic(format!( + "the sender key {} is a {} key, not an ENCRYPTION key", + sender.encryption_key_id, + sender_key.purpose() + ))); + } + if ecdh_public_key(sender_key, "sender")? + != PublicKey::from_secret_key(sender.encryption_secret_key) + { + return Err(Error::Generic(format!( + "the secret key given is not the one of the sender key {}", + sender.encryption_key_id + ))); + } + + let recipient_key = recipient + .get_public_key_by_id(recipient_key_id) + .ok_or_else(|| { + Error::Generic(format!( + "the recipient identity {} has no key {recipient_key_id}", + recipient.id() + )) + })?; + if !recipient_key_purpose_is_valid(recipient_key.purpose()) { + return Err(Error::Generic(format!( + "the recipient key {recipient_key_id} is a {} key, not a DECRYPTION or \ + ENCRYPTION key", + recipient_key.purpose() + ))); + } + let recipient_public_key = ecdh_public_key(recipient_key, "recipient")?; + + let payload = encode_private_document_payload(document_type, request.to_properties()?)?; + let outgoing_viewing_key = + private_contact_request_outgoing_viewing_key(sender.encryption_secret_key); + let entry = seal_private_document_entry( + &PrivateDocumentTarget { + contract_id: dashpay_contract.id().to_buffer(), + document_type_name: PRIVATE_CONTACT_REQUEST_DOCUMENT_TYPE_NAME, + }, + entry_size as usize, + &PrivateDocumentRecipient { + identity_id: recipient.id().to_buffer(), + public_key: &recipient_public_key, + }, + &PrivateDocumentSender::DeniableMac { + identity_id: sender.identity.id().to_buffer(), + key_id: sender.encryption_key_id, + secret_key: sender.encryption_secret_key, + }, + &payload, + &outgoing_viewing_key, + esk_seed, + ) + .map_err(|error| Error::Generic(format!("sealing a private contact request: {error}")))?; + Ok(BinaryData::new(entry)) +} + +/// The public key of an enabled `ECDSA_SECP256K1` identity key, which ECDH runs on. +fn ecdh_public_key(key: &IdentityPublicKey, role: &str) -> Result { + if key.key_type() != KeyType::ECDSA_SECP256K1 { + return Err(Error::Generic(format!( + "the {role} key {} is a {} key, not ECDSA_SECP256K1", + key.id(), + key.key_type() + ))); + } + if key.disabled_at().is_some() { + return Err(Error::Generic(format!( + "the {role} key {} is disabled", + key.id() + ))); + } + PublicKey::from_slice(key.data().as_slice()).map_err(|error| { + Error::Generic(format!( + "the {role} key {} is not a secp256k1 point: {error}", + key.id() + )) + }) +} + +fn private_contact_request_type( + dashpay_contract: &DataContract, +) -> Result, Error> { + dashpay_contract + .document_type_for_name(PRIVATE_CONTACT_REQUEST_DOCUMENT_TYPE_NAME) + .map_err(|_| { + Error::Generic(format!( + "the DashPay contract has no {PRIVATE_CONTACT_REQUEST_DOCUMENT_TYPE_NAME} type: \ + it is declared from DashPay v3, protocol version 15" + )) + }) +} + +/// A private contact request to one of the wallet's identities, from a sender whose key was +/// checked. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ReceivedPrivateContactRequest { + /// The entry's position in the store. + pub position: u64, + /// The height of the block that appended it. + pub block_height: u64, + /// The time of the block that appended it, in milliseconds. + pub block_time_ms: u64, + /// The wallet's identity it is to. + pub recipient_id: Identifier, + /// The key of that identity it is encrypted to. + pub recipient_key_id: KeyID, + /// The identity that sent it. + pub sender_id: Identifier, + /// The sender's encryption key it names, which wrote its MAC. + pub sender_key_id: KeyID, + /// The entry's commitment: a copy of the entry appended again has the same one. + pub commitment: [u8; 32], + /// The request. + pub request: PrivateContactRequest, +} + +/// A private contact request one of the wallet's identities sent, recovered with its outgoing +/// viewing key. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct SentPrivateContactRequest { + /// The entry's position in the store. + pub position: u64, + /// The height of the block that appended it. + pub block_height: u64, + /// The time of the block that appended it, in milliseconds. + pub block_time_ms: u64, + /// The wallet's identity that sent it. + pub sender_id: Identifier, + /// The identity it is to. + pub recipient_id: Identifier, + /// The recipient key it is encrypted to. + pub recipient_public_key: PublicKey, + /// The entry's commitment. + pub commitment: [u8; 32], + /// The request. + pub request: PrivateContactRequest, +} + +/// Why an entry that opened for one of the wallet's identities is not taken as a request. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum PrivateContactRequestRejection { + /// The entry itself is refused: malformed, of an unknown envelope version, or naming + /// another identity than its key's. + Envelope(PrivateDocumentEnvelopeError), + /// It names no sender, and a contact request is from someone. + AnonymousSender, + /// The identity it names as its sender does not exist. + SenderNotFound, + /// The sender key it names is missing, not an `ECDSA_SECP256K1` `ENCRYPTION` key, or was + /// disabled before the entry was appended. + SenderKeyNotUsable(String), + /// The sender key it names did not write it. + SenderNotAuthenticated, + /// Its payload is not a valid private contact request. One written after DashPay gained a + /// property the contract the SDK holds lacks reads as invalid until the contract is + /// refetched, so a wallet keeps the position to read again. + InvalidRequest(String), + /// An earlier entry of the scan holds a request from the same sender to the same + /// recipient for the same account reference, which is kept. + Duplicate, +} + +/// An entry of the store refused as a private contact request. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RejectedPrivateContactRequest { + /// The entry's position in the store. + pub position: u64, + /// The height of the block that appended it. + pub block_height: u64, + /// Why it is refused. + pub reason: PrivateContactRequestRejection, +} + +/// What one page of the `privateContactRequest` store holds for the wallet. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct PrivateContactRequestScan { + /// The requests to the wallet's identities, in store order. + pub received: Vec, + /// The requests the wallet's identities sent, in store order. + pub sent: Vec, + /// The entries refused, in store order. + pub rejected: Vec, + /// The position to scan from next. + pub next_position: u64, + /// How many entries the store held when the page was read. + pub total_count: u64, +} + +impl PrivateContactRequestScan { + /// Whether the scan reached the store's last entry. + pub fn is_complete(&self) -> bool { + self.next_position >= self.total_count + } +} + +/// The identity ids the received entries of `scan` name as their senders, which +/// [`private_contact_requests_from_scan`] checks them against. +pub fn private_contact_request_senders(scan: &PrivateDocumentScan) -> BTreeSet { + scan.received + .iter() + .map(|received| Identifier::from(*received.entry.sender_identity_id())) + .filter(|sender_id| *sender_id != Identifier::default()) + .collect() +} + +/// Reads the private contact requests of a scan of DashPay's `privateContactRequest` store: +/// decodes each payload, checks each received entry's sender against the key its identity, as +/// fetched into `sender_identities` (`None` for one that does not exist), holds under the id +/// the entry names, and keeps the first request from a sender to a recipient for an account +/// reference. A wallet keeps the requests it already holds from earlier scans, and drops a +/// later one from the same sender for the same account reference as this does. +pub fn private_contact_requests_from_scan( + dashpay_contract: &DataContract, + scan: PrivateDocumentScan, + keys: &PrivateDocumentScanKeys, + sender_identities: &BTreeMap>, + platform_version: &PlatformVersion, +) -> Result { + let document_type = private_contact_request_type(dashpay_contract)?; + let decode = |payload: &[u8]| -> Result { + let properties = decode_private_document_payload(document_type, payload, platform_version) + .map_err(|error| PrivateContactRequestRejection::InvalidRequest(error.to_string()))?; + PrivateContactRequest::from_properties(properties) + .map_err(|error| PrivateContactRequestRejection::InvalidRequest(error.to_string())) + }; + + let mut result = PrivateContactRequestScan { + next_position: scan.next_position, + total_count: scan.total_count, + ..Default::default() + }; + let mut rejected: Vec = scan + .rejected + .into_iter() + .map(|rejected| RejectedPrivateContactRequest { + position: rejected.position, + block_height: rejected.block_height, + reason: PrivateContactRequestRejection::Envelope(rejected.error), + }) + .collect(); + + let mut seen = BTreeSet::new(); + for received in scan.received { + let reject = |reason| RejectedPrivateContactRequest { + position: received.position, + block_height: received.block_height, + reason, + }; + match read_received(&received, keys, sender_identities, &decode) { + Ok(request) => { + let sender_id = Identifier::from(*received.entry.sender_identity_id()); + if !seen.insert(( + received.recipient_identity_id, + sender_id, + request.account_reference, + )) { + rejected.push(reject(PrivateContactRequestRejection::Duplicate)); + continue; + } + result.received.push(ReceivedPrivateContactRequest { + position: received.position, + block_height: received.block_height, + block_time_ms: received.block_time_ms, + recipient_id: received.recipient_identity_id, + recipient_key_id: received.recipient_key_id, + sender_id, + sender_key_id: received.entry.sender_key_id(), + commitment: *received.entry.commitment(), + request, + }); + } + Err(reason) => rejected.push(reject(reason)), + } + } + + for sent in scan.sent { + match decode(sent.entry.payload()) { + Ok(request) => result.sent.push(SentPrivateContactRequest { + position: sent.position, + block_height: sent.block_height, + block_time_ms: sent.block_time_ms, + sender_id: sent.sender_identity_id, + recipient_id: Identifier::from(*sent.entry.recipient_identity_id()), + recipient_public_key: *sent.entry.recipient_public_key(), + commitment: *sent.entry.commitment(), + request, + }), + Err(reason) => rejected.push(RejectedPrivateContactRequest { + position: sent.position, + block_height: sent.block_height, + reason, + }), + } + } + + rejected.sort_by_key(|rejected| rejected.position); + result.rejected = rejected; + Ok(result) +} + +/// The request a received entry holds, once its sender is checked. +fn read_received( + received: &ReceivedPrivateDocumentEntry, + keys: &PrivateDocumentScanKeys, + sender_identities: &BTreeMap>, + decode: &impl Fn(&[u8]) -> Result, +) -> Result { + let sender_id = Identifier::from(*received.entry.sender_identity_id()); + if sender_id == Identifier::default() { + return Err(PrivateContactRequestRejection::AnonymousSender); + } + let request = decode(received.entry.payload())?; + let sender = sender_identities + .get(&sender_id) + .and_then(Option::as_ref) + .ok_or(PrivateContactRequestRejection::SenderNotFound)?; + let key_id = received.entry.sender_key_id(); + let unusable = |message: String| PrivateContactRequestRejection::SenderKeyNotUsable(message); + let key = sender + .get_public_key_by_id(key_id) + .ok_or_else(|| unusable(format!("the sender has no key {key_id}")))?; + if !sender_key_purpose_is_valid(key.purpose()) { + return Err(unusable(format!( + "the sender key {key_id} is a {} key, not an ENCRYPTION key", + key.purpose() + ))); + } + if key.key_type() != KeyType::ECDSA_SECP256K1 { + return Err(unusable(format!( + "the sender key {key_id} is a {} key, not ECDSA_SECP256K1", + key.key_type() + ))); + } + // A key disabled after the entry was appended still wrote it: keys are disabled, never + // removed, and the entry is immutable. + if let Some(disabled_at) = key.disabled_at() { + if disabled_at <= received.block_time_ms { + return Err(unusable(format!( + "the sender key {key_id} was disabled at {disabled_at}, before the entry" + ))); + } + } + let sender_public_key = PublicKey::from_slice(key.data().as_slice()).map_err(|error| { + unusable(format!( + "the sender key {key_id} is not a secp256k1 point: {error}" + )) + })?; + keys.verify_sender(received, &sender_public_key) + .map_err(|_| PrivateContactRequestRejection::SenderNotAuthenticated)?; + Ok(request) +} + +impl Sdk { + /// Seals `request` from `sender` to the key `recipient_key_id` of `recipient`, as + /// [`seal_private_contact_request`] does, with the DashPay contract and a fresh ephemeral + /// seed. The entry is appended by a transition paid from the sender's notes. + pub async fn seal_private_contact_request( + &self, + sender: &PrivateContactRequestSender<'_>, + recipient: &Identity, + recipient_key_id: KeyID, + request: &PrivateContactRequest, + ) -> Result { + let dashpay_contract = self.fetch_dashpay_contract().await?; + // A seed whose hash is no secret key, a chance of about 2^-128, fails the seal, and a + // retry draws another. + let mut esk_seed = [0u8; 32]; + StdRng::from_os_rng().fill_bytes(&mut esk_seed); + let sealed = seal_private_contact_request( + &dashpay_contract, + sender, + recipient, + recipient_key_id, + request, + &esk_seed, + ); + esk_seed.zeroize(); + sealed + } + + /// Reads the page of DashPay's `privateContactRequest` store from `start_position` and + /// returns the requests it holds for the wallet whose `keys` are given: those to its + /// identities, each sender checked against the identity key the entry names, and those its + /// identities sent. The sender identities are fetched once each. + /// + /// A wallet keeps [`PrivateContactRequestScan::next_position`] as its cursor once it has + /// stored what the page held, and scans again from it until + /// [`PrivateContactRequestScan::is_complete`]. A failed fetch fails the scan, to be + /// retried from the same cursor. + pub async fn scan_private_contact_requests( + &self, + start_position: u64, + keys: &PrivateDocumentScanKeys, + ) -> Result { + let dashpay_contract = self.fetch_dashpay_contract().await?; + let scan = self + .scan_private_document_page( + dashpay_contract.id(), + PRIVATE_CONTACT_REQUEST_DOCUMENT_TYPE_NAME, + start_position, + keys, + ) + .await?; + let mut sender_identities = BTreeMap::new(); + for sender_id in private_contact_request_senders(&scan) { + sender_identities.insert(sender_id, Identity::fetch(self, sender_id).await?); + } + private_contact_requests_from_scan( + &dashpay_contract, + scan, + keys, + &sender_identities, + self.version(), + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::platform::private_document_entries::{PrivateDocumentEntries, PrivateDocumentEntry}; + use dpp::data_contract::document_type::accessors::DocumentTypeV0Getters; + use dpp::data_contract::document_type::DocumentPropertyType; + use dpp::identity::identity_public_key::v0::IdentityPublicKeyV0; + use dpp::identity::{IdentityV0, Purpose, SecurityLevel}; + use dpp::system_data_contracts::{load_system_data_contract, SystemDataContract}; + + const SENDER_ID: [u8; 32] = [0xA0; 32]; + const RECIPIENT_ID: [u8; 32] = [0xB0; 32]; + /// The time of the block the test entries are appended in. + const BLOCK_TIME_MS: u64 = 1_700_000_000_000; + + fn dashpay() -> DataContract { + load_system_data_contract(SystemDataContract::Dashpay, PlatformVersion::latest()) + .expect("DashPay loads at the latest version") + } + + fn secret_key(byte: u8) -> SecretKey { + SecretKey::from_secret_bytes([byte; 32]).expect("a valid secret key") + } + + fn key( + id: KeyID, + purpose: Purpose, + key_type: KeyType, + secret_key: &SecretKey, + disabled_at: Option, + ) -> IdentityPublicKey { + IdentityPublicKey::V0(IdentityPublicKeyV0 { + id, + purpose, + security_level: SecurityLevel::MEDIUM, + contract_bounds: None, + key_type, + read_only: false, + data: BinaryData::new(PublicKey::from_secret_key(secret_key).serialize().to_vec()), + disabled_at, + }) + } + + fn identity(id: [u8; 32], keys: Vec) -> Identity { + Identity::V0(IdentityV0 { + id: Identifier::from(id), + public_keys: keys.into_iter().map(|key| (key.id(), key)).collect(), + balance: 0, + revision: 0, + }) + } + + /// The sender, whose encryption key 1 is `secret_key(1)`, disabled at `disabled_at`. + fn sender_identity(disabled_at: Option) -> Identity { + identity( + SENDER_ID, + vec![ + key( + 0, + Purpose::AUTHENTICATION, + KeyType::ECDSA_SECP256K1, + &secret_key(9), + None, + ), + key( + 1, + Purpose::ENCRYPTION, + KeyType::ECDSA_SECP256K1, + &secret_key(1), + disabled_at, + ), + ], + ) + } + + /// The recipient, whose decryption key 2 is `secret_key(2)`, and whose authentication key 0 + /// and BLS encryption key 3 no request may be sealed to. + fn recipient_identity() -> Identity { + identity( + RECIPIENT_ID, + vec![ + key( + 0, + Purpose::AUTHENTICATION, + KeyType::ECDSA_SECP256K1, + &secret_key(8), + None, + ), + key( + 2, + Purpose::DECRYPTION, + KeyType::ECDSA_SECP256K1, + &secret_key(2), + None, + ), + key( + 3, + Purpose::ENCRYPTION, + KeyType::BLS12_381, + &secret_key(7), + None, + ), + key( + 4, + Purpose::ENCRYPTION, + KeyType::ECDSA_SECP256K1, + &secret_key(6), + Some(1), + ), + ], + ) + } + + /// A request filling every property to its bound: the largest payload the type holds. + fn largest_request() -> PrivateContactRequest { + PrivateContactRequest { + account_reference: u32::MAX, + extended_public_key: [0x33; COMPACT_XPUB_LEN], + account_label: Some("l".repeat(PRIVATE_CONTACT_REQUEST_ACCOUNT_LABEL_MAX_BYTES)), + auto_accept_proof: Some(vec![ + 0x44; + PRIVATE_CONTACT_REQUEST_AUTO_ACCEPT_PROOF_MAX_LEN + ]), + shielded_address: Some([0x55; PRIVATE_CONTACT_REQUEST_SHIELDED_ADDRESS_LEN]), + } + } + + fn smallest_request(account_reference: u32) -> PrivateContactRequest { + PrivateContactRequest { + account_reference, + extended_public_key: [0x33; COMPACT_XPUB_LEN], + account_label: None, + auto_accept_proof: None, + shielded_address: None, + } + } + + fn seal( + contract: &DataContract, + sender: &Identity, + request: &PrivateContactRequest, + seed: u8, + ) -> Vec { + seal_private_contact_request( + contract, + &PrivateContactRequestSender { + identity: sender, + encryption_key_id: 1, + encryption_secret_key: &secret_key(1), + }, + &recipient_identity(), + 2, + request, + &[seed; 32], + ) + .expect("the request seals") + .to_vec() + } + + fn page(entries: Vec>) -> PrivateDocumentEntries { + PrivateDocumentEntries { + total_count: entries.len() as u64, + entries: entries + .into_iter() + .zip(0u64..) + .map(|(data, position)| PrivateDocumentEntry { + position, + block_height: 10 + position, + block_time_ms: BLOCK_TIME_MS, + data, + }) + .collect(), + } + } + + /// The recipient's keys, and the sender's outgoing viewing key. + fn scan_keys() -> PrivateDocumentScanKeys { + PrivateDocumentScanKeys::new() + .with_recipient(Identifier::from(RECIPIENT_ID), &[(2, secret_key(2))]) + .with_sender( + Identifier::from(SENDER_ID), + private_contact_request_outgoing_viewing_key(&secret_key(1)), + ) + } + + fn read( + contract: &DataContract, + entries: Vec>, + sender: Option, + ) -> PrivateContactRequestScan { + let keys = scan_keys(); + let target = PrivateDocumentTarget { + contract_id: contract.id().to_buffer(), + document_type_name: PRIVATE_CONTACT_REQUEST_DOCUMENT_TYPE_NAME, + }; + let scan = keys.scan_page(&target, 0, &page(entries)); + let senders = private_contact_request_senders(&scan); + let sender_identities = senders + .into_iter() + .map(|sender_id| (sender_id, sender.clone())) + .collect(); + private_contact_requests_from_scan( + contract, + scan, + &keys, + &sender_identities, + PlatformVersion::latest(), + ) + .expect("DashPay declares private contact requests") + } + + /// The bounds the request checks are DashPay's: the schema's sizes, as dpp parses them. + #[test] + fn should_check_the_bounds_dashpay_declares() { + let contract = dashpay(); + let document_type = private_contact_request_type(&contract).expect("DashPay v3"); + let property_type = |name: &str| { + document_type + .properties() + .get(name) + .unwrap_or_else(|| panic!("{name} is declared")) + .property_type + .clone() + }; + let byte_array_sizes = |name: &str| match property_type(name) { + DocumentPropertyType::ByteArray(sizes) => ( + sizes.min_size.map(usize::from), + sizes.max_size.map(usize::from), + ), + other => panic!("{name} is a byte array, not {other:?}"), + }; + assert_eq!( + byte_array_sizes(EXTENDED_PUBLIC_KEY), + (Some(COMPACT_XPUB_LEN), Some(COMPACT_XPUB_LEN)) + ); + assert_eq!( + byte_array_sizes(AUTO_ACCEPT_PROOF), + ( + Some(PRIVATE_CONTACT_REQUEST_AUTO_ACCEPT_PROOF_MIN_LEN), + Some(PRIVATE_CONTACT_REQUEST_AUTO_ACCEPT_PROOF_MAX_LEN) + ) + ); + assert_eq!( + byte_array_sizes(SHIELDED_ADDRESS), + ( + Some(PRIVATE_CONTACT_REQUEST_SHIELDED_ADDRESS_LEN), + Some(PRIVATE_CONTACT_REQUEST_SHIELDED_ADDRESS_LEN) + ) + ); + match property_type(ACCOUNT_LABEL) { + DocumentPropertyType::String(sizes) => assert_eq!( + sizes.max_bytes.map(usize::from), + Some(PRIVATE_CONTACT_REQUEST_ACCOUNT_LABEL_MAX_BYTES) + ), + other => panic!("{ACCOUNT_LABEL} is a string, not {other:?}"), + } + // The schema's `maximum` is u32::MAX, which the field's type holds. + assert!(property_type(ACCOUNT_REFERENCE).is_integer()); + assert_eq!(document_type.properties().len(), 5); + } + + /// A request reads back from its payload as it was written, the largest one included, and + /// a request without its optional properties reads them as absent. + #[test] + fn should_read_a_request_back_from_its_payload() { + let contract = dashpay(); + let document_type = private_contact_request_type(&contract).expect("DashPay v3"); + for request in [largest_request(), smallest_request(7)] { + let payload = encode_private_document_payload( + document_type, + request.to_properties().expect("in bounds"), + ) + .expect("the payload encodes"); + let properties = + decode_private_document_payload(document_type, &payload, PlatformVersion::latest()) + .expect("the payload decodes"); + assert_eq!( + PrivateContactRequest::from_properties(properties).expect("a request"), + request + ); + } + } + + /// A request out of the schema's bounds is refused when written, and its properties when + /// read, as are properties missing the required ones or holding one the type lacks. + #[test] + fn should_refuse_a_request_out_of_the_schema_bounds() { + let out_of_bounds = [ + PrivateContactRequest { + account_label: Some( + "l".repeat(PRIVATE_CONTACT_REQUEST_ACCOUNT_LABEL_MAX_BYTES + 1), + ), + ..smallest_request(1) + }, + PrivateContactRequest { + auto_accept_proof: Some(vec![ + 0; + PRIVATE_CONTACT_REQUEST_AUTO_ACCEPT_PROOF_MIN_LEN - 1 + ]), + ..smallest_request(1) + }, + PrivateContactRequest { + auto_accept_proof: Some(vec![ + 0; + PRIVATE_CONTACT_REQUEST_AUTO_ACCEPT_PROOF_MAX_LEN + 1 + ]), + ..smallest_request(1) + }, + ]; + for request in &out_of_bounds { + assert!(request.to_properties().is_err(), "{request:?}"); + } + + let properties = || smallest_request(1).to_properties().expect("in bounds"); + let with = |name: &str, value: Value| { + let mut properties = properties(); + properties.insert(name.to_string(), value); + properties + }; + let without = |name: &str| { + let mut properties = properties(); + properties.remove(name); + properties + }; + for (case, properties) in [ + ( + "an account reference below 0", + with(ACCOUNT_REFERENCE, Value::I64(-1)), + ), + ( + "an account reference above u32::MAX", + with(ACCOUNT_REFERENCE, Value::I64(i64::from(u32::MAX) + 1)), + ), + ( + "an extended public key short of 69 bytes", + with( + EXTENDED_PUBLIC_KEY, + Value::Bytes(vec![0; COMPACT_XPUB_LEN - 1]), + ), + ), + ( + "a shielded address of 44 bytes", + with(SHIELDED_ADDRESS, Value::Bytes(vec![0; 44])), + ), + ( + "a label too long", + with(ACCOUNT_LABEL, Value::Text("l".repeat(64))), + ), + ( + "a label of the wrong type", + with(ACCOUNT_LABEL, Value::U32(1)), + ), + ( + "an unknown property", + with("note", Value::Text("hi".to_string())), + ), + ("no account reference", without(ACCOUNT_REFERENCE)), + ("no extended public key", without(EXTENDED_PUBLIC_KEY)), + ] { + assert!( + PrivateContactRequest::from_properties(properties).is_err(), + "{case} is refused" + ); + } + // An absent optional property may read back as null. + assert_eq!( + PrivateContactRequest::from_properties(with(ACCOUNT_LABEL, Value::Null)) + .expect("a request"), + smallest_request(1) + ); + } + + /// The recipient reads a request it received with its sender checked, the sender recovers + /// the one it sent, and the largest request fills the entry exactly. + #[test] + fn should_seal_a_request_its_recipient_reads_and_its_sender_recovers() { + let contract = dashpay(); + let sender = sender_identity(None); + let entry = seal(&contract, &sender, &largest_request(), 1); + assert_eq!(entry.len(), 589); + + let scan = read(&contract, vec![entry], Some(sender)); + assert_eq!(scan.rejected, vec![]); + assert_eq!(scan.received.len(), 1); + let received = &scan.received[0]; + assert_eq!(received.position, 0); + assert_eq!(received.block_height, 10); + assert_eq!(received.block_time_ms, BLOCK_TIME_MS); + assert_eq!(received.recipient_id, Identifier::from(RECIPIENT_ID)); + assert_eq!(received.recipient_key_id, 2); + assert_eq!(received.sender_id, Identifier::from(SENDER_ID)); + assert_eq!(received.sender_key_id, 1); + assert_eq!(received.request, largest_request()); + + assert_eq!(scan.sent.len(), 1); + let sent = &scan.sent[0]; + assert_eq!(sent.sender_id, Identifier::from(SENDER_ID)); + assert_eq!(sent.recipient_id, Identifier::from(RECIPIENT_ID)); + assert_eq!( + sent.recipient_public_key, + PublicKey::from_secret_key(&secret_key(2)) + ); + assert_eq!(sent.commitment, received.commitment); + assert_eq!(sent.request, largest_request()); + assert!(scan.is_complete()); + } + + /// A received request is refused when its sender does not exist, when the key it names was + /// disabled before it was appended, or when the identity's key under that id did not write + /// it; a key disabled after it was appended still wrote it. + #[test] + fn should_refuse_a_request_whose_sender_does_not_check() { + let contract = dashpay(); + let entry = seal(&contract, &sender_identity(None), &smallest_request(1), 1); + let reasons = |sender: Option| { + read(&contract, vec![entry.clone()], sender) + .rejected + .into_iter() + .map(|rejected| rejected.reason) + .collect::>() + }; + + assert_eq!( + reasons(None), + vec![PrivateContactRequestRejection::SenderNotFound] + ); + assert!(matches!( + reasons(Some(sender_identity(Some(BLOCK_TIME_MS)))).as_slice(), + [PrivateContactRequestRejection::SenderKeyNotUsable(_)] + )); + assert_eq!( + reasons(Some(sender_identity(Some(BLOCK_TIME_MS + 1)))), + vec![] + ); + // The identity's key 1 is another key than the one that wrote the MAC. + let impostor = identity( + SENDER_ID, + vec![key( + 1, + Purpose::ENCRYPTION, + KeyType::ECDSA_SECP256K1, + &secret_key(5), + None, + )], + ); + assert_eq!( + reasons(Some(impostor)), + vec![PrivateContactRequestRejection::SenderNotAuthenticated] + ); + // Key 1 is not an encryption key. + let wrong_purpose = identity( + SENDER_ID, + vec![key( + 1, + Purpose::AUTHENTICATION, + KeyType::ECDSA_SECP256K1, + &secret_key(1), + None, + )], + ); + assert!(matches!( + reasons(Some(wrong_purpose)).as_slice(), + [PrivateContactRequestRejection::SenderKeyNotUsable(_)] + )); + } + + /// An anonymous entry is no contact request, and a second request from the same sender for + /// the same account reference is a duplicate of the first, which is kept. + #[test] + fn should_refuse_an_anonymous_entry_and_a_duplicate_request() { + let contract = dashpay(); + let sender = sender_identity(None); + let document_type = private_contact_request_type(&contract).expect("DashPay v3"); + let anonymous = seal_private_document_entry( + &PrivateDocumentTarget { + contract_id: contract.id().to_buffer(), + document_type_name: PRIVATE_CONTACT_REQUEST_DOCUMENT_TYPE_NAME, + }, + 589, + &PrivateDocumentRecipient { + identity_id: RECIPIENT_ID, + public_key: &PublicKey::from_secret_key(&secret_key(2)), + }, + &PrivateDocumentSender::Anonymous, + &encode_private_document_payload( + document_type, + smallest_request(1).to_properties().expect("in bounds"), + ) + .expect("the payload encodes"), + &[0; 32], + &[9; 32], + ) + .expect("the payload fits"); + + let scan = read( + &contract, + vec![ + seal(&contract, &sender, &smallest_request(1), 1), + anonymous, + seal(&contract, &sender, &smallest_request(1), 2), + seal(&contract, &sender, &smallest_request(2), 3), + ], + Some(sender), + ); + assert_eq!( + scan.received + .iter() + .map(|received| (received.position, received.request.account_reference)) + .collect::>(), + vec![(0, 1), (3, 2)] + ); + assert_eq!( + scan.rejected, + vec![ + RejectedPrivateContactRequest { + position: 1, + block_height: 11, + reason: PrivateContactRequestRejection::AnonymousSender, + }, + RejectedPrivateContactRequest { + position: 2, + block_height: 12, + reason: PrivateContactRequestRejection::Duplicate, + }, + ] + ); + } + + /// A request is sealed only from the sender's enabled ECDSA encryption key with its own + /// secret key, and only to an enabled ECDSA decryption or encryption key of the recipient. + #[test] + fn should_refuse_to_seal_with_keys_dashpay_does_not_use() { + let contract = dashpay(); + let sender = sender_identity(None); + let attempt = |encryption_key_id: KeyID, secret: u8, recipient_key_id: KeyID| { + seal_private_contact_request( + &contract, + &PrivateContactRequestSender { + identity: &sender, + encryption_key_id, + encryption_secret_key: &secret_key(secret), + }, + &recipient_identity(), + recipient_key_id, + &smallest_request(1), + &[1; 32], + ) + }; + attempt(1, 1, 2).expect("the sender's encryption key to the recipient's decryption key"); + for (case, encryption_key_id, secret, recipient_key_id) in [ + ("the sender's authentication key", 0, 9, 2), + ("another secret key than the sender key's", 1, 5, 2), + ("a sender key the identity lacks", 7, 1, 2), + ("the recipient's authentication key", 1, 1, 0), + ("the recipient's BLS key", 1, 1, 3), + ("the recipient's disabled key", 1, 1, 4), + ("a recipient key the identity lacks", 1, 1, 9), + ] { + assert!( + attempt(encryption_key_id, secret, recipient_key_id).is_err(), + "{case}" + ); + } + let disabled = sender_identity(Some(1)); + assert!(seal_private_contact_request( + &contract, + &PrivateContactRequestSender { + identity: &disabled, + encryption_key_id: 1, + encryption_secret_key: &secret_key(1), + }, + &recipient_identity(), + 2, + &smallest_request(1), + &[1; 32], + ) + .is_err()); + } +} diff --git a/packages/rs-sdk/src/platform/private_document_scan.rs b/packages/rs-sdk/src/platform/private_document_scan.rs new file mode 100644 index 00000000000..9eca524c7ff --- /dev/null +++ b/packages/rs-sdk/src/platform/private_document_scan.rs @@ -0,0 +1,548 @@ +//! Scanning a private document type's store for the entries a wallet received or sent. +//! +//! An entry names nobody in the clear, so a wallet finds its own by trying every entry: with +//! the secret keys its identities receive at, and with the outgoing viewing keys its identities +//! send with, which recover what the wallet sent after a restore from seed. +//! [`PrivateDocumentScanKeys`] prepares both once, and [`Sdk::scan_private_document_page`] +//! fetches one proved page of the store from a cursor and sorts its entries. +//! +//! A scan only opens entries. What an entry claims about its sender is checked against the +//! sender identity's key with [`PrivateDocumentScanKeys::verify_sender`], and the payload is the +//! type's to decode and validate. `crate::platform::dashpay` does both for DashPay's private +//! contact requests. + +use crate::platform::private_document_entries::{ + PrivateDocumentEntries, PrivateDocumentEntriesQuery, PrivateDocumentEntry, +}; +use crate::platform::{Fetch, Identifier}; +use crate::{Error, Sdk}; +use dpp::identity::KeyID; +use dpp::secp256k1::{PublicKey, SecretKey}; +use platform_encryption::{ + recover_sent_private_document_entry, OpenedPrivateDocumentEntry, PrivateDocumentEnvelopeError, + PrivateDocumentRecipientKeys, PrivateDocumentTarget, +}; +use zeroize::Zeroize; + +/// One identity of the wallet and the keys entries to it may be encrypted to. +struct ScanRecipient { + identity_id: Identifier, + key_ids: Vec, + keys: PrivateDocumentRecipientKeys, +} + +/// One identity of the wallet and the outgoing viewing key it seals entries with. +struct ScanSender { + identity_id: Identifier, + outgoing_viewing_key: [u8; 32], +} + +impl Drop for ScanSender { + fn drop(&mut self) { + self.outgoing_viewing_key.zeroize(); + } +} + +/// The keys a wallet scans a private document type's store with: for each of its identities, +/// the secret keys entries to it may be encrypted to, and the outgoing viewing key it sealed its +/// own entries with. +#[derive(Default)] +pub struct PrivateDocumentScanKeys { + recipients: Vec, + senders: Vec, +} + +/// An entry to one of the wallet's identities. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ReceivedPrivateDocumentEntry { + /// The entry's position in the store. + pub position: u64, + /// The height of the block that appended it. + pub block_height: u64, + /// The time of the block that appended it, in milliseconds. + pub block_time_ms: u64, + /// The identity it is to. + pub recipient_identity_id: Identifier, + /// The key of that identity it is encrypted to. + pub recipient_key_id: KeyID, + /// The opened entry. Its sender is what it claims, until + /// [`PrivateDocumentScanKeys::verify_sender`] checks it. + pub entry: OpenedPrivateDocumentEntry, +} + +/// An entry one of the wallet's identities sealed, recovered with its outgoing viewing key. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct SentPrivateDocumentEntry { + /// The entry's position in the store. + pub position: u64, + /// The height of the block that appended it. + pub block_height: u64, + /// The time of the block that appended it, in milliseconds. + pub block_time_ms: u64, + /// The identity whose outgoing viewing key recovered it. + pub sender_identity_id: Identifier, + /// The recovered entry, with its recipient and payload. + pub entry: OpenedPrivateDocumentEntry, +} + +/// An entry that decrypted under one of the wallet's keys but is refused: malformed, of an +/// envelope version this SDK does not read, or naming an identity other than the key's. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RejectedPrivateDocumentEntry { + /// The entry's position in the store. + pub position: u64, + /// The height of the block that appended it. + pub block_height: u64, + /// Why it is refused. + pub error: PrivateDocumentEnvelopeError, +} + +/// What one page of a private document type's store holds for the wallet. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct PrivateDocumentScan { + /// The entries to the wallet's identities, in store order. + pub received: Vec, + /// The entries the wallet's identities sealed, in store order. + pub sent: Vec, + /// The entries refused, in store order. + pub rejected: Vec, + /// The position to scan from next: the one after the page's last entry, or the page's + /// start when the page is empty. + pub next_position: u64, + /// How many entries the store held when the page was read. + pub total_count: u64, +} + +impl PrivateDocumentScan { + /// Whether the scan reached the store's last entry. A later scan from + /// [`Self::next_position`] reads the entries appended since. + pub fn is_complete(&self) -> bool { + self.next_position >= self.total_count + } +} + +impl PrivateDocumentScanKeys { + /// No keys yet: add the wallet's identities with [`Self::with_recipient`] and + /// [`Self::with_sender`]. + pub fn new() -> Self { + Self::default() + } + + /// Adds `identity_id`, which entries may be encrypted to under any of `keys`, each the + /// identity's key id with its secret key. Entries to it are tried with the keys in this + /// order. + pub fn with_recipient(mut self, identity_id: Identifier, keys: &[(KeyID, SecretKey)]) -> Self { + let secret_keys: Vec = keys.iter().map(|(_, secret_key)| *secret_key).collect(); + self.recipients.push(ScanRecipient { + identity_id, + key_ids: keys.iter().map(|(key_id, _)| *key_id).collect(), + keys: PrivateDocumentRecipientKeys::new(&secret_keys), + }); + self + } + + /// Adds `identity_id` as a sender, which sealed its entries with `outgoing_viewing_key` + /// (`platform_encryption::private_document_outgoing_viewing_key`). + pub fn with_sender(mut self, identity_id: Identifier, outgoing_viewing_key: [u8; 32]) -> Self { + self.senders.push(ScanSender { + identity_id, + outgoing_viewing_key, + }); + self + } + + /// Sorts the entries of `page`, read from `start_position` of the store of `target`. + pub fn scan_page( + &self, + target: &PrivateDocumentTarget, + start_position: u64, + page: &PrivateDocumentEntries, + ) -> PrivateDocumentScan { + let mut scan = PrivateDocumentScan { + next_position: page + .entries + .last() + .map_or(start_position, |entry| entry.position.saturating_add(1)), + total_count: page.total_count, + ..Default::default() + }; + for entry in &page.entries { + self.scan_entry(target, entry, &mut scan); + } + scan + } + + /// Checks that the key a received entry names wrote it, as + /// [`OpenedPrivateDocumentEntry::verify_sender`] does with the recipient key that opened + /// it. `sender_public_key` is the key the sender identity holds under the entry's + /// `sender_key_id`. An entry these keys did not open is refused with + /// `RecipientSecretKeyRequired`. + pub fn verify_sender( + &self, + received: &ReceivedPrivateDocumentEntry, + sender_public_key: &PublicKey, + ) -> Result<(), PrivateDocumentEnvelopeError> { + self.recipients + .iter() + .find(|recipient| recipient.identity_id == received.recipient_identity_id) + .ok_or(PrivateDocumentEnvelopeError::RecipientSecretKeyRequired)? + .keys + .verify_sender(&received.entry, sender_public_key) + } + + fn scan_entry( + &self, + target: &PrivateDocumentTarget, + entry: &PrivateDocumentEntry, + scan: &mut PrivateDocumentScan, + ) { + let reject = |error| RejectedPrivateDocumentEntry { + position: entry.position, + block_height: entry.block_height, + error, + }; + + // An entry naming another identity than the key's is refused only when no other of the + // wallet's identities opens it: two of them may share a key. + let mut refusal = None; + let mut opened_for = None; + for recipient in &self.recipients { + match recipient + .keys + .open(&entry.data, target, recipient.identity_id.as_bytes()) + { + Ok(Some(opened)) => { + opened_for = Some((recipient, opened)); + break; + } + Ok(None) => {} + // A store's entries all take its entry size: one too short for the envelope + // holds nothing any key opens. + Err(PrivateDocumentEnvelopeError::InvalidEntryLength(_)) => break, + Err(error) => { + refusal.get_or_insert(error); + } + } + } + let received = opened_for.and_then(|(recipient, opened)| { + let recipient_key_id = opened + .recipient_key_index() + .and_then(|index| recipient.key_ids.get(index)) + .copied()?; + Some(ReceivedPrivateDocumentEntry { + position: entry.position, + block_height: entry.block_height, + block_time_ms: entry.block_time_ms, + recipient_identity_id: recipient.identity_id, + recipient_key_id, + entry: opened, + }) + }); + let mut rejected = false; + match (received, refusal) { + (Some(received), _) => scan.received.push(received), + (None, Some(error)) => { + scan.rejected.push(reject(error)); + rejected = true; + } + (None, None) => {} + } + + for sender in &self.senders { + match recover_sent_private_document_entry( + &entry.data, + target, + &sender.outgoing_viewing_key, + ) { + Ok(Some(recovered)) => { + scan.sent.push(SentPrivateDocumentEntry { + position: entry.position, + block_height: entry.block_height, + block_time_ms: entry.block_time_ms, + sender_identity_id: sender.identity_id, + entry: recovered, + }); + break; + } + Ok(None) => {} + Err(PrivateDocumentEnvelopeError::InvalidEntryLength(_)) => break, + // An entry is listed once, however many of the wallet's keys refuse it. + Err(error) => { + if !rejected { + scan.rejected.push(reject(error)); + } + break; + } + } + } + } +} + +impl Sdk { + /// Fetches the proved page of the store of the private type `document_type_name` of + /// `data_contract_id` from `start_position`, and sorts its entries with `keys`. + /// + /// A wallet keeps [`PrivateDocumentScan::next_position`] as its cursor once it has stored + /// what the page held, and scans from it again until [`PrivateDocumentScan::is_complete`]; + /// the store only grows, so a later scan from the cursor reads what was appended since. + pub async fn scan_private_document_page( + &self, + data_contract_id: Identifier, + document_type_name: &str, + start_position: u64, + keys: &PrivateDocumentScanKeys, + ) -> Result { + let query = + PrivateDocumentEntriesQuery::new(data_contract_id, document_type_name, start_position); + let page = PrivateDocumentEntries::fetch(self, query) + .await? + .ok_or_else(|| { + Error::Generic(format!( + "no page of the private document type \"{document_type_name}\" of contract \ + {data_contract_id} from position {start_position}" + )) + })?; + let target = PrivateDocumentTarget { + contract_id: data_contract_id.to_buffer(), + document_type_name, + }; + Ok(keys.scan_page(&target, start_position, &page)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use platform_encryption::{ + private_document_outgoing_viewing_key, seal_private_document_entry, + PrivateDocumentRecipient, PrivateDocumentSender, + }; + + const ENTRY_SIZE: usize = 589; + const CONTRACT_ID: [u8; 32] = [7; 32]; + const TYPE_NAME: &str = "privateContactRequest"; + + fn target() -> PrivateDocumentTarget<'static> { + PrivateDocumentTarget { + contract_id: CONTRACT_ID, + document_type_name: TYPE_NAME, + } + } + + fn secret_key(byte: u8) -> SecretKey { + SecretKey::from_secret_bytes([byte; 32]).expect("a valid secret key") + } + + fn public_key(byte: u8) -> PublicKey { + PublicKey::from_secret_key(&secret_key(byte)) + } + + fn ovk(byte: u8) -> [u8; 32] { + private_document_outgoing_viewing_key(&[byte; 32]) + } + + /// An entry from `sender_byte`'s identity and key 4 to `recipient` under the key of + /// `recipient_key`, sealed with the outgoing viewing key of `ovk_byte`. + fn seal( + sender_byte: u8, + recipient: [u8; 32], + recipient_key: u8, + ovk_byte: u8, + seed: u8, + ) -> Vec { + let sender_key = secret_key(sender_byte); + seal_private_document_entry( + &target(), + ENTRY_SIZE, + &PrivateDocumentRecipient { + identity_id: recipient, + public_key: &public_key(recipient_key), + }, + &PrivateDocumentSender::DeniableMac { + identity_id: [sender_byte; 32], + key_id: 4, + secret_key: &sender_key, + }, + &[seed; 40], + &ovk(ovk_byte), + &[seed; 32], + ) + .expect("the payload fits") + } + + fn page(start: u64, entries: Vec>, total_count: u64) -> PrivateDocumentEntries { + PrivateDocumentEntries { + entries: entries + .into_iter() + .zip(start..) + .map(|(data, position)| PrivateDocumentEntry { + position, + block_height: 100 + position, + block_time_ms: 1_000 * position, + data, + }) + .collect(), + total_count, + } + } + + /// A page sorts into what the wallet's identities received, what they sent, and what opened + /// for one of their keys but is refused; the rest, for other wallets, is passed over. + #[test] + fn should_sort_a_page_into_received_sent_and_rejected_entries() { + let alice = Identifier::from([0xA0; 32]); + let bob = Identifier::from([0xB0; 32]); + let keys = PrivateDocumentScanKeys::new() + .with_recipient(alice, &[(2, secret_key(20)), (3, secret_key(21))]) + .with_recipient(bob, &[(1, secret_key(30))]) + .with_sender(alice, ovk(0xA1)); + let entries = vec![ + // 10: to Alice's key 3, from Carol + seal(0xC0, alice.to_buffer(), 21, 0xC1, 1), + // 11: to someone else, from someone else + seal(0xC0, [0xD0; 32], 40, 0xC1, 2), + // 12: from Alice to Dave + seal(0xA0, [0xD0; 32], 40, 0xA1, 3), + // 13: to Bob's key, but naming Dave: a replay + seal(0xC0, [0xD0; 32], 30, 0xC1, 4), + // 14: from Alice to Bob, both the wallet's + seal(0xA0, bob.to_buffer(), 30, 0xA1, 5), + ]; + let scan = keys.scan_page(&target(), 10, &page(10, entries, 20)); + + assert_eq!( + scan.received + .iter() + .map(|received| ( + received.position, + received.recipient_identity_id, + received.recipient_key_id + )) + .collect::>(), + vec![(10, alice, 3), (14, bob, 1)] + ); + assert_eq!(scan.received[0].block_height, 110); + assert_eq!(scan.received[0].block_time_ms, 10_000); + assert_eq!(scan.received[0].entry.payload(), &[1; 40]); + assert_eq!( + scan.sent + .iter() + .map(|sent| (sent.position, sent.sender_identity_id)) + .collect::>(), + vec![(12, alice), (14, alice)] + ); + assert_eq!(scan.sent[0].entry.recipient_identity_id(), &[0xD0; 32]); + assert_eq!( + scan.rejected, + vec![RejectedPrivateDocumentEntry { + position: 13, + block_height: 113, + error: PrivateDocumentEnvelopeError::WrongRecipient, + }] + ); + assert_eq!(scan.next_position, 15); + assert!(!scan.is_complete()); + } + + /// An entry naming one of the wallet's identities opens for it even when another identity + /// of the wallet holding the same key comes first. + #[test] + fn should_open_an_entry_for_whichever_identity_it_names_when_two_share_a_key() { + let alice = Identifier::from([0xA0; 32]); + let bob = Identifier::from([0xB0; 32]); + let keys = PrivateDocumentScanKeys::new() + .with_recipient(alice, &[(2, secret_key(20))]) + .with_recipient(bob, &[(5, secret_key(20))]); + let scan = keys.scan_page( + &target(), + 0, + &page(0, vec![seal(0xC0, bob.to_buffer(), 20, 0xC1, 1)], 1), + ); + assert_eq!(scan.rejected, vec![]); + assert_eq!(scan.received.len(), 1); + assert_eq!(scan.received[0].recipient_identity_id, bob); + assert_eq!(scan.received[0].recipient_key_id, 5); + assert!(scan.is_complete()); + } + + /// The scan keys check a received entry's sender with the key that opened it. + #[test] + fn should_verify_a_received_entry_s_sender_with_the_key_that_opened_it() { + let alice = Identifier::from([0xA0; 32]); + let keys = PrivateDocumentScanKeys::new().with_recipient(alice, &[(2, secret_key(20))]); + let scan = keys.scan_page( + &target(), + 0, + &page(0, vec![seal(0xC0, alice.to_buffer(), 20, 0xC1, 1)], 1), + ); + let received = &scan.received[0]; + assert_eq!(received.entry.sender_identity_id(), &[0xC0; 32]); + assert_eq!(received.entry.sender_key_id(), 4); + keys.verify_sender(received, &public_key(0xC0)) + .expect("Carol's key wrote it"); + assert_eq!( + keys.verify_sender(received, &public_key(0xC1)), + Err(PrivateDocumentEnvelopeError::SenderNotAuthenticated) + ); + let others = PrivateDocumentScanKeys::new() + .with_recipient(Identifier::from([0xB0; 32]), &[(2, secret_key(20))]); + assert_eq!( + others.verify_sender(received, &public_key(0xC0)), + Err(PrivateDocumentEnvelopeError::RecipientSecretKeyRequired) + ); + } + + /// A page at the store's end is empty: the cursor stays where it is. + #[test] + fn should_keep_the_cursor_on_an_empty_page_at_the_store_s_end() { + let keys = PrivateDocumentScanKeys::new() + .with_recipient(Identifier::from([0xA0; 32]), &[(2, secret_key(20))]); + let scan = keys.scan_page(&target(), 7, &page(7, vec![], 7)); + assert_eq!( + scan, + PrivateDocumentScan { + next_position: 7, + total_count: 7, + ..Default::default() + } + ); + assert!(scan.is_complete()); + } + + #[cfg(feature = "mocks")] + #[tokio::test] + async fn should_scan_a_fetched_page_of_the_store() { + use crate::SdkBuilder; + use dpp::version::PlatformVersion; + + let mut sdk: Sdk = SdkBuilder::new_mock() + .with_version(PlatformVersion::latest()) + .build() + .expect("expected a mock sdk"); + let alice = Identifier::from([0xA0; 32]); + let contract_id = Identifier::from(CONTRACT_ID); + let query = PrivateDocumentEntriesQuery::new(contract_id, TYPE_NAME, 3); + let fetched = page( + 3, + vec![ + seal(0xC0, [0xD0; 32], 40, 0xC1, 1), + seal(0xC0, alice.to_buffer(), 20, 0xC1, 2), + ], + 5, + ); + sdk.mock() + .expect_fetch(query, Some(fetched)) + .await + .expect("expected to register the page"); + + let keys = PrivateDocumentScanKeys::new().with_recipient(alice, &[(2, secret_key(20))]); + let scan = sdk + .scan_private_document_page(contract_id, TYPE_NAME, 3, &keys) + .await + .expect("expected the page"); + assert_eq!(scan.received.len(), 1); + assert_eq!(scan.received[0].position, 4); + assert_eq!(scan.next_position, 5); + assert!(scan.is_complete()); + } +} diff --git a/packages/rs-sdk/src/platform/transition.rs b/packages/rs-sdk/src/platform/transition.rs index 3a99af47952..2eef48881fb 100644 --- a/packages/rs-sdk/src/platform/transition.rs +++ b/packages/rs-sdk/src/platform/transition.rs @@ -16,6 +16,8 @@ pub mod identity_create_from_shielded_pool; /// Shielded pool to an existing identity's balance. pub mod identity_top_up_from_shielded_pool; pub mod masternode_vote_keys; +/// Shielded pool to entries of a private document type's store. +pub mod private_document_create_from_shielded_pool; pub mod purchase_document; pub mod put_contract; pub mod put_document; diff --git a/packages/rs-sdk/src/platform/transition/private_document_create_from_shielded_pool.rs b/packages/rs-sdk/src/platform/transition/private_document_create_from_shielded_pool.rs new file mode 100644 index 00000000000..665a52f46ee --- /dev/null +++ b/packages/rs-sdk/src/platform/transition/private_document_create_from_shielded_pool.rs @@ -0,0 +1,157 @@ +//! Append entries to a private document type's store, paid from the shielded pool. + +use super::broadcast::BroadcastStateTransition; +use super::put_settings::PutSettings; +use super::validation::ensure_valid_state_transition_structure; +use crate::{Error, Sdk}; +use dpp::platform_value::{BinaryData, Identifier}; +use dpp::shielded::OrchardBundleParams; +use dpp::state_transition::private_document_create_from_shielded_pool_transition::methods::PrivateDocumentCreateFromShieldedPoolTransitionMethodsV0; +use dpp::state_transition::private_document_create_from_shielded_pool_transition::PrivateDocumentCreateFromShieldedPoolTransition; +use dpp::state_transition::proof_result::StateTransitionProofResult; +use dpp::state_transition::StateTransition; + +/// Append sealed entries to the store of a private document type (type 29), paying the fee +/// from shielded notes. The transition names no identity: the bundle's spends pay, and its +/// binding signature commits to the contract, the type name and every entry, so a node cannot +/// move the payment to other entries. +/// +/// The bundle must be an already proven Orchard spend whose value balance is `fee` (see +/// `dpp::shielded::builder::build_private_document_create_from_shielded_pool_transition`, which +/// builds the whole transition from notes). Every entry must be exactly the type's +/// `entrySize`, as `platform_encryption::seal_private_document_entry` builds them. +#[async_trait::async_trait] +pub trait PrivateDocumentCreateFromShieldedPool { + /// Build and structure-check the transition without broadcasting it. + #[allow(clippy::too_many_arguments)] + fn private_document_create_from_shielded_pool_transition( + &self, + data_contract_id: Identifier, + document_type_name: String, + entries: Vec, + fee: u64, + bundle: OrchardBundleParams, + ) -> Result; + + /// Build, broadcast, and wait for proven execution. Returns the `VerifiedShieldedNullifiers` + /// proof result (the spent nullifiers), so a wallet marks its notes spent only once the + /// entries are proven appended. + #[allow(clippy::too_many_arguments)] + async fn private_document_create_from_shielded_pool( + &self, + data_contract_id: Identifier, + document_type_name: String, + entries: Vec, + fee: u64, + bundle: OrchardBundleParams, + settings: Option, + ) -> Result; +} + +#[async_trait::async_trait] +impl PrivateDocumentCreateFromShieldedPool for Sdk { + fn private_document_create_from_shielded_pool_transition( + &self, + data_contract_id: Identifier, + document_type_name: String, + entries: Vec, + fee: u64, + bundle: OrchardBundleParams, + ) -> Result { + let OrchardBundleParams { + actions, + anchor, + proof, + binding_signature, + } = bundle; + + let state_transition = PrivateDocumentCreateFromShieldedPoolTransition::try_from_bundle( + data_contract_id, + document_type_name, + entries, + actions, + fee, + anchor, + proof, + binding_signature, + self.version(), + )?; + ensure_valid_state_transition_structure(&state_transition, self.version())?; + + Ok(state_transition) + } + + async fn private_document_create_from_shielded_pool( + &self, + data_contract_id: Identifier, + document_type_name: String, + entries: Vec, + fee: u64, + bundle: OrchardBundleParams, + settings: Option, + ) -> Result { + let state_transition = self.private_document_create_from_shielded_pool_transition( + data_contract_id, + document_type_name, + entries, + fee, + bundle, + )?; + let proof_result = state_transition + .broadcast_and_wait_for_affected_state::(self, settings) + .await?; + Ok(proof_result) + } +} + +#[cfg(all(test, feature = "mocks"))] +mod tests { + use super::*; + use crate::SdkBuilder; + use dpp::shielded::SerializedAction; + use dpp::version::PlatformVersion; + + fn bundle() -> OrchardBundleParams { + let action = SerializedAction { + nullifier: [1; 32], + rk: [2; 32], + cmx: [3; 32], + encrypted_note: vec![4; 216], + cv_net: [5; 32], + spend_auth_sig: [6; 64], + }; + OrchardBundleParams { + actions: vec![action.clone(), action], + anchor: [7; 32], + proof: vec![8; 64], + binding_signature: [9; 64], + } + } + + /// Entries consensus refuses, here none or two the same, are refused before anything is + /// broadcast. + #[test] + fn should_refuse_entries_consensus_refuses_before_broadcasting() { + let sdk: Sdk = SdkBuilder::new_mock() + .with_version(PlatformVersion::latest()) + .build() + .expect("expected a mock sdk"); + for (entries, refusal) in [ + (vec![], "carries 0 entries"), + ( + vec![BinaryData::new(vec![1; 589]), BinaryData::new(vec![1; 589])], + "is the same as entry 0", + ), + ] { + let result = sdk.private_document_create_from_shielded_pool_transition( + Identifier::new([4; 32]), + "privateContactRequest".to_string(), + entries, + 114_140_000, + bundle(), + ); + let error = result.expect_err("the entries are refused").to_string(); + assert!(error.contains(refusal), "{refusal}: {error}"); + } + } +} From a6ca844bb5b69f82d6902699f9c62ab251266e33 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Sun, 8 Nov 2026 11:12:38 +0700 Subject: [PATCH 003/117] feat(sdk): name the sender key of a recovered private contact request A wallet restoring its sent requests needs the encryption key each was sealed with to rebuild its local record of the request. Co-Authored-By: Claude Opus 5.5 --- .../rs-sdk/src/platform/dashpay/private_contact_request.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/rs-sdk/src/platform/dashpay/private_contact_request.rs b/packages/rs-sdk/src/platform/dashpay/private_contact_request.rs index 4fb4d4aa3eb..e9584e46b4a 100644 --- a/packages/rs-sdk/src/platform/dashpay/private_contact_request.rs +++ b/packages/rs-sdk/src/platform/dashpay/private_contact_request.rs @@ -387,6 +387,8 @@ pub struct SentPrivateContactRequest { pub block_time_ms: u64, /// The wallet's identity that sent it. pub sender_id: Identifier, + /// The sender's encryption key it names. + pub sender_key_id: KeyID, /// The identity it is to. pub recipient_id: Identifier, /// The recipient key it is encrypted to. @@ -541,6 +543,7 @@ pub fn private_contact_requests_from_scan( block_height: sent.block_height, block_time_ms: sent.block_time_ms, sender_id: sent.sender_identity_id, + sender_key_id: sent.entry.sender_key_id(), recipient_id: Identifier::from(*sent.entry.recipient_identity_id()), recipient_public_key: *sent.entry.recipient_public_key(), commitment: *sent.entry.commitment(), @@ -1073,6 +1076,7 @@ mod tests { assert_eq!(scan.sent.len(), 1); let sent = &scan.sent[0]; assert_eq!(sent.sender_id, Identifier::from(SENDER_ID)); + assert_eq!(sent.sender_key_id, 1); assert_eq!(sent.recipient_id, Identifier::from(RECIPIENT_ID)); assert_eq!( sent.recipient_public_key, From 74c0d7de6c30a4b9064a7d70de1edc9b5364d064 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Sun, 8 Nov 2026 11:35:55 +0700 Subject: [PATCH 004/117] feat(platform-wallet): send, read and accept DashPay private contact requests The wallet sends, reads and accepts DashPay private contact requests (protocol version 15), paying for each from the shielded pool: - `PlatformWallet::send_private_contact_request` builds the request a `contactRequest` document would carry (receiving xpub, masked account reference, label, auto-accept proof) plus a shielded address, has the crypto provider seal it, and appends it with a type 29 transition paid from an account's notes (new `ShieldedFeeKind::PrivateDocumentCreate`, reserved for the fee alone). - `DashPayView::sync_private_contact_requests` reads DashPay's private store from each identity's cursor: received requests with their senders checked, and sent ones recovered from the store alone. - `accept_private_contact_request` replies privately. The public accept refuses a request that came privately (`PrivateContactRequestNeedsPrivateReply`), as a document reply would publish the relationship. A private request is recorded as a `ContactRequest`, its xpub and label encrypted under the pair's DIP-15 shared key with IVs from the entry's commitment, so contacts establish, rotate and get their accounts as they do from documents, and nothing persisted changes shape. Whether a request came privately and the contact's shielded address are kept in memory (`PrivateContact`), with the store cursor, and rebuilt by the scan a cold start runs. Requests that came privately are not auto-accepted. `ContactCryptoProvider` gains `scan_private_document_page`, `read_private_contact_requests` and `seal_private_contact_request`, all defaulted to unsupported, so the keys stay in the signer. The document send and accept share their preparation and accept steps with the private ones, and the document sync's stale-account teardown moved into `tear_down_stale_external_accounts`, which both syncs call. Co-Authored-By: Claude Opus 5.5 --- packages/rs-platform-wallet/src/error.rs | 6 + packages/rs-platform-wallet/src/lib.rs | 8 +- .../src/wallet/identity/mod.rs | 3 +- .../identity/network/contact_requests.rs | 674 ++++++--- .../src/wallet/identity/network/mod.rs | 2 + .../network/private_contact_requests.rs | 1206 +++++++++++++++++ .../managed_identity/contact_requests.rs | 37 + .../state/managed_identity/dashpay.rs | 42 + .../identity/state/managed_identity/mod.rs | 2 +- .../src/wallet/identity/state/mod.rs | 2 +- .../src/wallet/platform_wallet.rs | 112 ++ .../src/wallet/shielded/note_selection.rs | 53 +- .../src/wallet/shielded/operations.rs | 188 ++- 13 files changed, 2118 insertions(+), 217 deletions(-) create mode 100644 packages/rs-platform-wallet/src/wallet/identity/network/private_contact_requests.rs diff --git a/packages/rs-platform-wallet/src/error.rs b/packages/rs-platform-wallet/src/error.rs index 322530e770b..5ed4e79f788 100644 --- a/packages/rs-platform-wallet/src/error.rs +++ b/packages/rs-platform-wallet/src/error.rs @@ -655,6 +655,12 @@ pub enum PlatformWalletError { #[error("SDK error: {0}")] Sdk(#[from] dash_sdk::Error), + /// The contact request from this sender came privately (protocol + /// version 15): a `contactRequest` document in reply would publish the + /// relationship, so it is accepted with a private reply. + #[error("the contact request from {0} came privately; accept it with a private reply")] + PrivateContactRequestNeedsPrivateReply(Identifier), + /// No DPNS `domain` document exists for the requested name (exact /// normalized-label lookup came back empty). Distinct from /// [`Self::InvalidParameter`]: the input was well-formed, the name diff --git a/packages/rs-platform-wallet/src/lib.rs b/packages/rs-platform-wallet/src/lib.rs index 0f4f53b2e4f..82b91876319 100644 --- a/packages/rs-platform-wallet/src/lib.rs +++ b/packages/rs-platform-wallet/src/lib.rs @@ -77,16 +77,16 @@ pub use wallet::core_address_key::{CoreAddressKeyError, CoreAddressPrivateKey}; pub use wallet::identity::network::{ derive_identity_auth_keypair, fetch_identity_public_keys, AutoAcceptProofSource, ContactCryptoProvider, ContactInfoOpened, ContactInfoPublishOutcome, ContactInfoSealed, - FetchedIdentityPublicKeys, PreparedDashPayPayment, SeedBindingVerification, IDENTITY_GAP_LIMIT, - MASTER_KEY_INDEX, + FetchedIdentityPublicKeys, PreparedDashPayPayment, PrivateContactSyncReport, + PrivateDocumentKeyPaths, SeedBindingVerification, IDENTITY_GAP_LIMIT, MASTER_KEY_INDEX, }; pub use wallet::identity::{ calculate_account_reference, derive_auto_accept_private_key, derive_contact_payment_address, derive_contact_payment_addresses, derive_contact_xpub, pubkey_binds_expected_key_data, unmask_account_reference, BlockTime, ContactProfileEntry, ContactRequest, ContactXpubData, DashPayProfile, DashPayState, DpnsFetch, DpnsNameInfo, EstablishedContact, IdentityLocation, - IdentityManager, IdentityStatus, KeyStorage, ManagedIdentity, PrivateKeyData, ProfileUpdate, - RegistrationIndex, DEFAULT_CONTACT_GAP_LIMIT, + IdentityManager, IdentityStatus, KeyStorage, ManagedIdentity, PrivateContact, PrivateKeyData, + ProfileUpdate, RegistrationIndex, DEFAULT_CONTACT_GAP_LIMIT, }; pub use wallet::masternode_withdrawal::{ MasternodeWithdrawalKey, MasternodeWithdrawalKeys, MasternodeWithdrawalRequest, diff --git a/packages/rs-platform-wallet/src/wallet/identity/mod.rs b/packages/rs-platform-wallet/src/wallet/identity/mod.rs index 01079577e1b..37eb12adeff 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/mod.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/mod.rs @@ -31,7 +31,8 @@ pub use crypto::{ }; pub use network::{DashPayView, IdentityWallet}; pub use state::{ - BlockTime, DashPayState, IdentityLocation, IdentityManager, ManagedIdentity, RegistrationIndex, + BlockTime, DashPayState, IdentityLocation, IdentityManager, ManagedIdentity, PrivateContact, + RegistrationIndex, }; pub use types::dashpay::profile::{calculate_avatar_hash, calculate_dhash_fingerprint}; pub use types::{ diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs b/packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs index 9d7c6664101..d2ce0bf8c05 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs @@ -22,6 +22,12 @@ use crate::wallet::identity::types::dashpay::contact_request::ContactRequest; use crate::wallet::identity::types::dashpay::established_contact::EstablishedContact; use crate::wallet::identity::ManagedIdentity; use crate::wallet::persister::WalletPersister; +use crate::wallet::platform_wallet::PlatformWalletInfo; +use dash_sdk::platform::dashpay::{PrivateContactRequest, PrivateContactRequestScan}; +use dash_sdk::platform::private_document_entries::PrivateDocumentEntries; +use dash_sdk::platform::private_document_scan::PrivateDocumentScan; +use dpp::identity::KeyID; +use dpp::platform_value::BinaryData; use std::collections::BTreeMap; // --------------------------------------------------------------------------- @@ -144,6 +150,69 @@ pub trait ContactCryptoProvider { enc_to_user_id: &[u8; 32], private_data_blob: &[u8], ) -> Result; + + /// Sorts `page`, read from `start_position` of the store of the private document type + /// `document_type_name` of `data_contract_id`, with the keys at `keys`: the entries the + /// wallet's identities received, those they sent, and those refused + /// ([`dash_sdk::platform::private_document_scan::PrivateDocumentScanKeys::scan_page`]). + /// The keys stay in the signer. + /// + /// Defaulted to an "unsupported" error so adding this method is not a source-breaking + /// change: a provider that never reads private contact requests needs no override. + async fn scan_private_document_page( + &self, + _keys: &PrivateDocumentKeyPaths, + _data_contract_id: Identifier, + _document_type_name: &str, + _start_position: u64, + _page: &PrivateDocumentEntries, + ) -> Result { + Err(private_documents_unsupported()) + } + + /// Reads the private contact requests of `scan`, a scan of DashPay's store made with the + /// same `keys`: decodes each, checks each received request's sender against the key + /// `sender_identities` holds under the id it names, with the key at `keys` that opened it + /// ([`dash_sdk::platform::dashpay::private_contact_requests_from_scan`]). + /// + /// Defaulted to an "unsupported" error, as [`Self::scan_private_document_page`]. + async fn read_private_contact_requests( + &self, + _keys: &PrivateDocumentKeyPaths, + _dashpay_contract: &dpp::prelude::DataContract, + _scan: PrivateDocumentScan, + _sender_identities: &BTreeMap>, + _platform_version: &dpp::version::PlatformVersion, + ) -> Result { + Err(private_documents_unsupported()) + } + + /// Seals `request` from `sender`, whose encryption key is the one at + /// `sender_encryption_key_path`, to the key `recipient_key_id` of `recipient` + /// ([`dash_sdk::platform::dashpay::seal_private_contact_request`]). `esk_seed` is 32 fresh + /// random bytes, never reused. + /// + /// Defaulted to an "unsupported" error, as [`Self::scan_private_document_page`]. + #[allow(clippy::too_many_arguments)] + async fn seal_private_contact_request( + &self, + _sender_encryption_key_path: &key_wallet::bip32::DerivationPath, + _dashpay_contract: &dpp::prelude::DataContract, + _sender: &Identity, + _sender_encryption_key_id: KeyID, + _recipient: &Identity, + _recipient_key_id: KeyID, + _request: &PrivateContactRequest, + _esk_seed: &[u8; 32], + ) -> Result { + Err(private_documents_unsupported()) + } +} + +fn private_documents_unsupported() -> PlatformWalletError { + PlatformWalletError::InvalidIdentityData( + "private contact requests are not supported by this crypto provider".to_string(), + ) } /// Result of [`ContactCryptoProvider::contact_info_seal`] — the two DIP-15 @@ -312,6 +381,87 @@ impl ContactCryptoProvider for SeedCryptoProvider { private_data, }) } + + async fn scan_private_document_page( + &self, + keys: &PrivateDocumentKeyPaths, + data_contract_id: Identifier, + document_type_name: &str, + start_position: u64, + page: &PrivateDocumentEntries, + ) -> Result { + let keys = keys.scan_keys(|path| self.secret_key_at(path))?; + Ok(keys.scan_page( + &platform_encryption::PrivateDocumentTarget { + contract_id: data_contract_id.to_buffer(), + document_type_name, + }, + start_position, + page, + )) + } + + async fn read_private_contact_requests( + &self, + keys: &PrivateDocumentKeyPaths, + dashpay_contract: &dpp::prelude::DataContract, + scan: PrivateDocumentScan, + sender_identities: &BTreeMap>, + platform_version: &dpp::version::PlatformVersion, + ) -> Result { + let keys = keys.scan_keys(|path| self.secret_key_at(path))?; + Ok( + dash_sdk::platform::dashpay::private_contact_requests_from_scan( + dashpay_contract, + scan, + &keys, + sender_identities, + platform_version, + )?, + ) + } + + async fn seal_private_contact_request( + &self, + sender_encryption_key_path: &key_wallet::bip32::DerivationPath, + dashpay_contract: &dpp::prelude::DataContract, + sender: &Identity, + sender_encryption_key_id: KeyID, + recipient: &Identity, + recipient_key_id: KeyID, + request: &PrivateContactRequest, + esk_seed: &[u8; 32], + ) -> Result { + let encryption_secret_key = self.secret_key_at(sender_encryption_key_path)?; + Ok(dash_sdk::platform::dashpay::seal_private_contact_request( + dashpay_contract, + &dash_sdk::platform::dashpay::PrivateContactRequestSender { + identity: sender, + encryption_key_id: sender_encryption_key_id, + encryption_secret_key: &encryption_secret_key, + }, + recipient, + recipient_key_id, + request, + esk_seed, + )?) + } +} + +#[cfg(test)] +impl SeedCryptoProvider { + /// The secret key at `path` of the test seed. + fn secret_key_at( + &self, + path: &key_wallet::bip32::DerivationPath, + ) -> Result { + let xprv = self + .wallet + .derive_extended_private_key(path) + .map_err(|e| PlatformWalletError::InvalidIdentityData(format!("test derive: {e}")))?; + dpp::secp256k1::SecretKey::from_secret_bytes(*xprv.private_key.to_bytes()) + .map_err(|e| PlatformWalletError::KeyDerivation(e.to_string())) + } } #[cfg(test)] @@ -378,6 +528,27 @@ pub enum AutoAcceptProofSource { }, } +/// What a contact request to send is built from, whether it is published as a +/// `contactRequest` document or sealed privately: see +/// `DashPayView::prepare_contact_request`. +pub(super) struct PreparedContactRequest { + pub(super) sender_identity: Identity, + pub(super) sender_key_index: u32, + /// The path of the sender's encryption key, whose scalar keys the ECDH and + /// the account reference mask in the signer. + pub(super) sender_enc_path: key_wallet::bip32::DerivationPath, + pub(super) recipient_identity: Identity, + pub(super) recipient_key_index: u32, + pub(super) recipient_enc_pubkey: dpp::secp256k1::PublicKey, + pub(super) account_index: u32, + /// Our receiving (friendship) xpub for the pair. + pub(super) contact_xpub_ext: key_wallet::bip32::ExtendedPubKey, + /// Its DIP-15 69-byte compact form, which the request carries. + pub(super) xpub_bytes: Vec, + pub(super) account_reference: u32, + pub(super) auto_accept_proof: Option>, +} + impl AutoAcceptProofSource { /// Map a pre-built optional proof (the FFI / legacy shape) into a source. pub fn from_option(proof: Option>) -> Self { @@ -428,6 +599,179 @@ impl DashPayView<'_, B> { where S: Signer + Send + Sync, C: ContactCryptoProvider + Sync, + { + // 1-4c. The keys, the receiving xpub, the masked account reference and + // the auto-accept proof, as a private request is built from them too. + let PreparedContactRequest { + sender_identity, + sender_key_index, + sender_enc_path, + recipient_identity, + recipient_key_index, + recipient_enc_pubkey, + account_index, + contact_xpub_ext, + xpub_bytes, + account_reference, + auto_accept_proof, + } = self + .prepare_contact_request( + sender_identity_id, + recipient_identity_id, + auto_accept_proof, + crypto, + ) + .await?; + + // 5. Build the signing key reference for document signing. + let identity_public_key = sender_identity + // Contact-request send writes a document state transition, + // which DPP requires to be signed by a HIGH-or-stricter + // authentication key. MASTER is rejected on document writes. + .available_signing_key( + signer, + Purpose::AUTHENTICATION, + &[SecurityLevel::HIGH, SecurityLevel::CRITICAL], + &[KeyType::ECDSA_SECP256K1], + false, + )? + .cloned() + .ok_or_else(|| { + PlatformWalletError::InvalidIdentityData( + "Sender identity has no HIGH or CRITICAL authentication key \ + (required for document state transitions)" + .to_string(), + ) + })?; + + // 6. Client-side ECDH via the signer: the shared secret is derived in + // the signer (scalar at `sender_enc_path`) against the recipient's + // encryption key, so the SDK write helper receives the finished secret + // (`EcdhProvider::ClientSide`) and no private key is ever materialized + // here. The recipient key is resolved exactly as the SDK would + // (`recipientKeyIndex` on the recipient identity); the helper re-checks + // the SDK asks for this same key before using the secret. + let shared_secret = crypto + .ecdh_shared_secret(&sender_enc_path, &recipient_enc_pubkey) + .await?; + + // 7. Broadcast through the write helper. All inputs are resolved + // above; the helper assembles the SDK `EcdhProvider` + xpub + // closure and dispatches `Sdk::send_contact_request`, keeping + // the seven-generic SDK signature out of this call site — see + // `sdk_writer.rs`. + let result = self + .sdk_writer + .send_contact_request(SendContactRequestParams { + sender_identity: sender_identity.clone(), + recipient_identity, + sender_key_index, + recipient_key_index, + account_reference, + account_label, + auto_accept_proof, + shared_secret, + expected_recipient_pubkey: recipient_enc_pubkey, + xpub_bytes, + signing_public_key: identity_public_key, + signer: signer as &(dyn Signer + Send + Sync), + }) + .await?; + + // 8. Mirror the local-state bookkeeping in `send_contact_request`. + // + // Store the REAL 96-byte ciphertext off the broadcast + // document (not a zero placeholder) so the persisted / + // SwiftData row matches what landed on Platform — a restored + // device comparing local rows against chain sees identity, + // and the sent-side re-ingest doesn't "upgrade" the row. + // Hard error rather than a zero-fill fallback: persisting a 96-byte + // all-zero "valid-looking" ciphertext would poison the local row + // (a restored device compares it to chain and mismatches; anything + // treating it as the contact's xpub source decrypts garbage). The + // broadcast already landed on-chain, so the sweep re-ingests + // the real document on the next pass — returning an error here is + // strictly safer than silently storing poison in release builds. + let encrypted_public_key = result + .document + .properties() + .get("encryptedPublicKey") + .and_then(|v: &Value| v.to_binary_bytes().ok()) + .ok_or_else(|| { + PlatformWalletError::InvalidIdentityData( + "broadcast contactRequest lacks a readable encryptedPublicKey; \ + the on-chain doc will reconcile on the next sync" + .to_string(), + ) + })?; + let mut contact_request = ContactRequest::new( + *sender_identity_id, + result.recipient_id, + sender_key_index, + recipient_key_index, + result.account_reference, + encrypted_public_key, + result.document.created_at_core_block_height().unwrap_or(0), + result.document.created_at().unwrap_or(0), + ); + // Mirror the broadcast doc's optional `encryptedAccountLabel` onto the + // local outgoing row so it matches what landed on Platform (same reason + // the 96-byte ciphertext is read off the doc above). Without this the + // sender's own row never reflects the label it just sent. + contact_request.encrypted_account_label = result + .document + .properties() + .get("encryptedAccountLabel") + .and_then(|v: &Value| v.to_binary_bytes().ok()); + + { + let mut wm = self.wallet_manager.write().await; + let info = wm + .get_wallet_info_mut(&self.wallet_id) + .ok_or_else(|| PlatformWalletError::WalletNotFound(hex::encode(self.wallet_id)))?; + let managed = info + .identity_manager + .managed_identity_mut(sender_identity_id) + .ok_or(PlatformWalletError::IdentityNotFound(*sender_identity_id))?; + managed + .add_sent_contact_request(contact_request.clone(), &self.persister) + .map_err(|e| { + PlatformWalletError::Persistence(format!( + "sent contact request not persisted: {e}" + )) + })?; + } + + // Register our receiving (friendship) account from the xpub the signer + // already derived above — same friendship key, no second derivation and + // no resident seed. + self.register_contact_account( + sender_identity_id, + recipient_identity_id, + account_index, + contact_xpub_ext, + ) + .await?; + + Ok(contact_request) + } + + /// Steps 1 to 4c of a contact request send, shared by the document send + /// ([`Self::send_contact_request_with_external_signer`]) and the private one + /// (`send_private_contact_request`): the sender's identity, encryption key + /// and its path, the recipient's identity and key, our receiving xpub for + /// the pair, the DIP-15 masked account reference (its version bumped past + /// the previous request to the recipient, public or private) and the + /// auto-accept proof over it. + pub(super) async fn prepare_contact_request( + &self, + sender_identity_id: &Identifier, + recipient_identity_id: &Identifier, + auto_accept_proof: AutoAcceptProofSource, + crypto: &C, + ) -> Result + where + C: ContactCryptoProvider + Sync, { // 1. Retrieve the sender identity and its HD index from the // local manager. @@ -615,34 +959,6 @@ impl DashPayView<'_, B> { ), }; - // 5. Build the signing key reference for document signing. - let identity_public_key = sender_identity - // Contact-request send writes a document state transition, - // which DPP requires to be signed by a HIGH-or-stricter - // authentication key. MASTER is rejected on document writes. - .available_signing_key( - signer, - Purpose::AUTHENTICATION, - &[SecurityLevel::HIGH, SecurityLevel::CRITICAL], - &[KeyType::ECDSA_SECP256K1], - false, - )? - .cloned() - .ok_or_else(|| { - PlatformWalletError::InvalidIdentityData( - "Sender identity has no HIGH or CRITICAL authentication key \ - (required for document state transitions)" - .to_string(), - ) - })?; - - // 6. Client-side ECDH via the signer: the shared secret is derived in - // the signer (scalar at `sender_enc_path`) against the recipient's - // encryption key, so the SDK write helper receives the finished secret - // (`EcdhProvider::ClientSide`) and no private key is ever materialized - // here. The recipient key is resolved exactly as the SDK would - // (`recipientKeyIndex` on the recipient identity); the helper re-checks - // the SDK asks for this same key before using the secret. let recipient_enc_pubkey = { use dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; let key = recipient_identity @@ -659,109 +975,19 @@ impl DashPayView<'_, B> { )) })? }; - let shared_secret = crypto - .ecdh_shared_secret(&sender_enc_path, &recipient_enc_pubkey) - .await?; - - // 7. Broadcast through the write helper. All inputs are resolved - // above; the helper assembles the SDK `EcdhProvider` + xpub - // closure and dispatches `Sdk::send_contact_request`, keeping - // the seven-generic SDK signature out of this call site — see - // `sdk_writer.rs`. - let result = self - .sdk_writer - .send_contact_request(SendContactRequestParams { - sender_identity: sender_identity.clone(), - recipient_identity, - sender_key_index, - recipient_key_index, - account_reference, - account_label, - auto_accept_proof, - shared_secret, - expected_recipient_pubkey: recipient_enc_pubkey, - xpub_bytes, - signing_public_key: identity_public_key, - signer: signer as &(dyn Signer + Send + Sync), - }) - .await?; - - // 8. Mirror the local-state bookkeeping in `send_contact_request`. - // - // Store the REAL 96-byte ciphertext off the broadcast - // document (not a zero placeholder) so the persisted / - // SwiftData row matches what landed on Platform — a restored - // device comparing local rows against chain sees identity, - // and the sent-side re-ingest doesn't "upgrade" the row. - // Hard error rather than a zero-fill fallback: persisting a 96-byte - // all-zero "valid-looking" ciphertext would poison the local row - // (a restored device compares it to chain and mismatches; anything - // treating it as the contact's xpub source decrypts garbage). The - // broadcast already landed on-chain, so the sweep re-ingests - // the real document on the next pass — returning an error here is - // strictly safer than silently storing poison in release builds. - let encrypted_public_key = result - .document - .properties() - .get("encryptedPublicKey") - .and_then(|v: &Value| v.to_binary_bytes().ok()) - .ok_or_else(|| { - PlatformWalletError::InvalidIdentityData( - "broadcast contactRequest lacks a readable encryptedPublicKey; \ - the on-chain doc will reconcile on the next sync" - .to_string(), - ) - })?; - let mut contact_request = ContactRequest::new( - *sender_identity_id, - result.recipient_id, + Ok(PreparedContactRequest { + sender_identity, sender_key_index, + sender_enc_path, + recipient_identity, recipient_key_index, - result.account_reference, - encrypted_public_key, - result.document.created_at_core_block_height().unwrap_or(0), - result.document.created_at().unwrap_or(0), - ); - // Mirror the broadcast doc's optional `encryptedAccountLabel` onto the - // local outgoing row so it matches what landed on Platform (same reason - // the 96-byte ciphertext is read off the doc above). Without this the - // sender's own row never reflects the label it just sent. - contact_request.encrypted_account_label = result - .document - .properties() - .get("encryptedAccountLabel") - .and_then(|v: &Value| v.to_binary_bytes().ok()); - - { - let mut wm = self.wallet_manager.write().await; - let info = wm - .get_wallet_info_mut(&self.wallet_id) - .ok_or_else(|| PlatformWalletError::WalletNotFound(hex::encode(self.wallet_id)))?; - let managed = info - .identity_manager - .managed_identity_mut(sender_identity_id) - .ok_or(PlatformWalletError::IdentityNotFound(*sender_identity_id))?; - managed - .add_sent_contact_request(contact_request.clone(), &self.persister) - .map_err(|e| { - PlatformWalletError::Persistence(format!( - "sent contact request not persisted: {e}" - )) - })?; - } - - // Register our receiving (friendship) account from the xpub the signer - // already derived above — same friendship key, no second derivation and - // no resident seed. - self.register_contact_account( - sender_identity_id, - recipient_identity_id, + recipient_enc_pubkey, account_index, contact_xpub_ext, - ) - .await?; - - Ok(contact_request) + xpub_bytes, + account_reference, + auto_accept_proof, + }) } } @@ -861,7 +1087,7 @@ fn query_lower_bound(high_water: Option) -> Option { /// /// DIP-15 section 8.4 allows ignoring additional requests, which is what the /// collapse does. -fn newest_received_per_sender( +pub(super) fn newest_received_per_sender( requests: impl IntoIterator, ) -> std::collections::BTreeMap { let mut newest: std::collections::BTreeMap = @@ -894,7 +1120,7 @@ fn newest_received_per_sender( /// with the freshest outgoing reference — otherwise the next rotation /// collides on the contract's `($ownerId, toUserId, accountReference)` unique /// index. -fn newest_sent_per_recipient( +pub(super) fn newest_sent_per_recipient( requests: impl IntoIterator, ) -> std::collections::BTreeMap { let mut newest: std::collections::BTreeMap = @@ -978,7 +1204,7 @@ fn record_and_collapse_sent_requests( /// /// Split out of the sweep so the persist-failure branches are reachable in a /// test without standing up a Platform that answers document queries. -fn ingest_received_requests( +pub(super) fn ingest_received_requests( managed: &mut crate::wallet::identity::ManagedIdentity, persister: &crate::wallet::persister::WalletPersister, identity_id: Identifier, @@ -1062,7 +1288,7 @@ fn ingest_received_requests( /// /// `add_sent_contact_request` carries its own duplicate / metadata-loss guard, /// so re-ingesting the same range on the next sweep is safe. -fn ingest_sent_requests( +pub(super) fn ingest_sent_requests( managed: &mut ManagedIdentity, persister: &WalletPersister, identity_id: Identifier, @@ -1149,6 +1375,61 @@ pub(crate) fn budget_spent(deadline: Option) -> bool { deadline.is_some_and(|d| std::time::Instant::now() >= d) } +/// Tears down the external (sending) accounts of `identity_id`'s contacts that +/// must be rebuilt: those `rotated` this pass, re-keyed by a rotation request, +/// and any registered account not built from its contact's current incoming +/// request. Removes both the immutable `Account` (old xpub, `send_payment`'s +/// derivation source) and the managed wrapper (old address pool); the account +/// build sweep then re-queues them and re-registers from the current xpub, and +/// the persisted account row is upserted (same unique key) when that round +/// lands. +/// +/// The second case is the rotation self-heal across a restart: an external +/// account rebuilt from the persisted (tombstone-less) registration row can +/// carry the STALE xpub while the established contact already tracks the new +/// incoming reference (the deferred rebuild was lost with the in-memory queue +/// at load). Such an account exists but was not built from the current +/// reference, so the plain "has an external account" gate would skip it +/// forever: a recorded `external_account_reference` that does not match the +/// contact's current `incoming_request.account_reference`, including a `None` +/// marker from a cold restore that did not carry it, queues it here. +/// Idempotent: once rebuilt the marker is stamped and the next sweep skips it. +pub(super) fn tear_down_stale_external_accounts( + wallet: &mut key_wallet::wallet::Wallet, + info: &mut PlatformWalletInfo, + identity_id: &Identifier, + mut rotated: Vec, +) { + use key_wallet::account::account_collection::DashpayAccountKey; + let key_for = |contact_id: &Identifier| DashpayAccountKey { + index: 0, + user_identity_id: identity_id.to_buffer(), + friend_identity_id: contact_id.to_buffer(), + }; + if let Some(managed) = info.identity_manager.managed_identity(identity_id) { + for (contact_id, contact) in managed.dashpay().established_contacts().iter() { + let has_external = info + .core_wallet + .accounts + .dashpay_external_accounts + .contains_key(&key_for(contact_id)); + if external_account_needs_rebuild(contact, has_external) + && !rotated.contains(contact_id) + { + rotated.push(*contact_id); + } + } + } + for contact_id in &rotated { + let key = key_for(contact_id); + wallet.accounts.dashpay_external_accounts.remove(&key); + info.core_wallet + .accounts + .dashpay_external_accounts + .remove(&key); + } +} + /// Whether a registered outbound `DashpayExternalAccount` for `contact` /// must be torn down + rebuilt because it was NOT built from the contact's /// current `incoming_request.account_reference`. @@ -1623,69 +1904,6 @@ impl DashPayView<'_, B> { let sent_persist_ok = ingest_sent_sweep(managed, &self.persister, identity_id, parsed_sent, sent_ok); - // (2a') Rotation self-heal across restart: an external account - // rebuilt from the persisted (tombstone-less) registration - // row after a restart can carry the STALE xpub while the - // established contact already tracks the new incoming - // reference (the deferred rebuild was lost with the - // in-memory queue at load). Such an account exists but was - // NOT built from the current reference, so the plain - // `has_external` gate would skip it forever. Detect a - // registered external account whose recorded - // `external_account_reference` does not match the contact's - // current `incoming_request.account_reference` (including a - // `None` marker from a cold restore that didn't carry it), - // and enqueue it for teardown + rebuild alongside the - // in-pass rotations. Idempotent: once rebuilt the marker is - // stamped, so the next sweep sees a match and skips it. - // Accesses the managed identity's established contacts and - // `info.core_wallet` as DISJOINT fields of `info` via a - // plain `for` loop (the same split the teardown loop below - // relies on) — a closure capturing whole `info` would - // collide with the mutable `managed` reborrow. - { - use key_wallet::account::account_collection::DashpayAccountKey; - for (contact_id, contact) in managed.dashpay().established_contacts().iter() { - let key = DashpayAccountKey { - index: 0, - user_identity_id: identity_id.to_buffer(), - friend_identity_id: contact_id.to_buffer(), - }; - let has_external = info - .core_wallet - .accounts - .dashpay_external_accounts - .contains_key(&key); - if external_account_needs_rebuild(contact, has_external) - && !rotated_contacts.contains(contact_id) - { - rotated_contacts.push(*contact_id); - } - } - } - - // (2b) Tear down stale external accounts for contacts that - // rotated in this pass: both the immutable Account - // (old xpub — `send_payment`'s derivation source) and - // the managed wrapper (old address pool). The - // candidate collection below then re-queues them and - // the build step re-registers from the NEW encrypted - // xpub. The persisted account row is upserted (same - // unique key) when the re-registration round lands. - for contact_id in &rotated_contacts { - use key_wallet::account::account_collection::DashpayAccountKey; - let key = DashpayAccountKey { - index: 0, - user_identity_id: identity_id.to_buffer(), - friend_identity_id: contact_id.to_buffer(), - }; - wallet.accounts.dashpay_external_accounts.remove(&key); - info.core_wallet - .accounts - .dashpay_external_accounts - .remove(&key); - } - // Advance the high-water cursors to the max `$createdAt` // fetched this sweep, never below the current value. Advance a // direction's cursor only when BOTH its fetch succeeded AND @@ -1716,6 +1934,11 @@ impl DashPayView<'_, B> { report.unpersisted_identities.push(identity_id); } + // (2a', 2b) Tear down the external accounts of contacts re-keyed + // this pass, or rebuilt after a restart from a stale + // xpub, so the build below re-registers them. + tear_down_stale_external_accounts(wallet, info, &identity_id, rotated_contacts); + // (3) Collect missing receiving accounts for sent-only // requests and account pairs for established contacts. Self::collect_account_build_candidates(info, &identity_id) @@ -2005,7 +2228,7 @@ impl DashPayView<'_, B> { /// /// Called **after** the sync write guard is dropped: this re-acquires /// the non-reentrant wallet-manager lock. - async fn build_contact_accounts( + pub(super) async fn build_contact_accounts( &self, identity_id: &Identifier, candidate: AccountBuildCandidate, @@ -3496,6 +3719,12 @@ impl DashPayView<'_, B> { /// [`Self::send_contact_request_with_external_signer`] so signing /// crosses the FFI via the supplied `&S: Signer`. /// Same ECDH caveat applies — see that method's docstring. + /// + /// A request that came privately is refused with + /// [`PlatformWalletError::PrivateContactRequestNeedsPrivateReply`]: a + /// `contactRequest` document in reply would publish the relationship the + /// sender kept off chain. It is accepted with + /// `accept_private_contact_request`. pub async fn accept_contact_request_with_external_signer( &self, request: &ContactRequest, @@ -3505,6 +3734,51 @@ impl DashPayView<'_, B> { where S: Signer + Send + Sync, C: ContactCryptoProvider + Sync, + { + let received_privately = { + let wm = self.wallet_manager.read().await; + wm.get_wallet_info(&self.wallet_id) + .and_then(|info| { + info.identity_manager + .managed_identity(&request.recipient_id) + }) + .and_then(|managed| managed.dashpay().private_contact(&request.sender_id)) + .is_some_and(|private| private.received_privately && !private.sent_privately) + }; + if received_privately { + return Err(PlatformWalletError::PrivateContactRequestNeedsPrivateReply( + request.sender_id, + )); + } + self.accept_contact_request_replying_with(request, crypto, || async { + self.send_contact_request_with_external_signer( + &request.recipient_id, + &request.sender_id, + None, + AutoAcceptProofSource::None, + signer, + crypto, + ) + .await + .map(|_| ()) + }) + .await + } + + /// Accept `request`, with `send_reply` sending our reciprocal request to + /// its sender unless one already exists: the body of + /// [`Self::accept_contact_request_with_external_signer`], shared with the + /// private accept, which replies privately. + pub(super) async fn accept_contact_request_replying_with( + &self, + request: &ContactRequest, + crypto: &C, + send_reply: R, + ) -> Result + where + C: ContactCryptoProvider + Sync, + R: FnOnce() -> Fut, + Fut: std::future::Future>, { let our_identity_id = request.recipient_id; let sender_id = request.sender_id; @@ -3620,15 +3894,7 @@ impl DashPayView<'_, B> { ), } } else { - self.send_contact_request_with_external_signer( - &our_identity_id, - &sender_id, - None, - AutoAcceptProofSource::None, - signer, - crypto, - ) - .await?; + send_reply().await?; } // 4. Validate key indices (same gate as the sync sweep and the diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/mod.rs b/packages/rs-platform-wallet/src/wallet/identity/network/mod.rs index d431d449039..c52b1e610ee 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/mod.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/mod.rs @@ -75,6 +75,8 @@ mod payments; pub(crate) use payments::{record_incoming_dashpay_payments, sent_payment_status_for_record}; mod prepared_payment; pub use prepared_payment::PreparedDashPayPayment; +mod private_contact_requests; +pub use private_contact_requests::{PrivateContactSyncReport, PrivateDocumentKeyPaths}; mod profile; mod public_keys; pub use public_keys::{fetch_identity_public_keys, FetchedIdentityPublicKeys}; diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/private_contact_requests.rs b/packages/rs-platform-wallet/src/wallet/identity/network/private_contact_requests.rs new file mode 100644 index 00000000000..19476f98e47 --- /dev/null +++ b/packages/rs-platform-wallet/src/wallet/identity/network/private_contact_requests.rs @@ -0,0 +1,1206 @@ +//! DashPay private contact requests (protocol version 15): send, sync, accept. +//! +//! A private contact request is an entry of DashPay's `privateContactRequest` +//! store, sealed to one of the recipient's DashPay keys and appended by a +//! transition paid from the shielded pool, so nothing on chain names either +//! side. The sender names itself inside, with a deniable MAC by its encryption +//! key, and seals with the outgoing viewing key of that key, so a wallet +//! restored from seed recovers what it sent. +//! +//! The wallet keeps a private request as it keeps a public one, a +//! [`ContactRequest`], so contacts establish, rotate and get their DashPay +//! accounts the same way whichever way a request came. The entry carries the +//! compact xpub and the label in the clear; the wallet encrypts them under the +//! pair's DIP-15 shared key, as a `contactRequest` document carries them, with +//! IVs from the entry's commitment so a rescan rebuilds the same record. What a +//! `ContactRequest` has no field for (that the request came privately, and the +//! contact's shielded address) is kept in [`PrivateContact`]. +//! +//! All key material stays in the [`ContactCryptoProvider`]: the store is +//! opened, senders are checked and requests are sealed inside it. +//! +//! [`PrivateContact`]: crate::wallet::identity::PrivateContact + +use std::collections::btree_map::Entry; +use std::collections::BTreeMap; +use std::future::Future; + +use dash_sdk::dapi_grpc::platform::v0::ResponseMetadata; +use dash_sdk::platform::dashpay::{ + private_contact_request_outgoing_viewing_key, private_contact_request_senders, + recipient_key_purpose_is_valid, PrivateContactRequest, RejectedPrivateContactRequest, + PRIVATE_CONTACT_REQUEST_DOCUMENT_TYPE_NAME, +}; +use dash_sdk::platform::private_document_entries::{ + PrivateDocumentEntries, PrivateDocumentEntriesQuery, +}; +use dash_sdk::platform::private_document_scan::PrivateDocumentScanKeys; +use dash_sdk::platform::Fetch; +use dpp::data_contract::accessors::v0::DataContractV0Getters; +use dpp::identity::accessors::IdentityGettersV0; +use dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; +use dpp::identity::identity_public_key::Purpose; +use dpp::identity::{Identity, KeyID, KeyType}; +use dpp::platform_value::BinaryData; +use dpp::prelude::Identifier; +use dpp::secp256k1::rand::rngs::StdRng; +use dpp::secp256k1::rand::{RngCore, SeedableRng}; +use dpp::secp256k1::{PublicKey, SecretKey}; +use key_wallet::bip32::{DerivationPath, KeyDerivationType}; +use platform_encryption::{encrypt_account_label, encrypt_extended_public_key, COMPACT_XPUB_LEN}; +use zeroize::Zeroize; + +use super::contact_requests::{ + ingest_received_requests, ingest_sent_requests, newest_received_per_sender, + newest_sent_per_recipient, tear_down_stale_external_accounts, AutoAcceptProofSource, + ContactCryptoProvider, PreparedContactRequest, +}; +use super::{dashpay_contract, DashPayView, IdentityWallet}; +use crate::broadcaster::TransactionBroadcaster; +use crate::error::PlatformWalletError; +use crate::wallet::identity::types::dashpay::contact_request::ContactRequest; +use crate::wallet::identity::types::dashpay::established_contact::EstablishedContact; + +/// The paths of the keys a wallet's identities read DashPay's private contact +/// request store with, for a [`ContactCryptoProvider`] to derive inside the +/// signer. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct PrivateDocumentKeyPaths { + /// Each identity entries may be sealed to, with the path of each of its + /// keys an entry may be sealed to, by key id. + pub recipients: Vec<(Identifier, Vec<(KeyID, DerivationPath)>)>, + /// Each identity that may have sealed entries, with the path of each of + /// its encryption keys: one outgoing viewing key each. + pub senders: Vec<(Identifier, DerivationPath)>, +} + +impl PrivateDocumentKeyPaths { + /// The scan keys these paths derive to, `derive` deriving the secret key at + /// a path: for a provider implementation to build inside the signer and + /// drop there. + pub fn scan_keys( + &self, + mut derive: impl FnMut(&DerivationPath) -> Result, + ) -> Result { + let mut keys = PrivateDocumentScanKeys::new(); + for (identity_id, paths) in &self.recipients { + let secret_keys = paths + .iter() + .map(|(key_id, path)| Ok((*key_id, derive(path)?))) + .collect::, E>>()?; + keys = keys.with_recipient(*identity_id, &secret_keys); + } + for (identity_id, path) in &self.senders { + let secret_key = derive(path)?; + keys = keys.with_sender( + *identity_id, + private_contact_request_outgoing_viewing_key(&secret_key), + ); + } + Ok(keys) + } +} + +/// What a pass over DashPay's private contact request store did. +#[derive(Clone, Debug, Default)] +pub struct PrivateContactSyncReport { + /// The store pages read. + pub pages_read: usize, + /// The requests ingested, received and sent, as the wallet records them. + pub requests: Vec, + /// Entries that opened for one of the wallet's identities but were + /// refused. + pub rejected: Vec, + /// The store position the pass stopped at. + pub next_position: u64, + /// Whether the pass read to the store's last entry. + pub complete: bool, + /// Identities whose ingest did not reach disk: their cursor stays where + /// it was, so the next pass reads their entries again. + pub unpersisted_identities: Vec, +} + +/// One of the wallet's identities, as a pass reads the store for it. +struct ScannedIdentity { + identity_id: Identifier, + identity_index: u32, + /// Its cursor when the pass last advanced it; a change under the pass is a + /// rewind, which the pass leaves alone. + position: u64, + /// Its ingest failed this pass: it is not advanced again until the next. + failed: bool, +} + +/// The local record of a private request: a [`ContactRequest`] carrying the +/// request's compact xpub and label encrypted under `shared_key`, the pair's +/// DIP-15 shared key, as a `contactRequest` document carries them. The IVs are +/// the two halves of the entry's `commitment`, so a rescan of the entry +/// rebuilds the same record, and the wallet's comparisons of a contact's +/// encrypted xpub hold across passes. +#[allow(clippy::too_many_arguments)] +pub(super) fn private_request_record( + sender_id: Identifier, + recipient_id: Identifier, + sender_key_index: KeyID, + recipient_key_index: KeyID, + request: &PrivateContactRequest, + commitment: &[u8; 32], + shared_key: &[u8; 32], + core_height_created_at: u32, + created_at: u64, +) -> ContactRequest { + let (xpub_iv, label_iv) = commitment.split_at(16); + let xpub_iv: [u8; 16] = xpub_iv + .try_into() + .expect("a 32-byte commitment splits in two"); + let label_iv: [u8; 16] = label_iv + .try_into() + .expect("a 32-byte commitment splits in two"); + let mut record = ContactRequest::new( + sender_id, + recipient_id, + sender_key_index, + recipient_key_index, + request.account_reference, + encrypt_extended_public_key(shared_key, &xpub_iv, &request.extended_public_key), + core_height_created_at, + created_at, + ); + record.encrypted_account_label = request + .account_label + .as_deref() + .map(|label| encrypt_account_label(shared_key, &label_iv, label)); + record.auto_accept_proof = request.auto_accept_proof.clone(); + record +} + +/// The public key of `identity`'s key `key_id`. +fn identity_public_key( + identity: &Identity, + key_id: KeyID, +) -> Result { + let key = identity.public_keys().get(&key_id).ok_or_else(|| { + PlatformWalletError::InvalidIdentityData(format!( + "identity {} has no key {key_id}", + identity.id() + )) + })?; + PublicKey::from_slice(key.data().as_slice()).map_err(|e| { + PlatformWalletError::InvalidIdentityData(format!( + "key {key_id} of identity {} is not a secp256k1 key: {e}", + identity.id() + )) + }) +} + +impl DashPayView<'_, B> { + /// Send a private contact request from `sender_identity_id` to + /// `recipient_identity_id`: the request a `contactRequest` document would + /// carry (our receiving xpub for the pair, the masked account reference, + /// the label and the auto-accept proof) plus our `shielded_address`, sealed + /// in the signer to the recipient's DashPay key, then appended by `pay`, + /// which pays for it from the shielded pool and returns the metadata of + /// the proof that it was appended + /// (`PlatformWallet::send_private_contact_request`). + /// + /// The keys and the account reference are chosen as for a document send, + /// so a later request to the same recipient, either way, rotates past this + /// one. Once `pay` succeeds the request is recorded as sent and our + /// receiving account for the contact is registered. + #[allow(clippy::too_many_arguments)] + pub async fn send_private_contact_request( + &self, + sender_identity_id: &Identifier, + recipient_identity_id: &Identifier, + account_label: Option, + auto_accept_proof: AutoAcceptProofSource, + shielded_address: Option<[u8; 43]>, + crypto: &C, + pay: F, + ) -> Result + where + C: ContactCryptoProvider + Sync, + F: FnOnce(BinaryData) -> Fut + Send, + Fut: Future> + Send, + { + let PreparedContactRequest { + sender_identity, + sender_key_index, + sender_enc_path, + recipient_identity, + recipient_key_index, + recipient_enc_pubkey, + account_index, + contact_xpub_ext, + xpub_bytes, + account_reference, + auto_accept_proof, + } = self + .prepare_contact_request( + sender_identity_id, + recipient_identity_id, + auto_accept_proof, + crypto, + ) + .await?; + let extended_public_key: [u8; COMPACT_XPUB_LEN] = + xpub_bytes.as_slice().try_into().map_err(|_| { + PlatformWalletError::InvalidIdentityData(format!( + "the receiving xpub is {} bytes, not the {COMPACT_XPUB_LEN}-byte compact form", + xpub_bytes.len() + )) + })?; + let request = PrivateContactRequest { + account_reference, + extended_public_key, + account_label, + auto_accept_proof, + shielded_address, + }; + + let contract = dashpay_contract()?; + let mut esk_seed = [0u8; 32]; + StdRng::from_os_rng().fill_bytes(&mut esk_seed); + let sealed = crypto + .seal_private_contact_request( + &sender_enc_path, + &contract, + &sender_identity, + sender_key_index, + &recipient_identity, + recipient_key_index, + &request, + &esk_seed, + ) + .await; + esk_seed.zeroize(); + let entry = sealed?; + // An entry starts with its 32-byte commitment. + let commitment: [u8; 32] = entry + .as_slice() + .get(..32) + .and_then(|commitment| commitment.try_into().ok()) + .ok_or_else(|| { + PlatformWalletError::InvalidIdentityData( + "the provider sealed an entry shorter than its commitment".to_string(), + ) + })?; + + let metadata = pay(entry).await?; + + let shared_secret = crypto + .ecdh_shared_secret(&sender_enc_path, &recipient_enc_pubkey) + .await?; + // Its core height is unknown, so our receiving account for the contact + // is scanned from the wallet's birth (`receiving_scan_checkpoint`). + let contact_request = private_request_record( + *sender_identity_id, + *recipient_identity_id, + sender_key_index, + recipient_key_index, + &request, + &commitment, + &shared_secret, + 0, + metadata.time_ms, + ); + + { + let mut wm = self.wallet_manager.write().await; + let managed = wm + .get_wallet_info_mut(&self.wallet_id) + .ok_or_else(|| PlatformWalletError::WalletNotFound(hex::encode(self.wallet_id)))? + .identity_manager + .managed_identity_mut(sender_identity_id) + .ok_or(PlatformWalletError::IdentityNotFound(*sender_identity_id))?; + managed + .add_sent_contact_request(contact_request.clone(), &self.persister) + .map_err(|e| { + PlatformWalletError::Persistence(format!( + "sent private contact request not persisted: {e}" + )) + })?; + managed.note_private_contact(*recipient_identity_id, false, None); + } + + self.register_contact_account( + sender_identity_id, + recipient_identity_id, + account_index, + contact_xpub_ext, + ) + .await?; + + Ok(contact_request) + } + + /// Accept a contact request that came privately, replying privately: as + /// [`Self::accept_contact_request_with_external_signer`], with the reply + /// sent by [`Self::send_private_contact_request`], `pay` paying for it and + /// `shielded_address` the address the contact may pay us at privately. + pub async fn accept_private_contact_request( + &self, + request: &ContactRequest, + shielded_address: Option<[u8; 43]>, + crypto: &C, + pay: F, + ) -> Result + where + C: ContactCryptoProvider + Sync, + F: FnOnce(BinaryData) -> Fut + Send, + Fut: Future> + Send, + { + self.accept_contact_request_replying_with(request, crypto, || async { + self.send_private_contact_request( + &request.recipient_id, + &request.sender_id, + None, + AutoAcceptProofSource::None, + shielded_address, + crypto, + pay, + ) + .await + .map(|_| ()) + }) + .await + } + + /// The wallet's identities a pass reads the store for (those with an HD + /// index, whose keys the signer can derive) and the paths of their keys: + /// every `ECDSA_SECP256K1` decryption or encryption key, disabled ones + /// included, as an entry sealed before a key was disabled still opens with + /// it, and every such encryption key as a sender. + async fn private_contact_request_identities( + &self, + ) -> Result<(Vec, PrivateDocumentKeyPaths), PlatformWalletError> { + let wm = self.wallet_manager.read().await; + let info = wm + .get_wallet_info(&self.wallet_id) + .ok_or_else(|| PlatformWalletError::WalletNotFound(hex::encode(self.wallet_id)))?; + let mut identities = Vec::new(); + let mut paths = PrivateDocumentKeyPaths::default(); + for identity in info.identity_manager.all_identities() { + let identity_id = identity.id(); + let Some(managed) = info.identity_manager.managed_identity(&identity_id) else { + continue; + }; + let Some(identity_index) = managed.identity_index else { + continue; + }; + let path = |key_id| { + IdentityWallet::::identity_auth_derivation_path( + self.sdk.network, + KeyDerivationType::ECDSA, + identity_index, + key_id, + ) + }; + let mut recipient_paths = Vec::new(); + for (key_id, key) in managed.identity.public_keys() { + if key.key_type() != KeyType::ECDSA_SECP256K1 + || !recipient_key_purpose_is_valid(key.purpose()) + { + continue; + } + recipient_paths.push((*key_id, path(*key_id)?)); + if key.purpose() == Purpose::ENCRYPTION { + paths.senders.push((identity_id, path(*key_id)?)); + } + } + if recipient_paths.is_empty() { + continue; + } + paths.recipients.push((identity_id, recipient_paths)); + identities.push(ScannedIdentity { + identity_id, + identity_index, + position: managed.dashpay().private_contact_requests_position(), + failed: false, + }); + } + Ok((identities, paths)) + } + + /// Read DashPay's private contact request store for the wallet's + /// identities, at most `max_pages` pages from the earliest of their + /// cursors, and ingest what it holds for them: requests to them, each + /// sender checked against the identity key the entry names, and requests + /// they sent, recovered from the store alone. Contacts establish, and their + /// DashPay accounts are queued, as the document sync does. + /// + /// A request that came privately carries no auto-accept proof into the + /// wallet: accepting it replies privately, which pays from the shielded + /// pool, so it is accepted by hand + /// ([`Self::accept_private_contact_request`]). + pub async fn sync_private_contact_requests( + &self, + crypto: &C, + max_pages: usize, + ) -> Result + where + C: ContactCryptoProvider + Sync, + { + let (mut identities, paths) = self.private_contact_request_identities().await?; + let mut report = PrivateContactSyncReport::default(); + let Some(start) = identities.iter().map(|identity| identity.position).min() else { + report.complete = true; + return Ok(report); + }; + let identity_indexes: BTreeMap = identities + .iter() + .map(|identity| (identity.identity_id, identity.identity_index)) + .collect(); + let key_path = |identity_id: &Identifier, key_id: KeyID| { + let identity_index = identity_indexes + .get(identity_id) + .copied() + .ok_or_else(|| PlatformWalletError::IdentityNotFound(*identity_id))?; + IdentityWallet::::identity_auth_derivation_path( + self.sdk.network, + KeyDerivationType::ECDSA, + identity_index, + key_id, + ) + }; + let contract = dashpay_contract()?; + let mut fetched_identities: BTreeMap> = BTreeMap::new(); + let mut position = start; + report.next_position = start; + + while report.pages_read < max_pages { + let query = PrivateDocumentEntriesQuery::new( + contract.id(), + PRIVATE_CONTACT_REQUEST_DOCUMENT_TYPE_NAME, + position, + ); + let (page, metadata) = + PrivateDocumentEntries::fetch_with_metadata(&self.sdk, query, None).await?; + let page = page.ok_or_else(|| { + PlatformWalletError::InvalidIdentityData(format!( + "no page of DashPay's private contact requests from position {position}" + )) + })?; + report.pages_read += 1; + + let scan = crypto + .scan_private_document_page( + &paths, + contract.id(), + PRIVATE_CONTACT_REQUEST_DOCUMENT_TYPE_NAME, + position, + &page, + ) + .await?; + for sender_id in private_contact_request_senders(&scan) { + if let Entry::Vacant(entry) = fetched_identities.entry(sender_id) { + entry.insert(Identity::fetch(&self.sdk, sender_id).await?); + } + } + let read = crypto + .read_private_contact_requests( + &paths, + &contract, + scan, + &fetched_identities, + self.sdk.version(), + ) + .await?; + for sent in &read.sent { + if let Entry::Vacant(entry) = fetched_identities.entry(sent.recipient_id) { + entry.insert(Identity::fetch(&self.sdk, sent.recipient_id).await?); + } + } + + // The wallet's records of the page's requests, under the shared + // keys the signer computes. + let core_height = metadata.core_chain_locked_height; + let mut records: Vec<(Identifier, ContactRequest, bool, Option<[u8; 43]>)> = Vec::new(); + for received in &read.received { + let Some(Some(sender)) = fetched_identities.get(&received.sender_id) else { + continue; + }; + let sender_key = identity_public_key(sender, received.sender_key_id)?; + let shared_secret = crypto + .ecdh_shared_secret( + &key_path(&received.recipient_id, received.recipient_key_id)?, + &sender_key, + ) + .await?; + // Accepting replies privately, by hand: no auto-accept. + let mut record = private_request_record( + received.sender_id, + received.recipient_id, + received.sender_key_id, + received.recipient_key_id, + &received.request, + &received.commitment, + &shared_secret, + core_height, + received.block_time_ms, + ); + record.auto_accept_proof = None; + records.push(( + received.recipient_id, + record, + true, + received.request.shielded_address, + )); + } + for sent in &read.sent { + let recipient_key = sent.recipient_public_key.serialize(); + let recipient_key_id = fetched_identities + .get(&sent.recipient_id) + .and_then(Option::as_ref) + .and_then(|recipient| { + recipient + .public_keys() + .iter() + .find(|(_, key)| key.data().as_slice() == recipient_key.as_slice()) + .map(|(key_id, _)| *key_id) + }); + let Some(recipient_key_id) = recipient_key_id else { + tracing::warn!( + sender = %sent.sender_id, + recipient = %sent.recipient_id, + position = sent.position, + "sent private contact request is sealed to a key its recipient does not \ + hold; not recorded" + ); + continue; + }; + let shared_secret = crypto + .ecdh_shared_secret( + &key_path(&sent.sender_id, sent.sender_key_id)?, + &sent.recipient_public_key, + ) + .await?; + // Its core height is unknown: our receiving account for the + // contact is scanned from the wallet's birth. + let record = private_request_record( + sent.sender_id, + sent.recipient_id, + sent.sender_key_id, + recipient_key_id, + &sent.request, + &sent.commitment, + &shared_secret, + 0, + sent.block_time_ms, + ); + records.push((sent.sender_id, record, false, None)); + } + for rejected in &read.rejected { + tracing::debug!( + position = rejected.position, + reason = ?rejected.reason, + "private contact request refused" + ); + } + report.rejected.extend(read.rejected.iter().cloned()); + + // Ingest what the page held for each identity, then advance its + // cursor past the page if that reached disk. + let mut candidates = Vec::new(); + { + let mut wm = self.wallet_manager.write().await; + let (wallet, info) = + wm.get_wallet_mut_and_info_mut(&self.wallet_id) + .ok_or_else(|| { + PlatformWalletError::WalletNotFound(hex::encode(self.wallet_id)) + })?; + for identity in identities.iter_mut() { + let identity_id = identity.identity_id; + let Some(managed) = info.identity_manager.managed_identity_mut(&identity_id) + else { + continue; + }; + let mut received = Vec::new(); + let mut sent = Vec::new(); + for (owner, record, is_received, shielded_address) in &records { + if *owner != identity_id { + continue; + } + if *is_received { + managed.note_private_contact(record.sender_id, true, *shielded_address); + received.push(record.clone()); + } else { + managed.note_private_contact(record.recipient_id, false, None); + sent.push(record.clone()); + } + } + report.requests.extend(received.iter().cloned()); + report.requests.extend(sent.iter().cloned()); + + let mut rotated = Vec::new(); + let mut ingested = Vec::new(); + let received_ok = ingest_received_requests( + managed, + &self.persister, + identity_id, + newest_received_per_sender(received), + &mut rotated, + &mut ingested, + ); + let sent_ok = ingest_sent_requests( + managed, + &self.persister, + identity_id, + newest_sent_per_recipient(sent), + ); + if received_ok && sent_ok && !identity.failed { + managed.advance_private_contact_requests_position( + identity.position, + read.next_position, + ); + identity.position = managed.dashpay().private_contact_requests_position(); + } else if !identity.failed { + identity.failed = true; + report.unpersisted_identities.push(identity_id); + } + tear_down_stale_external_accounts(wallet, info, &identity_id, rotated); + candidates.push(( + identity_id, + Self::collect_account_build_candidates(info, &identity_id), + )); + } + } + for (identity_id, identity_candidates) in candidates { + for candidate in identity_candidates { + self.build_contact_accounts(&identity_id, candidate).await; + } + } + + report.next_position = read.next_position; + position = read.next_position; + if read.is_complete() { + report.complete = true; + break; + } + } + Ok(report) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::changeset::{ + ClientStartState, PersistenceError, PlatformWalletChangeSet, PlatformWalletPersistence, + }; + use crate::events::{EventHandler, PlatformEventHandler}; + use crate::wallet::identity::network::identity_auth_derivation_path_for_type; + use crate::wallet::identity::network::SeedCryptoProvider; + use crate::wallet::platform_wallet::{PlatformWallet, WalletId}; + use crate::PlatformWalletManager; + use dash_sdk::error::ContextProviderError; + use dash_sdk::platform::private_document_entries::PrivateDocumentEntry; + use dash_sdk::platform::ContextProvider; + use dpp::data_contract::{DataContract, TokenConfiguration}; + use dpp::identity::identity_public_key::v0::IdentityPublicKeyV0; + use dpp::identity::v0::IdentityV0; + use dpp::identity::{IdentityPublicKey, SecurityLevel}; + use dpp::version::PlatformVersion; + use key_wallet::account::account_collection::DashpayAccountKey; + use key_wallet::mnemonic::Mnemonic; + use key_wallet::wallet::initialization::WalletAccountCreationOptions; + use key_wallet::Network; + use platform_encryption::{decrypt_account_label, decrypt_extended_public_key}; + use std::sync::{Arc, Mutex}; + + const ALICE_PHRASE: &str = "abandon abandon abandon abandon abandon abandon abandon abandon \ + abandon abandon abandon about"; + const BOB_PHRASE: &str = + "legal winner thank year wave sausage worth useful legal winner thank yellow"; + const ALICE: [u8; 32] = [0xA1; 32]; + const BOB: [u8; 32] = [0xB1; 32]; + /// The time of the block that appends the test entries. + const BLOCK_TIME_MS: u64 = 1_700_000_000_000; + /// The key ids of each test identity's DashPay keys. + const ENCRYPTION_KEY_ID: KeyID = 1; + const DECRYPTION_KEY_ID: KeyID = 2; + + #[derive(Default)] + struct NoPersistence; + + impl PlatformWalletPersistence for NoPersistence { + fn store( + &self, + _wallet_id: WalletId, + _changeset: PlatformWalletChangeSet, + ) -> Result<(), PersistenceError> { + Ok(()) + } + + fn flush(&self, _wallet_id: WalletId) -> Result<(), PersistenceError> { + Ok(()) + } + + fn load(&self) -> Result { + Ok(ClientStartState::default()) + } + } + + struct NoopEventHandler; + impl EventHandler for NoopEventHandler {} + impl PlatformEventHandler for NoopEventHandler {} + + /// A context provider holding the bundled DashPay contract. + struct DashpayContractProvider(Arc); + + impl ContextProvider for DashpayContractProvider { + fn get_data_contract( + &self, + id: &Identifier, + _platform_version: &PlatformVersion, + ) -> Result>, ContextProviderError> { + Ok((*id == self.0.id()).then(|| Arc::clone(&self.0))) + } + + fn get_token_configuration( + &self, + _token_id: &Identifier, + ) -> Result, ContextProviderError> { + Ok(None) + } + + fn get_quorum_public_key( + &self, + _quorum_type: u32, + _quorum_hash: [u8; 32], + _core_chain_locked_height: u32, + ) -> Result<[u8; 48], ContextProviderError> { + Err(ContextProviderError::Config( + "no quorum keys in this test".into(), + )) + } + + fn get_platform_activation_height(&self) -> Result { + Ok(1) + } + } + + fn seed(phrase: &str) -> [u8; 64] { + *Mnemonic::from_phrase(phrase) + .expect("test mnemonic") + .to_seed("") + } + + /// The identity `id` whose DashPay keys are those of identity index 0 of + /// `phrase`'s seed: its encryption key 1 and decryption key 2. + fn identity(phrase: &str, id: [u8; 32]) -> Identity { + let wallet = key_wallet::wallet::Wallet::from_seed_bytes( + seed(phrase), + Network::Testnet, + WalletAccountCreationOptions::None, + ) + .expect("test wallet"); + let key = |key_id: KeyID, purpose: Purpose| { + let path = identity_auth_derivation_path_for_type( + Network::Testnet, + KeyDerivationType::ECDSA, + 0, + key_id, + ) + .expect("identity key path"); + let xprv = wallet + .derive_extended_private_key(&path) + .expect("identity key"); + let secret_key = + SecretKey::from_secret_bytes(*xprv.private_key.to_bytes()).expect("secret key"); + IdentityPublicKey::V0(IdentityPublicKeyV0 { + id: key_id, + purpose, + security_level: SecurityLevel::MEDIUM, + contract_bounds: None, + key_type: KeyType::ECDSA_SECP256K1, + read_only: false, + data: BinaryData::new(PublicKey::from_secret_key(&secret_key).serialize().to_vec()), + disabled_at: None, + }) + }; + let keys = [ + key(ENCRYPTION_KEY_ID, Purpose::ENCRYPTION), + key(DECRYPTION_KEY_ID, Purpose::DECRYPTION), + ]; + Identity::V0(IdentityV0 { + id: Identifier::from(id), + public_keys: keys.into_iter().map(|key| (key.id(), key)).collect(), + balance: 0, + revision: 0, + }) + } + + /// A wallet of `phrase` holding `own` at identity index 0, on a mock SDK that + /// answers identity fetches with `known` and page 0 of DashPay's private + /// contact request store with `entries`. + async fn wallet( + phrase: &str, + own: &Identity, + known: &[&Identity], + entries: Vec>, + ) -> (PlatformWalletManager, Arc) { + let contract = dashpay_contract().expect("bundled DashPay"); + let mut sdk = dash_sdk::SdkBuilder::new_mock() + .with_network(Network::Testnet) + .with_version(PlatformVersion::latest()) + .with_context_provider(DashpayContractProvider(Arc::clone(&contract))) + .build() + .expect("mock sdk"); + for identity in known { + sdk.mock() + .expect_fetch(identity.id(), Some((*identity).clone())) + .await + .expect("identity expectation"); + } + let total_count = entries.len() as u64; + let page = PrivateDocumentEntries { + entries: entries + .into_iter() + .zip(0u64..) + .map(|(data, position)| PrivateDocumentEntry { + position, + block_height: 10 + position, + block_time_ms: BLOCK_TIME_MS, + data, + }) + .collect(), + total_count, + }; + sdk.mock() + .expect_fetch( + PrivateDocumentEntriesQuery::new( + contract.id(), + PRIVATE_CONTACT_REQUEST_DOCUMENT_TYPE_NAME, + 0, + ), + Some(page), + ) + .await + .expect("page expectation"); + + let manager = PlatformWalletManager::new( + Arc::new(sdk), + Arc::new(NoPersistence), + Arc::new(NoopEventHandler), + ); + let wallet = manager + .create_wallet_from_seed_bytes( + Network::Testnet, + &seed(phrase), + WalletAccountCreationOptions::Default, + Some(0), + ) + .await + .expect("wallet"); + { + let iw = wallet.identity(); + let mut wm = iw.wallet_manager.write().await; + let info = wm + .get_wallet_info_mut(&wallet.wallet_id()) + .expect("wallet info"); + info.identity_manager + .add_identity(own.clone(), 0, wallet.wallet_id(), &iw.persister) + .expect("own identity"); + } + (manager, wallet) + } + + /// A `pay` that keeps the entry instead of paying for it, and answers as + /// the proof of a block at `BLOCK_TIME_MS` would. + fn keep_entry( + kept: &Arc>>, + ) -> impl FnOnce(BinaryData) -> std::future::Ready> + + Send { + let kept = Arc::clone(kept); + move |entry| { + kept.lock().expect("kept entries").push(entry); + std::future::ready(Ok(ResponseMetadata { + time_ms: BLOCK_TIME_MS, + ..Default::default() + })) + } + } + + async fn managed( + wallet: &PlatformWallet, + identity_id: [u8; 32], + read: impl FnOnce(&crate::wallet::identity::ManagedIdentity) -> R, + ) -> R { + let iw = wallet.identity(); + let wm = iw.wallet_manager.read().await; + let managed = wm + .get_wallet_info(&wallet.wallet_id()) + .expect("wallet info") + .identity_manager + .managed_identity(&Identifier::from(identity_id)) + .expect("managed identity"); + read(managed) + } + + /// Alice sends Bob a private request; Bob reads it from the store with + /// Alice checked as its sender, refuses to accept it with a public reply, + /// and accepts it with a private one, which establishes the contact. The + /// request Bob records carries Alice's receiving xpub and label as a + /// document would, under the pair's shared key. + #[tokio::test] + async fn should_send_read_and_accept_a_private_contact_request() { + let alice = identity(ALICE_PHRASE, ALICE); + let bob = identity(BOB_PHRASE, BOB); + let alice_crypto = SeedCryptoProvider::from_seed(seed(ALICE_PHRASE), Network::Testnet); + let bob_crypto = SeedCryptoProvider::from_seed(seed(BOB_PHRASE), Network::Testnet); + let shielded_address = [0x55; 43]; + + let (_alice_manager, alice_wallet) = wallet(ALICE_PHRASE, &alice, &[&bob], vec![]).await; + let kept = Arc::new(Mutex::new(Vec::new())); + let sent = alice_wallet + .identity() + .dashpay() + .send_private_contact_request( + &alice.id(), + &bob.id(), + Some("Alice".to_string()), + AutoAcceptProofSource::None, + Some(shielded_address), + &alice_crypto, + keep_entry(&kept), + ) + .await + .expect("Alice sends the request"); + let entry = kept.lock().expect("kept entries").remove(0); + assert_eq!(entry.len(), 589, "the entry takes DashPay's entry size"); + assert_eq!(sent.sender_key_index, ENCRYPTION_KEY_ID); + assert_eq!(sent.recipient_key_index, DECRYPTION_KEY_ID); + assert_eq!(sent.created_at, BLOCK_TIME_MS); + managed(&alice_wallet, ALICE, |managed| { + assert_eq!( + managed.dashpay().sent_contact_requests().get(&bob.id()), + Some(&sent) + ); + assert!( + managed + .dashpay() + .private_contact(&bob.id()) + .expect("noted") + .sent_privately + ); + }) + .await; + + let (_bob_manager, bob_wallet) = + wallet(BOB_PHRASE, &bob, &[&alice, &bob], vec![entry.to_vec()]).await; + let bob_view = bob_wallet.identity().dashpay(); + let report = bob_view + .sync_private_contact_requests(&bob_crypto, 4) + .await + .expect("Bob reads the store"); + assert_eq!(report.pages_read, 1); + assert!(report.complete); + assert_eq!(report.next_position, 1); + assert_eq!(report.rejected, vec![]); + let received = managed(&bob_wallet, BOB, |managed| { + assert_eq!(managed.dashpay().private_contact_requests_position(), 1); + let private = managed + .dashpay() + .private_contact(&alice.id()) + .expect("noted"); + assert!(private.received_privately); + assert_eq!(private.shielded_address, Some(shielded_address)); + managed + .dashpay() + .incoming_contact_requests() + .get(&alice.id()) + .cloned() + .expect("Bob holds Alice's request") + }) + .await; + assert_eq!(received.sender_key_index, ENCRYPTION_KEY_ID); + assert_eq!(received.recipient_key_index, DECRYPTION_KEY_ID); + assert_eq!(received.account_reference, sent.account_reference); + assert_eq!(received.created_at, BLOCK_TIME_MS); + assert_eq!(received.auto_accept_proof, None); + // The shared key Bob's decryption key and Alice's encryption key agree + // on opens the xpub and label, as it opens a document's. + let bob_key_path = identity_auth_derivation_path_for_type( + Network::Testnet, + KeyDerivationType::ECDSA, + 0, + DECRYPTION_KEY_ID, + ) + .expect("Bob's decryption key path"); + let shared_key = bob_crypto + .ecdh_shared_secret( + &bob_key_path, + &identity_public_key(&alice, ENCRYPTION_KEY_ID).expect("Alice's key"), + ) + .await + .expect("shared key"); + assert_eq!( + decrypt_extended_public_key(&shared_key, &received.encrypted_public_key) + .expect("the xpub opens"), + decrypt_extended_public_key(&shared_key, &sent.encrypted_public_key) + .expect("Alice's record opens with the same key"), + ); + assert_eq!( + received.encrypted_public_key, sent.encrypted_public_key, + "both sides record the same ciphertext" + ); + assert_eq!( + decrypt_account_label( + &shared_key, + received + .encrypted_account_label + .as_deref() + .expect("a label") + ) + .expect("the label opens"), + "Alice" + ); + + // A public reply would publish the relationship. + let signer = simple_signer::signer::SimpleSigner::default(); + assert!(matches!( + bob_view + .accept_contact_request_with_external_signer(&received, &signer, &bob_crypto) + .await, + Err(PlatformWalletError::PrivateContactRequestNeedsPrivateReply(sender)) + if sender == alice.id() + )); + + let reply = Arc::new(Mutex::new(Vec::new())); + let established = bob_view + .accept_private_contact_request(&received, None, &bob_crypto, keep_entry(&reply)) + .await + .expect("Bob accepts privately"); + assert_eq!(established.contact_identity_id, alice.id()); + assert_eq!(established.incoming_request, received); + assert_eq!( + reply.lock().expect("reply").len(), + 1, + "the reply is a private entry" + ); + managed(&bob_wallet, BOB, |managed| { + let private = managed + .dashpay() + .private_contact(&alice.id()) + .expect("noted"); + assert!(private.received_privately && private.sent_privately); + }) + .await; + // Bob can pay Alice: her sending account is registered from the xpub. + { + let iw = bob_wallet.identity(); + let wm = iw.wallet_manager.read().await; + let info = wm + .get_wallet_info(&bob_wallet.wallet_id()) + .expect("wallet info"); + assert!(info + .core_wallet + .accounts + .dashpay_external_accounts + .contains_key(&DashpayAccountKey { + index: 0, + user_identity_id: BOB, + friend_identity_id: ALICE, + })); + } + + // Un-ignoring a sender reads the store again from its start, where the + // requests skipped while it was ignored are. + { + let iw = bob_wallet.identity(); + let mut wm = iw.wallet_manager.write().await; + let managed = wm + .get_wallet_info_mut(&bob_wallet.wallet_id()) + .expect("wallet info") + .identity_manager + .managed_identity_mut(&bob.id()) + .expect("Bob"); + managed.ignore_sender(&alice.id()); + assert_eq!(managed.dashpay().private_contact_requests_position(), 1); + managed.unignore_sender(&alice.id()); + assert_eq!(managed.dashpay().private_contact_requests_position(), 0); + } + } + + /// A wallet restored from seed recovers the private requests it sent from + /// the store alone, recording each as it recorded it when sending. + #[tokio::test] + async fn should_recover_a_sent_private_contact_request_from_the_store() { + let alice = identity(ALICE_PHRASE, ALICE); + let bob = identity(BOB_PHRASE, BOB); + let alice_crypto = SeedCryptoProvider::from_seed(seed(ALICE_PHRASE), Network::Testnet); + + let (_manager, alice_wallet) = wallet(ALICE_PHRASE, &alice, &[&bob], vec![]).await; + let kept = Arc::new(Mutex::new(Vec::new())); + let sent = alice_wallet + .identity() + .dashpay() + .send_private_contact_request( + &alice.id(), + &bob.id(), + None, + AutoAcceptProofSource::None, + None, + &alice_crypto, + keep_entry(&kept), + ) + .await + .expect("Alice sends the request"); + let entry = kept.lock().expect("kept entries").remove(0); + + let (_restored_manager, restored) = + wallet(ALICE_PHRASE, &alice, &[&bob], vec![entry.to_vec()]).await; + let report = restored + .identity() + .dashpay() + .sync_private_contact_requests(&alice_crypto, 4) + .await + .expect("the restored wallet reads the store"); + assert_eq!(report.requests, vec![sent.clone()]); + managed(&restored, ALICE, |managed| { + assert_eq!( + managed.dashpay().sent_contact_requests().get(&bob.id()), + Some(&sent) + ); + }) + .await; + } + + /// The record of a private request rebuilds byte for byte from the same + /// entry, and another entry's record encrypts under other IVs. + #[test] + fn should_record_a_private_request_the_same_way_every_time() { + let request = PrivateContactRequest { + account_reference: 7, + extended_public_key: [0x33; COMPACT_XPUB_LEN], + account_label: Some("label".to_string()), + auto_accept_proof: None, + shielded_address: None, + }; + let record = |commitment: [u8; 32]| { + private_request_record( + Identifier::from(ALICE), + Identifier::from(BOB), + 1, + 2, + &request, + &commitment, + &[9; 32], + 0, + BLOCK_TIME_MS, + ) + }; + assert_eq!(record([1; 32]), record([1; 32])); + assert_ne!( + record([1; 32]).encrypted_public_key, + record([2; 32]).encrypted_public_key, + "another entry encrypts under other IVs" + ); + assert_eq!( + decrypt_extended_public_key(&[9; 32], &record([1; 32]).encrypted_public_key) + .expect("the xpub opens"), + vec![0x33; COMPACT_XPUB_LEN] + ); + } +} diff --git a/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/contact_requests.rs b/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/contact_requests.rs index b16b60ca532..6094079713f 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/contact_requests.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/contact_requests.rs @@ -376,6 +376,7 @@ impl ManagedIdentity { // Rewind the receive cursor so the next sweep re-fetches the // now-un-ignored sender's on-chain requests. self.dashpay.high_water_received_ms = None; + self.dashpay.private_contact_requests_position = 0; let mut cs = ContactChangeSet::default(); cs.unignored.insert((owner_id, *sender_id)); @@ -791,6 +792,42 @@ impl ManagedIdentity { advance_if_unchanged(self.dashpay.high_water_received_ms, snapshot, max_fetched); } + /// Advance the private contact request cursor to `next_position` once + /// every entry before it was read and what it held for this identity + /// ingested. Never moves it back, and leaves it where a concurrent + /// [`Self::unignore_sender`] rewound it from `snapshot`. + pub(crate) fn advance_private_contact_requests_position( + &mut self, + snapshot: u64, + next_position: u64, + ) { + let position = &mut self.dashpay.private_contact_requests_position; + if *position == snapshot && next_position > snapshot { + *position = next_position; + } + } + + /// Record what a private contact request with `contact` carries beyond the + /// [`ContactRequest`]: `received` when it came from the contact, else it + /// went to the contact. A request without a shielded address keeps the one + /// already known. + pub(crate) fn note_private_contact( + &mut self, + contact: Identifier, + received: bool, + shielded_address: Option<[u8; 43]>, + ) { + let entry = self.dashpay.private_contacts.entry(contact).or_default(); + if received { + entry.received_privately = true; + if shielded_address.is_some() { + entry.shielded_address = shielded_address; + } + } else { + entry.sent_privately = true; + } + } + /// Sent-direction counterpart of [`Self::advance_high_water_received`]; /// same compare-and-advance semantics and caller contract. pub fn advance_high_water_sent(&mut self, snapshot: Option, max_fetched: Option) { diff --git a/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/dashpay.rs b/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/dashpay.rs index dbade254b1b..0b626a58165 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/dashpay.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/dashpay.rs @@ -13,6 +13,20 @@ use crate::wallet::identity::{ use dpp::prelude::Identifier; use std::collections::{BTreeMap, BTreeSet}; +/// What the private contact requests between an identity and a contact +/// (protocol version 15) carry beyond what a [`ContactRequest`] holds. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct PrivateContact { + /// The contact's request to this identity came privately, so accepting it + /// replies privately too. + pub received_privately: bool, + /// This identity's request to the contact went privately. + pub sent_privately: bool, + /// The Orchard address the contact asked to be paid at privately: an + /// 11-byte diversifier and a 32-byte transmission key. + pub shielded_address: Option<[u8; 43]>, +} + /// DashPay social state carried by one [`ManagedIdentity`](super::ManagedIdentity). #[derive(Debug, Clone, Default)] pub struct DashPayState { @@ -79,6 +93,22 @@ pub struct DashPayState { /// High-water mark for the sent direction (`$ownerId == me`). pub(super) high_water_sent_ms: Option, + /// The position of DashPay's private contact request store (protocol + /// version 15) to scan from next for this identity: every entry before it + /// was read and what it held for this identity ingested. In memory only, + /// like the high-water marks: a cold start scans the store again from 0, + /// and ingest is a fixpoint. [`ManagedIdentity::unignore_sender`] rewinds + /// it, as it does the received cursor. + /// + /// [`ManagedIdentity::unignore_sender`]: super::ManagedIdentity::unignore_sender + pub(super) private_contact_requests_position: u64, + + /// What this identity's private contact requests carry beyond what a + /// [`ContactRequest`] holds, keyed by the contact: whether the requests + /// came or went privately, and the contact's shielded address. In memory + /// only, rebuilt by the scan a cold start runs. + pub(super) private_contacts: BTreeMap, + /// Lowest Platform-assigned `$createdAtCoreBlockHeight` among OUR sent /// `contactRequest` docs to each recipient seen by a sweep this process. /// @@ -233,6 +263,18 @@ impl DashPayState { .or_else(|| self.sent_contact_requests.get(contact)) } + /// The position of DashPay's private contact request store to scan from + /// next for this identity; see the field doc. + pub fn private_contact_requests_position(&self) -> u64 { + self.private_contact_requests_position + } + + /// What this identity's private contact requests with `contact` carry; see + /// the field doc. + pub fn private_contact(&self, contact: &Identifier) -> Option<&PrivateContact> { + self.private_contacts.get(contact) + } + /// Whether a sent-request sweep completed this process; see the field doc. pub fn sent_sweep_completed(&self) -> bool { self.sent_sweep_completed diff --git a/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/mod.rs b/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/mod.rs index 436dbaaabf6..c11f1e91f8a 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/mod.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/mod.rs @@ -9,7 +9,7 @@ mod dashpay; mod identity_ops; mod sync; -pub use dashpay::DashPayState; +pub use dashpay::{DashPayState, PrivateContact}; // `block_time` + `key_storage` moved to `crate::wallet::identity::types`. // Re-export so every `impl ManagedIdentity` block below keeps working diff --git a/packages/rs-platform-wallet/src/wallet/identity/state/mod.rs b/packages/rs-platform-wallet/src/wallet/identity/state/mod.rs index affa70d0156..05f321ec6ef 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/state/mod.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/state/mod.rs @@ -8,7 +8,7 @@ pub mod managed_identity; pub mod manager; -pub use managed_identity::{BlockTime, DashPayState, ManagedIdentity}; +pub use managed_identity::{BlockTime, DashPayState, ManagedIdentity, PrivateContact}; pub use manager::IdentityLocation; pub use manager::IdentityManager; pub use manager::RegistrationIndex; diff --git a/packages/rs-platform-wallet/src/wallet/platform_wallet.rs b/packages/rs-platform-wallet/src/wallet/platform_wallet.rs index 50a564b04df..6de24a84afd 100644 --- a/packages/rs-platform-wallet/src/wallet/platform_wallet.rs +++ b/packages/rs-platform-wallet/src/wallet/platform_wallet.rs @@ -42,6 +42,15 @@ use dpp::fee::Credits; use dpp::identity::signer::Signer; use dpp::identity::{Identity, IdentityPublicKey}; use dpp::prelude::Identifier; +#[cfg(feature = "shielded")] +use { + super::identity::network::dashpay_contract, + crate::{AutoAcceptProofSource, ContactCryptoProvider, ContactRequest, EstablishedContact}, + dash_sdk::dapi_grpc::platform::v0::ResponseMetadata, + dash_sdk::platform::dashpay::PRIVATE_CONTACT_REQUEST_DOCUMENT_TYPE_NAME, + dpp::data_contract::accessors::v0::DataContractV0Getters, + dpp::platform_value::BinaryData, +}; /// Unique identifier for a wallet (32-byte hash). pub type WalletId = [u8; 32]; @@ -1529,6 +1538,109 @@ impl PlatformWallet { Ok(proven_balance) } + /// Send a DashPay private contact request (protocol version 15) from + /// `sender_identity_id` to `recipient_identity_id`, paying for it from + /// `account`'s shielded notes: nothing on chain names either identity. The + /// request carries `account_label`, `auto_accept_proof` and our + /// `shielded_address` for the contact, and is sealed by `crypto`, which + /// keeps the keys. `seed` supplies the transient spend authority (see + /// [`shielded_transfer_to`](Self::shielded_transfer_to)). See + /// [`DashPayView::send_private_contact_request`](crate::DashPayView::send_private_contact_request). + #[cfg(feature = "shielded")] + #[allow(clippy::too_many_arguments)] + pub async fn send_private_contact_request( + &self, + coordinator: &Arc, + seed: &[u8], + account: u32, + sender_identity_id: &Identifier, + recipient_identity_id: &Identifier, + account_label: Option, + auto_accept_proof: AutoAcceptProofSource, + shielded_address: Option<[u8; 43]>, + crypto: &C, + prover: P, + ) -> Result + where + C: ContactCryptoProvider + Sync, + P: dpp::shielded::builder::OrchardProver + Send + Sync, + { + let keyset = self.derive_spend_keyset(seed, account).await?; + self.identity + .dashpay() + .send_private_contact_request( + sender_identity_id, + recipient_identity_id, + account_label, + auto_accept_proof, + shielded_address, + crypto, + |entry| { + self.pay_private_contact_request(coordinator, keyset, account, entry, prover) + }, + ) + .await + } + + /// Accept a DashPay contact request that came privately, replying + /// privately and paying for the reply from `account`'s shielded notes, as + /// [`send_private_contact_request`](Self::send_private_contact_request) + /// pays. `shielded_address` is the address the contact may pay us at + /// privately. See + /// [`DashPayView::accept_private_contact_request`](crate::DashPayView::accept_private_contact_request). + #[cfg(feature = "shielded")] + #[allow(clippy::too_many_arguments)] + pub async fn accept_private_contact_request( + &self, + coordinator: &Arc, + seed: &[u8], + account: u32, + request: &ContactRequest, + shielded_address: Option<[u8; 43]>, + crypto: &C, + prover: P, + ) -> Result + where + C: ContactCryptoProvider + Sync, + P: dpp::shielded::builder::OrchardProver + Send + Sync, + { + let keyset = self.derive_spend_keyset(seed, account).await?; + self.identity + .dashpay() + .accept_private_contact_request(request, shielded_address, crypto, |entry| { + self.pay_private_contact_request(coordinator, keyset, account, entry, prover) + }) + .await + } + + /// Append the sealed private contact request `entry` to DashPay's store, + /// paying from `account`'s notes, and return the metadata of the proof + /// that it was appended. + #[cfg(feature = "shielded")] + async fn pay_private_contact_request( + &self, + coordinator: &Arc, + keyset: super::shielded::OrchardKeySet, + account: u32, + entry: BinaryData, + prover: P, + ) -> Result { + let dashpay_id = dashpay_contract()?.id(); + super::shielded::operations::private_document_create_from_pool_with_metadata( + &self.sdk, + coordinator.store(), + Some(&self.persister), + self.wallet_id, + &keyset, + account, + dashpay_id, + PRIVATE_CONTACT_REQUEST_DOCUMENT_TYPE_NAME, + vec![entry], + &prover, + ) + .await + } + /// Withdraw from `account`'s notes to a Core L1 address /// (Base58Check string). `core_fee_per_byte` is the L1 fee /// rate (duffs/byte). `seed` supplies the transient spend diff --git a/packages/rs-platform-wallet/src/wallet/shielded/note_selection.rs b/packages/rs-platform-wallet/src/wallet/shielded/note_selection.rs index d6fee1d7ba8..86f1b1fe593 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/note_selection.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/note_selection.rs @@ -8,9 +8,10 @@ use super::store::ShieldedNote; use crate::error::PlatformWalletError; use dpp::fee::Credits; use dpp::shielded::{ - compute_minimum_shielded_fee, compute_shielded_identity_create_fee, - compute_shielded_identity_top_up_fee, compute_shielded_unshield_fee, - compute_shielded_withdrawal_fee, is_allowed_identity_create_denomination, + compute_minimum_shielded_fee, compute_private_document_create_from_shielded_pool_fee, + compute_shielded_identity_create_fee, compute_shielded_identity_top_up_fee, + compute_shielded_unshield_fee, compute_shielded_withdrawal_fee, + is_allowed_identity_create_denomination, }; use dpp::version::PlatformVersion; use dpp::ProtocolError; @@ -50,6 +51,15 @@ pub enum ShieldedFeeKind { /// `compute_shielded_identity_top_up_fee`: IdentityTopUpFromShieldedPool (base plus the flat /// identity-balance write cost, the Unshield model with an identity as the output). IdentityTopUp, + /// `compute_private_document_create_from_shielded_pool_fee`: + /// PrivateDocumentCreateFromShieldedPool (base plus, per entry, its storage and the flat + /// processing fee). Nothing leaves the pool but the fee, so the amount selected for is 0. + PrivateDocumentCreate { + /// How many entries the transition appends. + entry_count: usize, + /// The bytes of each, the private type's `entrySize`. + entry_size: u32, + }, } impl ShieldedFeeKind { @@ -73,6 +83,15 @@ impl ShieldedFeeKind { ShieldedFeeKind::IdentityTopUp => { compute_shielded_identity_top_up_fee(num_actions, platform_version) } + ShieldedFeeKind::PrivateDocumentCreate { + entry_count, + entry_size, + } => compute_private_document_create_from_shielded_pool_fee( + num_actions, + entry_count, + entry_size, + platform_version, + ), } } } @@ -335,6 +354,34 @@ mod tests { assert!(total >= denomination); } + /// A private document create selects notes for its fee alone, the fee consensus pins for + /// its action count and its entries' number and size: one DashPay entry from two actions is + /// the 114,140,000-credit base plus the entry's storage and its processing fee. + #[test] + fn test_select_for_a_private_document_create_covers_its_fee_alone() { + let platform_version = PlatformVersion::latest(); + let kind = ShieldedFeeKind::PrivateDocumentCreate { + entry_count: 1, + entry_size: 589, + }; + let fee = kind.compute(2, platform_version).expect("the fee"); + assert_eq!( + fee, + compute_private_document_create_from_shielded_pool_fee(2, 1, 589, platform_version) + .expect("the consensus fee") + ); + assert_eq!(fee, 114_140_000 + (589 + 32) * 27_400 + 10_000_000); + + let notes = vec![test_note(fee, 0), test_note(fee - 1, 1)]; + let (selected, total, exact_fee) = + select_notes_with_fee(¬es, 0, 2, kind, platform_version).expect("covers the fee"); + assert_eq!((selected.len(), total, exact_fee), (1, fee, fee)); + assert!(matches!( + select_notes_with_fee(¬es[1..], 0, 2, kind, platform_version), + Err(PlatformWalletError::ShieldedInsufficientBalance { .. }) + )); + } + #[test] fn test_select_needs_multiple() { let notes = vec![test_note(100, 0), test_note(200, 1), test_note(150, 2)]; diff --git a/packages/rs-platform-wallet/src/wallet/shielded/operations.rs b/packages/rs-platform-wallet/src/wallet/shielded/operations.rs index 8a8f8a4b39b..a1a16d01cb9 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/operations.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/operations.rs @@ -60,9 +60,11 @@ use dpp::identity::{Identity, IdentityPublicKey}; use dpp::prelude::{Identifier, IdentityNonce}; use dpp::shielded::builder::{ build_identity_create_from_shielded_pool_transition, - build_identity_top_up_from_shielded_pool_transition, build_shield_from_identity_transition, - build_shield_transition, build_shielded_transfer_transition, - build_shielded_withdrawal_transition, build_unshield_transition, OrchardProver, SpendableNote, + build_identity_top_up_from_shielded_pool_transition, + build_private_document_create_from_shielded_pool_transition, + build_shield_from_identity_transition, build_shield_transition, + build_shielded_transfer_transition, build_shielded_withdrawal_transition, + build_unshield_transition, OrchardProver, SpendableNote, }; use dpp::shielded::compute_minimum_shielded_fee; use dpp::state_transition::proof_result::StateTransitionProofResult; @@ -278,6 +280,7 @@ fn shielded_actions(st: &StateTransition) -> &[dpp::shielded::SerializedAction] use dpp::state_transition::identity_top_up_from_shielded_pool_transition::accessors::IdentityTopUpFromShieldedPoolTransitionAccessorsV0; use dpp::state_transition::shield_from_identity_transition::accessors::ShieldFromIdentityTransitionAccessorsV0; use dpp::state_transition::unshield_transition::accessors::UnshieldTransitionAccessorsV0; + use dpp::state_transition::private_document_create_from_shielded_pool_transition::accessors::PrivateDocumentCreateFromShieldedPoolTransitionAccessorsV0; match st { StateTransition::Shield(ShieldTransition::V0(v0)) => &v0.actions, @@ -288,6 +291,7 @@ fn shielded_actions(st: &StateTransition) -> &[dpp::shielded::SerializedAction] StateTransition::IdentityCreateFromShieldedPool(t) => t.actions(), StateTransition::ShieldFromIdentity(t) => t.actions(), StateTransition::IdentityTopUpFromShieldedPool(t) => t.actions(), + StateTransition::PrivateDocumentCreateFromShieldedPool(t) => t.actions(), _ => &[], } } @@ -1553,6 +1557,169 @@ pub(in crate::wallet) async fn identity_top_up_from_pool_with_metadata< } } +// ------------------------------------------------------------------------- +// PrivateDocumentCreateFromShieldedPool: shielded pool -> entries of a +// private document type's store (Type 29) +// ------------------------------------------------------------------------- + +/// Append `entries` to the store of the private document type +/// `document_type_name` of `data_contract_id` (`PrivateDocumentCreateFromShieldedPool`, +/// type 29), paying the fee from `account`'s notes, the change going back to +/// the account. Nothing but the fee leaves the pool. Every entry must take the +/// type's `entrySize`, as `platform_encryption::seal_private_document_entry` +/// builds them. +/// +/// Waits for the spent nullifiers to be proven before marking the notes +/// spent, and returns the metadata of that proof. +#[allow(clippy::too_many_arguments)] +pub(in crate::wallet) async fn private_document_create_from_pool_with_metadata< + S: ShieldedStore, + P: OrchardProver, +>( + sdk: &Arc, + store: &Arc>, + persister: Option<&WalletPersister>, + wallet_id: WalletId, + keys: &OrchardKeySet, + account: u32, + data_contract_id: Identifier, + document_type_name: &str, + entries: Vec, + prover: &P, +) -> Result { + let views = keys.viewing_keys(); + let change_addr = default_orchard_address(&views)?; + let id = SubwalletId::new(wallet_id, account); + let fee_kind = ShieldedFeeKind::PrivateDocumentCreate { + entry_count: entries.len(), + entry_size: entries.first().map_or(0, |entry| entry.len() as u32), + }; + + let (selected_notes, total_input, exact_fee) = + reserve_notes_covering(sdk, store, id, 0, 2, fee_kind).await?; + + info!( + account, + fee = exact_fee, + entries = entries.len(), + inputs = selected_notes.len(), + total_input, + data_contract = %data_contract_id, + document_type = document_type_name, + "PrivateDocumentCreateFromShieldedPool" + ); + + let mut pending_entry = None; + let result = async { + let (spends, anchor) = extract_spends_and_anchor(sdk, store, &selected_notes).await?; + let anchor_bytes = anchor.to_bytes(); + + let (state_transition, fee_used) = + build_private_document_create_from_shielded_pool_transition( + spends, + data_contract_id, + document_type_name.to_string(), + entries, + &change_addr, + &keys.full_viewing_key, + &keys.spend_auth_key, + anchor, + prover, + sdk.version(), + ) + .map_err(|e| PlatformWalletError::ShieldedBuildError(e.to_string()))?; + debug_assert_eq!( + fee_used, exact_fee, + "builder fee must match the reserved private document create fee" + ); + + // The spend's only outputs are change, so it records as an own spend + // with its fee and no counterparty. + pending_entry = record_pending_activity( + store, + persister, + wallet_id, + id, + &views, + LiveEntryParams { + kind: ShieldedActivityKind::ShieldedSpend, + direction: ShieldedDirection::Out, + amount: 0, + fee: Some(fee_used), + counterparty: None, + memo: None, + actions: shielded_actions(&state_transition), + spent_notes: &selected_notes, + }, + ) + .await; + arm_pending_release(store, id, anchor_bytes, &pending_entry, &selected_notes).await; + + trace!("PrivateDocumentCreateFromShieldedPool: state transition built, broadcasting..."); + // Type 29 proves its spent nullifiers as a snapshot, which a competing + // spend of the same notes shares, as type 22's does: the affected-state + // wait accepts it (see `identity_top_up_from_pool_with_metadata`). + broadcast_shielded_spend_with_redrive_with_metadata( + sdk, + store, + id, + &pending_entry, + anchor_bytes, + &selected_notes, + &state_transition, + "private document create from shielded pool", + SpendResultWait::AffectedState, + ) + .await + } + .await; + + match result { + Ok((_proof, metadata)) => { + record_activity_status( + store, + persister, + wallet_id, + id, + &pending_entry, + ShieldedActivityStatus::Confirmed, + None, + ) + .await; + if let Err(e) = finalize_pending(store, persister, wallet_id, id, &selected_notes).await + { + warn!( + account, + error = %e, + "PrivateDocumentCreateFromShieldedPool broadcast succeeded but local \ + spent-state update failed; will heal on next sync" + ); + } + info!( + account, + document_type = document_type_name, + "PrivateDocumentCreateFromShieldedPool broadcast succeeded" + ); + Ok(metadata) + } + Err(e @ PlatformWalletError::ShieldedSpendUnconfirmed { .. }) => Err(e), + Err(e) => { + record_activity_status( + store, + persister, + wallet_id, + id, + &pending_entry, + ShieldedActivityStatus::Failed, + None, + ) + .await; + cancel_pending(store, id, &selected_notes).await; + Err(e) + } + } +} + // ------------------------------------------------------------------------- // ShieldFromAssetLock: Core L1 asset lock -> shielded pool (Type 18) // (orchestrated entry point lives in `wallet/shielded/fund_from_asset_lock.rs`) @@ -2796,7 +2963,22 @@ async fn reserve_unspent_notes( ) -> Result<(Vec, u64, u64), PlatformWalletError> { // All amount-based spends reject zero before waiting on a concurrent sync's store lock. ensure_positive_amount(amount)?; + reserve_notes_covering(sdk, store, id, amount, outputs, fee_kind).await +} +/// Select and reserve, in one write-locked critical section, notes covering +/// `amount` plus the fee of `fee_kind`, which [`reserve_unspent_notes`] does +/// for a positive amount. A spend paying only its fee (an +/// `PrivateDocumentCreateFromShieldedPool`) reserves with an `amount` of 0. +/// Callers must pair this with [`finalize_pending`] / [`cancel_pending`]. +async fn reserve_notes_covering( + sdk: &Arc, + store: &Arc>, + id: SubwalletId, + amount: u64, + outputs: usize, + fee_kind: ShieldedFeeKind, +) -> Result<(Vec, u64, u64), PlatformWalletError> { let mut store = store.write().await; let unspent = store .get_unspent_notes(id) From c3f1b0d71b9339d2ab5c31fb65edea0e464714b1 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Sun, 8 Nov 2026 17:10:24 +0700 Subject: [PATCH 005/117] feat(sdk): sign private contact requests with the sender's signing key A private contact request carried a deniable MAC keyed by the static ECDH of the sender's encryption key and the recipient's key. Anyone holding the recipient's key could compute it, so a stolen recipient key forged requests from any sender, including rotations that redirect the recipient's payments. The request is now signed by one of the sender's signing keys, as its public request's transition is: an enabled AUTHENTICATION key at a level DashPay's documents are signed at (CRITICAL or HIGH), ECDSA_SECP256K1 or ECDSA_HASH160. The entry still names the encryption key, which the pair's shared key and the outgoing viewing key come from; the reader recovers the signing key from the signature. Entries with a MAC are refused. Envelope (signature mode, nothing deployed): the signature now signs the double SHA-256 of the signing data, as identity signers sign data, so a wallet signs with the signer it signs transitions with. The caller passes the signature; `private_document_signing_data`, `sign_private_document_signing_data`, `recover_private_document_signer` and `OpenedPrivateDocumentEntry::recover_signer` are new. Prepared recipient keys are wiped on drop. Reading also checks, at the entry's block time, the recipient key it is encrypted to, and that a sent entry names the identity and key its outgoing viewing key comes from and is signed by that identity. The sender is checked before the payload is decoded. `PrivateDocumentScanKeys::verify_sender` tries every key set of the identity. Co-Authored-By: Claude Opus 5.5 --- packages/rs-platform-encryption/src/lib.rs | 12 +- .../src/private_document_envelope.rs | 240 ++- packages/rs-sdk/src/platform/dashpay/mod.rs | 12 +- .../dashpay/private_contact_request.rs | 1378 +++++++++++++---- .../src/platform/private_document_scan.rs | 72 +- 5 files changed, 1394 insertions(+), 320 deletions(-) diff --git a/packages/rs-platform-encryption/src/lib.rs b/packages/rs-platform-encryption/src/lib.rs index 4cf5a00c44e..e8bf2e5f4a4 100644 --- a/packages/rs-platform-encryption/src/lib.rs +++ b/packages/rs-platform-encryption/src/lib.rs @@ -42,9 +42,11 @@ pub use ecdh::derive_shared_key_ecdh; pub use error::CryptoError; pub use private_document_envelope::{ open_private_document_entry, private_document_outgoing_viewing_key, - recover_sent_private_document_entry, seal_private_document_entry, OpenedPrivateDocumentEntry, - PrivateDocumentAuthMode, PrivateDocumentEnvelopeError, PrivateDocumentRecipient, - PrivateDocumentRecipientKeys, PrivateDocumentSender, PrivateDocumentTarget, - PRIVATE_DOCUMENT_ENVELOPE_BODY_HEADER_LEN, PRIVATE_DOCUMENT_ENVELOPE_OVERHEAD_LEN, - PRIVATE_DOCUMENT_ENVELOPE_VERSION, PRIVATE_DOCUMENT_ENVELOPE_WRAPPER_LEN, + private_document_signing_data, recover_private_document_signer, + recover_sent_private_document_entry, seal_private_document_entry, + sign_private_document_signing_data, OpenedPrivateDocumentEntry, PrivateDocumentAuthMode, + PrivateDocumentEnvelopeError, PrivateDocumentRecipient, PrivateDocumentRecipientKeys, + PrivateDocumentSender, PrivateDocumentTarget, PRIVATE_DOCUMENT_ENVELOPE_BODY_HEADER_LEN, + PRIVATE_DOCUMENT_ENVELOPE_OVERHEAD_LEN, PRIVATE_DOCUMENT_ENVELOPE_VERSION, + PRIVATE_DOCUMENT_ENVELOPE_WRAPPER_LEN, }; diff --git a/packages/rs-platform-encryption/src/private_document_envelope.rs b/packages/rs-platform-encryption/src/private_document_envelope.rs index 4660e463cfc..831f56afb5d 100644 --- a/packages/rs-platform-encryption/src/private_document_envelope.rs +++ b/packages/rs-platform-encryption/src/private_document_envelope.rs @@ -48,8 +48,17 @@ //! `HMAC-SHA256(H("Dash/PrivDoc/v1/mac" || ser(sender key * R)), m)`, then zeros. The //! recipient learns who sent the entry but could have computed the MAC itself, so it cannot //! prove it to anyone; -//! - signature (2): `auth` is a recoverable ECDSA signature of `m` by the sender key, in the -//! compact form (header byte `31 + recovery id`, then `r || s`), which anyone can check. +//! - signature (2): `auth` is a recoverable ECDSA signature of `SHA-256(SHA-256(m))`, in the +//! compact form (header byte `31 + recovery id`, then `r || s`), which anyone who reads the +//! body can check. That is how a Dash identity signer signs the data `m`, so a wallet signs an +//! entry with the signer it signs transitions with. `m` is a 32-byte hash, which no +//! transition's signable bytes can be. The signing key is recovered from the signature. Which keys may sign, and what the sender key id names, +//! is the application's rule: the signer may be the named key, or another key of the sender +//! identity. DashPay contact requests name the sender's encryption key, which the pair's +//! shared key is derived from, and are signed by one of the sender's signing keys. +//! +//! Every auth mode sits inside the encrypted body: only the recipient, and the sender with its +//! outgoing viewing key, see who wrote an entry or its signature. use chacha20poly1305::aead::{Aead, KeyInit, Payload}; use chacha20poly1305::{ChaCha20Poly1305, Key, Nonce}; @@ -91,6 +100,9 @@ pub enum PrivateDocumentEnvelopeError { #[error("a deniable MAC is checked with the recipient's secret key, and none was given")] RecipientSecretKeyRequired, + + #[error("the private document entry carries a deniable MAC, not a signature")] + NotSigned, } /// The envelope version this module builds and opens. @@ -152,18 +164,23 @@ pub enum PrivateDocumentSender<'a> { Anonymous, /// The sender's identity and key, with a MAC under the static ECDH of that key and the /// recipient's. The recipient learns who wrote the entry, but could have computed the MAC - /// itself, so it cannot prove that to anyone. DashPay contact requests use this, with the - /// sender's DashPay encryption key. + /// itself, so it cannot prove that to anyone. Anyone holding the recipient's key can compute + /// it as well, so a stolen recipient key forges entries from any sender. DeniableMac { identity_id: [u8; 32], key_id: u32, secret_key: &'a SecretKey, }, - /// The sender's identity and key, with a signature by that key anyone can check. + /// The sender's identity and a key id, with a signature anyone who reads the body can + /// check: an identity signer's signature of [`private_document_signing_data`] for this + /// entry, or [`sign_private_document_signing_data`]'s. `key_id` names the signing key, or + /// another key of the sender identity when the application finds the signer with + /// [`OpenedPrivateDocumentEntry::recover_signer`]: DashPay names the sender's encryption key + /// and signs with one of its signing keys. Signature { identity_id: [u8; 32], key_id: u32, - secret_key: &'a SecretKey, + signature: &'a [u8; 65], }, } @@ -189,7 +206,8 @@ impl PrivateDocumentAuthMode { /// An entry its recipient opened, or its sender recovered. /// /// The sender fields are what the entry claims; [`Self::verify_sender`] checks the claim -/// against the key the sender's identity holds under `sender_key_id`. The fields are read-only, +/// against the key the sender's identity holds under `sender_key_id`, and +/// [`Self::recover_signer`] gives the key that signed a signed entry. The fields are read-only, /// so what a caller reads is what was authenticated. #[derive(Clone, Debug, PartialEq, Eq)] pub struct OpenedPrivateDocumentEntry { @@ -277,15 +295,7 @@ impl OpenedPrivateDocumentEntry { .map_err(|_| PrivateDocumentEnvelopeError::SenderNotAuthenticated) } PrivateDocumentAuthMode::Signature => { - let recovery_id = RecoveryId::from_u8_masked( - self.auth[0].wrapping_sub(COMPACT_SIGNATURE_HEADER_BASE), - ); - let signature = RecoverableSignature::from_compact(&self.auth[1..], recovery_id) - .map_err(|_| PrivateDocumentEnvelopeError::SenderNotAuthenticated)?; - let signer = signature - .recover_ecdsa(Message::from_digest(self.auth_message)) - .map_err(|_| PrivateDocumentEnvelopeError::SenderNotAuthenticated)?; - if signer == *sender_public_key { + if self.recover_signer()? == *sender_public_key { Ok(()) } else { Err(PrivateDocumentEnvelopeError::SenderNotAuthenticated) @@ -293,6 +303,78 @@ impl OpenedPrivateDocumentEntry { } } } + + /// The key that signed a signed entry, recovered from its signature. The signature is + /// valid under the key returned, so the caller's check is that the key is one the sender + /// identity may sign with, as it was when the entry was written. An anonymous entry is + /// refused with `AnonymousSender`, and one carrying a deniable MAC with `NotSigned`. + pub fn recover_signer(&self) -> Result { + match self.auth_mode { + PrivateDocumentAuthMode::Anonymous => { + Err(PrivateDocumentEnvelopeError::AnonymousSender) + } + PrivateDocumentAuthMode::DeniableMac => Err(PrivateDocumentEnvelopeError::NotSigned), + PrivateDocumentAuthMode::Signature => { + recover_private_document_signer(&self.auth, &self.auth_message) + } + } + } +} + +/// What the sender of a signed entry signs, `m` above: an identity signer signs it as data, +/// that is its double SHA-256. The fields are the entry's, so it is computed before sealing. +pub fn private_document_signing_data( + target: &PrivateDocumentTarget, + sender_identity_id: &[u8; 32], + sender_key_id: u32, + recipient_identity_id: &[u8; 32], + payload: &[u8], +) -> [u8; 32] { + auth_message( + target, + PrivateDocumentAuthMode::Signature, + sender_identity_id, + sender_key_id, + recipient_identity_id, + payload, + ) +} + +/// Signs `signing_data` with `secret_key` as a Dash identity signer does: a recoverable ECDSA +/// signature of its double SHA-256, in the compact form. +pub fn sign_private_document_signing_data( + signing_data: &[u8; 32], + secret_key: &SecretKey, +) -> [u8; 65] { + let (recovery_id, compact) = + RecoverableSignature::sign_ecdsa_recoverable(signed_digest(signing_data), secret_key) + .serialize_compact(); + let mut signature = [0u8; AUTH_LEN]; + signature[0] = COMPACT_SIGNATURE_HEADER_BASE + u8::from(recovery_id); + signature[1..].copy_from_slice(&compact); + signature +} + +/// The digest a signature of `signing_data` signs: its double SHA-256. +fn signed_digest(signing_data: &[u8; 32]) -> Message { + Message::from_digest(Sha256::digest(Sha256::digest(signing_data)).into()) +} + +/// The key a compact signature of `signing_data` recovers to, refused with +/// `SenderNotAuthenticated` when it is no signature. A signer checks its own signature with it +/// before sealing. +pub fn recover_private_document_signer( + signature: &[u8; AUTH_LEN], + signing_data: &[u8; 32], +) -> Result { + if !(COMPACT_SIGNATURE_HEADER_BASE..COMPACT_SIGNATURE_HEADER_BASE + 4).contains(&signature[0]) { + return Err(PrivateDocumentEnvelopeError::SenderNotAuthenticated); + } + let recovery_id = + RecoveryId::from_u8_masked(signature[0].wrapping_sub(COMPACT_SIGNATURE_HEADER_BASE)); + RecoverableSignature::from_compact(&signature[1..], recovery_id) + .and_then(|signature| signature.recover_ecdsa(signed_digest(signing_data))) + .map_err(|_| PrivateDocumentEnvelopeError::SenderNotAuthenticated) } /// The outgoing viewing key a sender derives from a secret of its wallet: with it, and nothing @@ -370,14 +452,11 @@ pub fn seal_private_document_entry( mac.update(&auth_message); auth[..MAC_LEN].copy_from_slice(&mac.finalize().into_bytes()); } - PrivateDocumentSender::Signature { secret_key, .. } => { - let (recovery_id, compact) = RecoverableSignature::sign_ecdsa_recoverable( - Message::from_digest(auth_message), - secret_key, - ) - .serialize_compact(); - auth[0] = COMPACT_SIGNATURE_HEADER_BASE + u8::from(recovery_id); - auth[1..].copy_from_slice(&compact); + PrivateDocumentSender::Signature { signature, .. } => { + // A signature of other data would recover to some other key: the caller checks the + // key, and this that the signature is one. + recover_private_document_signer(signature, &auth_message)?; + auth.copy_from_slice(*signature); } } @@ -495,6 +574,14 @@ impl PrivateDocumentRecipientKeys { } } +impl Drop for PrivateDocumentRecipientKeys { + fn drop(&mut self) { + for (secret_key, _, _) in &mut self.keys { + secret_key.non_secure_erase(); + } + } +} + /// [`PrivateDocumentRecipientKeys::open`] with keys prepared for this one entry; a scan of many /// entries prepares them once instead. pub fn open_private_document_entry( @@ -785,6 +872,20 @@ mod tests { private_document_outgoing_viewing_key(&[0x0F; 32]) } + /// The signature by `secret_key` of the test entry's signing data, naming sender key 4. + fn signature(secret_key: &SecretKey) -> [u8; 65] { + sign_private_document_signing_data( + &private_document_signing_data( + &target(), + &SENDER_IDENTITY, + 4, + &RECIPIENT_IDENTITY, + &payload(), + ), + secret_key, + ) + } + /// An entry to the recipient key `secret_key(2)` from `sender`, built from `esk_seed`. fn seal(sender: &PrivateDocumentSender, esk_seed: u8) -> Vec { seal_private_document_entry( @@ -853,12 +954,12 @@ mod tests { /// A signed entry is checked against the sender's key alone. #[test] fn should_check_a_signature_against_the_sender_key() { - let sender_key = secret_key(1); + let signature = signature(&secret_key(1)); let entry = seal( &PrivateDocumentSender::Signature { identity_id: SENDER_IDENTITY, key_id: 4, - secret_key: &sender_key, + signature: &signature, }, 9, ); @@ -883,6 +984,88 @@ mod tests { .expect("the sender checks its recovered entry with its own key alone"); } + /// The signer of a signed entry is recovered from its signature, whichever key the entry + /// names: DashPay names the encryption key and signs with a signing key. + #[test] + fn should_recover_the_signer_of_a_signed_entry() { + let signature = signature(&secret_key(1)); + let entry = seal( + &PrivateDocumentSender::Signature { + identity_id: SENDER_IDENTITY, + key_id: 4, + signature: &signature, + }, + 9, + ); + let opened = open(&entry, &[secret_key(2)]).expect("it opens"); + assert_eq!(opened.sender_key_id(), 4); + assert_eq!(opened.recover_signer(), Ok(public_key(1))); + let recovered = + recover_sent_private_document_entry(&entry, &target(), &outgoing_viewing_key()) + .expect("well formed") + .expect("the sender built it"); + assert_eq!(recovered.recover_signer(), Ok(public_key(1))); + + // A signature of other data recovers to another key, which the caller's check refuses; + // bytes that are no signature are refused when sealing. + let other_data = sign_private_document_signing_data(&[0x77; 32], &secret_key(1)); + let entry = seal( + &PrivateDocumentSender::Signature { + identity_id: SENDER_IDENTITY, + key_id: 4, + signature: &other_data, + }, + 9, + ); + let opened = open(&entry, &[secret_key(2)]).expect("it opens"); + assert_ne!(opened.recover_signer(), Ok(public_key(1))); + let mut not_a_signature = signature; + not_a_signature[0] = 27; + assert_eq!( + seal_private_document_entry( + &target(), + ENTRY_SIZE, + &PrivateDocumentRecipient { + identity_id: RECIPIENT_IDENTITY, + public_key: &public_key(2), + }, + &PrivateDocumentSender::Signature { + identity_id: SENDER_IDENTITY, + key_id: 4, + signature: ¬_a_signature, + }, + &payload(), + &outgoing_viewing_key(), + &[9; 32], + ), + Err(PrivateDocumentEnvelopeError::SenderNotAuthenticated) + ); + + let mac_key = secret_key(3); + let mac_entry = seal( + &PrivateDocumentSender::DeniableMac { + identity_id: SENDER_IDENTITY, + key_id: 4, + secret_key: &mac_key, + }, + 9, + ); + let opened = open(&mac_entry, &[secret_key(2)]).expect("it opens"); + assert_eq!( + opened.recover_signer(), + Err(PrivateDocumentEnvelopeError::NotSigned) + ); + let anonymous = open( + &seal(&PrivateDocumentSender::Anonymous, 9), + &[secret_key(2)], + ) + .expect("it opens"); + assert_eq!( + anonymous.recover_signer(), + Err(PrivateDocumentEnvelopeError::AnonymousSender) + ); + } + /// An anonymous entry names no sender, and there is nothing to check. #[test] fn should_open_an_anonymous_entry_without_a_sender() { @@ -1153,13 +1336,14 @@ mod tests { #[test] fn should_build_the_same_entry_from_the_same_inputs() { let sender_key = secret_key(1); + let signature = signature(&sender_key); let senders = [ PrivateDocumentSender::Anonymous, deniable_mac_sender(&sender_key), PrivateDocumentSender::Signature { identity_id: SENDER_IDENTITY, key_id: 4, - secret_key: &sender_key, + signature: &signature, }, ]; let pinned: Vec = senders @@ -1178,7 +1362,7 @@ mod tests { [ "abb644128f3e73aade8b6210573fba942709e7e44eaeffef92dc02d5187f37d3", "bedf5084aca776ae520e1395e4a4f92628f4589f04b244630acc890649f74a82", - "a1054055987e953aeeb76deb9998db79eebe3e798a7296eadeb90575bc6e0e56", + "17ce85ed9211640dc929b27aee7c4349208bda374cf9594f83e6c71b71d1d407", ] ); } diff --git a/packages/rs-sdk/src/platform/dashpay/mod.rs b/packages/rs-sdk/src/platform/dashpay/mod.rs index 7241dfb6cb7..17aa6196751 100644 --- a/packages/rs-sdk/src/platform/dashpay/mod.rs +++ b/packages/rs-sdk/src/platform/dashpay/mod.rs @@ -23,14 +23,16 @@ pub use invitation::{ MAX_INVITATION_DUFFS, MAX_INVITATION_TTL_SECS, MIN_INVITATION_DUFFS, }; pub use private_contact_request::{ - private_contact_request_outgoing_viewing_key, private_contact_request_senders, - private_contact_requests_from_scan, seal_private_contact_request, PrivateContactRequest, - PrivateContactRequestRejection, PrivateContactRequestScan, PrivateContactRequestSender, - ReceivedPrivateContactRequest, RejectedPrivateContactRequest, SentPrivateContactRequest, + private_contact_request_identities, private_contact_request_outgoing_viewing_key, + private_contact_request_signature, private_contact_request_signing_data, + private_contact_request_signing_security_levels, private_contact_requests_from_scan, + seal_private_contact_request, PrivateContactRequest, PrivateContactRequestRejection, + PrivateContactRequestScan, PrivateContactRequestSender, ReceivedPrivateContactRequest, + RejectedPrivateContactRequest, SentPrivateContactRequest, PRIVATE_CONTACT_REQUEST_ACCOUNT_LABEL_MAX_BYTES, PRIVATE_CONTACT_REQUEST_AUTO_ACCEPT_PROOF_MAX_LEN, PRIVATE_CONTACT_REQUEST_AUTO_ACCEPT_PROOF_MIN_LEN, PRIVATE_CONTACT_REQUEST_DOCUMENT_TYPE_NAME, - PRIVATE_CONTACT_REQUEST_SHIELDED_ADDRESS_LEN, + PRIVATE_CONTACT_REQUEST_SHIELDED_ADDRESS_LEN, PRIVATE_CONTACT_REQUEST_SIGNING_KEY_TYPES, }; pub use profile::ProfileFields; diff --git a/packages/rs-sdk/src/platform/dashpay/private_contact_request.rs b/packages/rs-sdk/src/platform/dashpay/private_contact_request.rs index e9584e46b4a..24d50f64b96 100644 --- a/packages/rs-sdk/src/platform/dashpay/private_contact_request.rs +++ b/packages/rs-sdk/src/platform/dashpay/private_contact_request.rs @@ -3,15 +3,20 @@ //! A private contact request is an entry of the `privateContactRequest` store sealed to one of //! the recipient's DashPay keys: only the recipient learns that it exists, who sent it and what //! it holds. The sender pays for it from the shielded pool, so no identity appears on chain. -//! Inside the entry the sender names its identity and DashPay encryption key, with a deniable -//! MAC: the recipient learns who wrote it, but cannot prove that to anyone else. +//! Inside the entry the sender names its identity and DashPay encryption key, which the pair's +//! shared key is derived from, and signs it with one of its signing keys, as it signs a public +//! request's transition. The signature is inside the encrypted entry: only the recipient sees +//! it, and could show it to someone else. A stolen recipient key forges nothing, as it forges no +//! public request. //! -//! - Sending: [`seal_private_contact_request`] (or [`Sdk::seal_private_contact_request`]) builds -//! the entry, and a wallet appends it, paying from its notes, with +//! - Sending: [`private_contact_request_signing_data`] is what the sender's identity signer +//! signs, [`seal_private_contact_request`] builds the entry with the signature (or +//! [`Sdk::seal_private_contact_request`] does both), and a wallet appends it, paying from its +//! notes, with //! [`PrivateDocumentCreateFromShieldedPool`](crate::platform::transition::private_document_create_from_shielded_pool::PrivateDocumentCreateFromShieldedPool) //! or the dpp builder. //! - Receiving: [`Sdk::scan_private_contact_requests`] reads a page of the store, opens the -//! wallet's entries, checks each sender against its identity's key and reads the request. +//! wallet's entries, checks each sender against its identity's keys and reads the request. //! //! Each identity seals with the outgoing viewing key of its DashPay encryption key //! ([`private_contact_request_outgoing_viewing_key`]), so a wallet restored from seed recovers @@ -20,27 +25,31 @@ use super::contact_request::{recipient_key_purpose_is_valid, sender_key_purpose_is_valid}; use crate::platform::private_document_scan::{ PrivateDocumentScan, PrivateDocumentScanKeys, ReceivedPrivateDocumentEntry, + SentPrivateDocumentEntry, }; use crate::platform::{Fetch, Identifier}; use crate::{Error, Sdk}; use dpp::data_contract::accessors::v0::DataContractV0Getters; -use dpp::data_contract::document_type::accessors::DocumentTypeV2Getters; +use dpp::data_contract::document_type::accessors::{DocumentTypeV0Getters, DocumentTypeV2Getters}; use dpp::data_contract::document_type::{ decode_private_document_payload, encode_private_document_payload, DocumentTypeRef, }; use dpp::data_contract::DataContract; use dpp::identity::accessors::IdentityGettersV0; use dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; -use dpp::identity::{Identity, IdentityPublicKey, KeyID, KeyType}; +use dpp::identity::signer::Signer; +use dpp::identity::{Identity, IdentityPublicKey, KeyID, KeyType, Purpose, SecurityLevel}; use dpp::platform_value::{BinaryData, Value}; use dpp::secp256k1::rand::rngs::StdRng; use dpp::secp256k1::rand::{RngCore, SeedableRng}; use dpp::secp256k1::{PublicKey, SecretKey}; +use dpp::util::hash::ripemd160_sha256; use dpp::version::PlatformVersion; use platform_encryption::{ - private_document_outgoing_viewing_key, seal_private_document_entry, - PrivateDocumentEnvelopeError, PrivateDocumentRecipient, PrivateDocumentSender, - PrivateDocumentTarget, COMPACT_XPUB_LEN, + private_document_outgoing_viewing_key, private_document_signing_data, + recover_private_document_signer, seal_private_document_entry, OpenedPrivateDocumentEntry, + PrivateDocumentAuthMode, PrivateDocumentEnvelopeError, PrivateDocumentRecipient, + PrivateDocumentSender, PrivateDocumentTarget, COMPACT_XPUB_LEN, }; use std::collections::{BTreeMap, BTreeSet}; use zeroize::Zeroize; @@ -210,21 +219,103 @@ pub fn private_contact_request_outgoing_viewing_key(encryption_secret_key: &Secr outgoing_viewing_key } -/// The sender of a private contact request: its identity, and the id and secret key of the -/// DashPay encryption key it names in the entry. +/// The key types a private contact request may be signed with: those a public request's +/// transition may be signed with, whose signatures recover their key. +pub const PRIVATE_CONTACT_REQUEST_SIGNING_KEY_TYPES: [KeyType; 2] = + [KeyType::ECDSA_SECP256K1, KeyType::ECDSA_HASH160]; + +/// The security levels an `AUTHENTICATION` key may sign a private contact request at: the ones +/// Platform lets a document of the type be signed at, from `CRITICAL` to the type's requirement +/// (`HIGH` unless the contract sets another). `MASTER` keys sign no documents. +pub fn private_contact_request_signing_security_levels( + dashpay_contract: &DataContract, +) -> Result, Error> { + let requirement = private_contact_request_type(dashpay_contract)?.security_level_requirement(); + Ok(if requirement == SecurityLevel::MASTER { + vec![SecurityLevel::MASTER] + } else { + SecurityLevel::full_range() + .into_iter() + .filter(|level| *level != SecurityLevel::MASTER && *level as u8 <= requirement as u8) + .collect() + }) +} + +/// What a private contact request's sender signs, before sealing it: the entry's signing data +/// for `request` from the encryption key `encryption_key_id` of `sender` to the key +/// `recipient_key_id` of `recipient`. The sender's identity signer signs it as data, with one of +/// its keys [`private_contact_request_signing_security_levels`] and +/// [`PRIVATE_CONTACT_REQUEST_SIGNING_KEY_TYPES`] allow, as it signs a public request's +/// transition; [`private_contact_request_signature`] does that. +pub fn private_contact_request_signing_data( + dashpay_contract: &DataContract, + sender: &Identity, + encryption_key_id: KeyID, + recipient: &Identity, + recipient_key_id: KeyID, + request: &PrivateContactRequest, +) -> Result<[u8; 32], Error> { + Ok(SealInputs::new( + dashpay_contract, + sender, + encryption_key_id, + recipient, + recipient_key_id, + request, + )? + .signing_data) +} + +/// `signer`'s signature with `signing_key` of a private contact request's `signing_data`, in the +/// 65-byte compact form the entry carries. +pub async fn private_contact_request_signature( + signer: &S, + signing_key: &IdentityPublicKey, + signing_data: &[u8; 32], +) -> Result<[u8; 65], Error> +where + S: Signer + ?Sized, +{ + let signed = signer.sign(signing_key, signing_data).await?; + let mut signature: [u8; 65] = signed.as_slice().try_into().map_err(|_| { + Error::Generic(format!( + "the signer's signature with key {} is {} bytes, not a 65-byte compact signature", + signing_key.id(), + signed.len() + )) + })?; + // A header for an uncompressed key (27 + recovery id) names the same signature. + if (27..31).contains(&signature[0]) { + signature[0] += 4; + } + Ok(signature) +} + +/// The sender of a private contact request: its identity, the DashPay encryption key it names +/// in the entry, and its signature of the request. pub struct PrivateContactRequestSender<'a> { /// The sending identity. pub identity: &'a Identity, - /// The id of its enabled `ECDSA_SECP256K1` `ENCRYPTION` key. + /// The id of its enabled `ECDSA_SECP256K1` `ENCRYPTION` key, which the pair's shared key is + /// derived from. pub encryption_key_id: KeyID, - /// That key's secret key. + /// That key's secret key, which the outgoing viewing key is derived from. pub encryption_secret_key: &'a SecretKey, + /// The id of the enabled signing key that signed the request. + pub signing_key_id: KeyID, + /// That key's signature of [`private_contact_request_signing_data`]. + pub signature: &'a [u8; 65], } /// Seals `request` from `sender` to the key `recipient_key_id` of `recipient`: an entry of the -/// `privateContactRequest` store of `dashpay_contract`, of the type's entry size. The sender -/// key must be the identity's enabled `ECDSA_SECP256K1` `ENCRYPTION` key whose secret key is -/// given; the recipient key an enabled `ECDSA_SECP256K1` `DECRYPTION` or `ENCRYPTION` key. +/// `privateContactRequest` store of `dashpay_contract`, of the type's entry size. +/// +/// - The encryption key must be the sender's enabled `ECDSA_SECP256K1` `ENCRYPTION` key whose +/// secret key is given. +/// - The signature must be the signing key's, of [`private_contact_request_signing_data`]. The +/// signing key must be an enabled `AUTHENTICATION` key of the sender at a level and of a type +/// that may sign the type's documents. +/// - The recipient key must be an enabled `ECDSA_SECP256K1` `DECRYPTION` or `ENCRYPTION` key. /// /// `esk_seed` must be 32 fresh random bytes for every entry, never reused, not even on a retry /// ([`Sdk::seal_private_contact_request`] draws one). @@ -236,78 +327,61 @@ pub fn seal_private_contact_request( request: &PrivateContactRequest, esk_seed: &[u8; 32], ) -> Result { - let document_type = private_contact_request_type(dashpay_contract)?; - let entry_size = document_type - .private_document() - .map(|config| config.entry_size()) - .ok_or_else(|| { - Error::Generic(format!( - "DashPay {PRIVATE_CONTACT_REQUEST_DOCUMENT_TYPE_NAME} is not a private type" - )) - })?; - - let sender_key = sender - .identity - .get_public_key_by_id(sender.encryption_key_id) - .ok_or_else(|| { - Error::Generic(format!( - "the sender identity {} has no key {}", - sender.identity.id(), - sender.encryption_key_id - )) - })?; - if !sender_key_purpose_is_valid(sender_key.purpose()) { - return Err(Error::Generic(format!( - "the sender key {} is a {} key, not an ENCRYPTION key", - sender.encryption_key_id, - sender_key.purpose() - ))); - } - if ecdh_public_key(sender_key, "sender")? - != PublicKey::from_secret_key(sender.encryption_secret_key) - { + let inputs = SealInputs::new( + dashpay_contract, + sender.identity, + sender.encryption_key_id, + recipient, + recipient_key_id, + request, + )?; + if inputs.encryption_public_key != PublicKey::from_secret_key(sender.encryption_secret_key) { return Err(Error::Generic(format!( "the secret key given is not the one of the sender key {}", sender.encryption_key_id ))); } - - let recipient_key = recipient - .get_public_key_by_id(recipient_key_id) + let signing_key = sender + .identity + .get_public_key_by_id(sender.signing_key_id) .ok_or_else(|| { Error::Generic(format!( - "the recipient identity {} has no key {recipient_key_id}", - recipient.id() + "the sender identity {} has no key {}", + sender.identity.id(), + sender.signing_key_id )) })?; - if !recipient_key_purpose_is_valid(recipient_key.purpose()) { + if let Some(problem) = signing_key_problem( + signing_key, + &private_contact_request_signing_security_levels(dashpay_contract)?, + None, + ) { + return Err(Error::Generic(problem)); + } + let signer = recover_private_document_signer(sender.signature, &inputs.signing_data) + .map_err(|error| Error::Generic(format!("the request's signature: {error}")))?; + if !key_is(signing_key, &signer) { return Err(Error::Generic(format!( - "the recipient key {recipient_key_id} is a {} key, not a DECRYPTION or \ - ENCRYPTION key", - recipient_key.purpose() + "the signature is not the sender key {}'s signature of this request", + sender.signing_key_id ))); } - let recipient_public_key = ecdh_public_key(recipient_key, "recipient")?; - let payload = encode_private_document_payload(document_type, request.to_properties()?)?; let outgoing_viewing_key = private_contact_request_outgoing_viewing_key(sender.encryption_secret_key); let entry = seal_private_document_entry( - &PrivateDocumentTarget { - contract_id: dashpay_contract.id().to_buffer(), - document_type_name: PRIVATE_CONTACT_REQUEST_DOCUMENT_TYPE_NAME, - }, - entry_size as usize, + &inputs.target(), + inputs.entry_size, &PrivateDocumentRecipient { identity_id: recipient.id().to_buffer(), - public_key: &recipient_public_key, + public_key: &inputs.recipient_public_key, }, - &PrivateDocumentSender::DeniableMac { + &PrivateDocumentSender::Signature { identity_id: sender.identity.id().to_buffer(), key_id: sender.encryption_key_id, - secret_key: sender.encryption_secret_key, + signature: sender.signature, }, - &payload, + &inputs.payload, &outgoing_viewing_key, esk_seed, ) @@ -315,29 +389,187 @@ pub fn seal_private_contact_request( Ok(BinaryData::new(entry)) } -/// The public key of an enabled `ECDSA_SECP256K1` identity key, which ECDH runs on. -fn ecdh_public_key(key: &IdentityPublicKey, role: &str) -> Result { +/// What sealing a request takes from its keys and the contract, once checked. +struct SealInputs { + contract_id: [u8; 32], + entry_size: usize, + payload: Vec, + encryption_public_key: PublicKey, + recipient_public_key: PublicKey, + signing_data: [u8; 32], +} + +impl SealInputs { + fn new( + dashpay_contract: &DataContract, + sender: &Identity, + encryption_key_id: KeyID, + recipient: &Identity, + recipient_key_id: KeyID, + request: &PrivateContactRequest, + ) -> Result { + let document_type = private_contact_request_type(dashpay_contract)?; + let entry_size = document_type + .private_document() + .map(|config| config.entry_size()) + .ok_or_else(|| { + Error::Generic(format!( + "DashPay {PRIVATE_CONTACT_REQUEST_DOCUMENT_TYPE_NAME} is not a private type" + )) + })?; + let encryption_public_key = + encryption_key(sender, encryption_key_id, None).map_err(Error::Generic)?; + + let recipient_key = recipient + .get_public_key_by_id(recipient_key_id) + .ok_or_else(|| { + Error::Generic(format!( + "the recipient identity {} has no key {recipient_key_id}", + recipient.id() + )) + })?; + if !recipient_key_purpose_is_valid(recipient_key.purpose()) { + return Err(Error::Generic(format!( + "the recipient key {recipient_key_id} is a {} key, not a DECRYPTION or \ + ENCRYPTION key", + recipient_key.purpose() + ))); + } + let recipient_public_key = + ecdh_public_key(recipient_key, "recipient", None).map_err(Error::Generic)?; + + let payload = encode_private_document_payload(document_type, request.to_properties()?)?; + let contract_id = dashpay_contract.id().to_buffer(); + let signing_data = private_document_signing_data( + &PrivateDocumentTarget { + contract_id, + document_type_name: PRIVATE_CONTACT_REQUEST_DOCUMENT_TYPE_NAME, + }, + &sender.id().to_buffer(), + encryption_key_id, + &recipient.id().to_buffer(), + &payload, + ); + Ok(Self { + contract_id, + entry_size: entry_size as usize, + payload, + encryption_public_key, + recipient_public_key, + signing_data, + }) + } + + fn target(&self) -> PrivateDocumentTarget<'static> { + PrivateDocumentTarget { + contract_id: self.contract_id, + document_type_name: PRIVATE_CONTACT_REQUEST_DOCUMENT_TYPE_NAME, + } + } +} + +/// The public key of `identity`'s DashPay encryption key `key_id`: an `ECDSA_SECP256K1` +/// `ENCRYPTION` key, enabled at `at_ms` (now when `None`). +fn encryption_key( + identity: &Identity, + key_id: KeyID, + at_ms: Option, +) -> Result { + let key = identity + .get_public_key_by_id(key_id) + .ok_or_else(|| format!("the sender identity {} has no key {key_id}", identity.id()))?; + if !sender_key_purpose_is_valid(key.purpose()) { + return Err(format!( + "the sender key {key_id} is a {} key, not an ENCRYPTION key", + key.purpose() + )); + } + ecdh_public_key(key, "sender", at_ms) +} + +/// The public key of an `ECDSA_SECP256K1` identity key enabled at `at_ms` (now when `None`), +/// which ECDH runs on. A key disabled after `at_ms` was enabled then: keys are disabled, never +/// removed. +fn ecdh_public_key( + key: &IdentityPublicKey, + role: &str, + at_ms: Option, +) -> Result { if key.key_type() != KeyType::ECDSA_SECP256K1 { - return Err(Error::Generic(format!( + return Err(format!( "the {role} key {} is a {} key, not ECDSA_SECP256K1", key.id(), key.key_type() - ))); + )); } - if key.disabled_at().is_some() { - return Err(Error::Generic(format!( - "the {role} key {} is disabled", - key.id() - ))); + if let Some(problem) = disabled_problem(key, role, at_ms) { + return Err(problem); } PublicKey::from_slice(key.data().as_slice()).map_err(|error| { - Error::Generic(format!( + format!( "the {role} key {} is not a secp256k1 point: {error}", key.id() - )) + ) }) } +/// Why `key` was not enabled at `at_ms` (now when `None`), if it was not. +fn disabled_problem(key: &IdentityPublicKey, role: &str, at_ms: Option) -> Option { + let disabled_at = key.disabled_at()?; + match at_ms { + None => Some(format!("the {role} key {} is disabled", key.id())), + Some(at_ms) if disabled_at <= at_ms => Some(format!( + "the {role} key {} was disabled at {disabled_at}, before the entry", + key.id() + )), + Some(_) => None, + } +} + +/// Why `key` may not sign a private contact request at `at_ms` (now when `None`), if it may +/// not: it must be an `AUTHENTICATION` key at one of `levels`, of a signing key type, and +/// enabled then. +fn signing_key_problem( + key: &IdentityPublicKey, + levels: &[SecurityLevel], + at_ms: Option, +) -> Option { + if key.purpose() != Purpose::AUTHENTICATION { + return Some(format!( + "the signing key {} is a {} key, not an AUTHENTICATION key", + key.id(), + key.purpose() + )); + } + if !levels.contains(&key.security_level()) { + return Some(format!( + "the signing key {} is a {} key, which signs no {PRIVATE_CONTACT_REQUEST_DOCUMENT_TYPE_NAME}", + key.id(), + key.security_level() + )); + } + if !PRIVATE_CONTACT_REQUEST_SIGNING_KEY_TYPES.contains(&key.key_type()) { + return Some(format!( + "the signing key {} is a {} key, whose signature names no key", + key.id(), + key.key_type() + )); + } + disabled_problem(key, "signing", at_ms) +} + +/// Whether the identity key `key` is the secp256k1 key `public_key`. +fn key_is(key: &IdentityPublicKey, public_key: &PublicKey) -> bool { + match key.key_type() { + KeyType::ECDSA_SECP256K1 => PublicKey::from_slice(key.data().as_slice()) + .is_ok_and(|key_public_key| key_public_key == *public_key), + KeyType::ECDSA_HASH160 => { + key.data().as_slice() == ripemd160_sha256(&public_key.serialize()).as_slice() + } + _ => false, + } +} + fn private_contact_request_type( dashpay_contract: &DataContract, ) -> Result, Error> { @@ -367,7 +599,7 @@ pub struct ReceivedPrivateContactRequest { pub recipient_key_id: KeyID, /// The identity that sent it. pub sender_id: Identifier, - /// The sender's encryption key it names, which wrote its MAC. + /// The sender's encryption key it names, which the pair's shared key is derived from. pub sender_key_id: KeyID, /// The entry's commitment: a copy of the entry appended again has the same one. pub commitment: [u8; 32], @@ -407,16 +639,25 @@ pub enum PrivateContactRequestRejection { Envelope(PrivateDocumentEnvelopeError), /// It names no sender, and a contact request is from someone. AnonymousSender, + /// It carries a deniable MAC instead of a signature: anyone holding the recipient's key + /// could have written that, so it proves nothing about its sender. + NotSigned, /// The identity it names as its sender does not exist. SenderNotFound, - /// The sender key it names is missing, not an `ECDSA_SECP256K1` `ENCRYPTION` key, or was - /// disabled before the entry was appended. + /// The encryption key it names is missing, not an `ECDSA_SECP256K1` `ENCRYPTION` key, or + /// was disabled before the entry was appended; or the sender key that signed it may not + /// sign the type's documents, or was disabled before the entry was appended. SenderKeyNotUsable(String), - /// The sender key it names did not write it. + /// No key of the sender signed it; or, for a sent entry, it names another identity or key + /// than the outgoing viewing key that recovered it is derived from. SenderNotAuthenticated, - /// Its payload is not a valid private contact request. One written after DashPay gained a - /// property the contract the SDK holds lacks reads as invalid until the contract is - /// refetched, so a wallet keeps the position to read again. + /// The recipient key it is encrypted to was disabled before it was appended, or the + /// recipient identity was not given to check it. + RecipientKeyNotUsable(String), + /// Its payload is not a valid private contact request. The sender is checked first, so the + /// sender wrote it so. One written after DashPay gained a property the contract the SDK + /// holds lacks reads as invalid until the contract is refetched, so a wallet keeps the + /// position to read again. InvalidRequest(String), /// An earlier entry of the scan holds a request from the same sender to the same /// recipient for the same account reference, which is kept. @@ -456,35 +697,58 @@ impl PrivateContactRequestScan { } } -/// The identity ids the received entries of `scan` name as their senders, which -/// [`private_contact_requests_from_scan`] checks them against. -pub fn private_contact_request_senders(scan: &PrivateDocumentScan) -> BTreeSet { +/// The identities whose keys [`private_contact_requests_from_scan`] checks the entries of +/// `scan` against: the senders received entries name, the wallet's identities they are to, and +/// the wallet's identities that sent the recovered ones. +pub fn private_contact_request_identities(scan: &PrivateDocumentScan) -> BTreeSet { scan.received .iter() - .map(|received| Identifier::from(*received.entry.sender_identity_id())) - .filter(|sender_id| *sender_id != Identifier::default()) + .flat_map(|received| { + [ + Identifier::from(*received.entry.sender_identity_id()), + received.recipient_identity_id, + ] + }) + .chain(scan.sent.iter().map(|sent| sent.sender_identity_id)) + .filter(|identity_id| *identity_id != Identifier::default()) .collect() } -/// Reads the private contact requests of a scan of DashPay's `privateContactRequest` store: -/// decodes each payload, checks each received entry's sender against the key its identity, as -/// fetched into `sender_identities` (`None` for one that does not exist), holds under the id -/// the entry names, and keeps the first request from a sender to a recipient for an account -/// reference. A wallet keeps the requests it already holds from earlier scans, and drops a -/// later one from the same sender for the same account reference as this does. +/// Reads the private contact requests of a scan of DashPay's `privateContactRequest` store. +/// +/// `identities` holds the identities [`private_contact_request_identities`] lists, as fetched +/// (`None` for one that does not exist). Each entry, received or sent, is checked before its +/// payload is read: +/// - it must be signed, and its sender identity must exist; +/// - the encryption key it names must be the sender's `ECDSA_SECP256K1` `ENCRYPTION` key, +/// enabled when the entry was appended; +/// - its signature must be by a key of the sender that may sign the type's documents, enabled +/// then; +/// - a received entry's recipient key must have been enabled then too; +/// - a sent entry must name the identity and key its outgoing viewing key is derived from. +/// +/// The first request from a sender to a recipient for an account reference is kept. A wallet +/// keeps the requests it already holds from earlier scans, and drops a later one from the same +/// sender for the same account reference as this does. pub fn private_contact_requests_from_scan( dashpay_contract: &DataContract, scan: PrivateDocumentScan, - keys: &PrivateDocumentScanKeys, - sender_identities: &BTreeMap>, + identities: &BTreeMap>, platform_version: &PlatformVersion, ) -> Result { let document_type = private_contact_request_type(dashpay_contract)?; - let decode = |payload: &[u8]| -> Result { - let properties = decode_private_document_payload(document_type, payload, platform_version) - .map_err(|error| PrivateContactRequestRejection::InvalidRequest(error.to_string()))?; - PrivateContactRequest::from_properties(properties) - .map_err(|error| PrivateContactRequestRejection::InvalidRequest(error.to_string())) + let signing_levels = private_contact_request_signing_security_levels(dashpay_contract)?; + let reader = EntryReader { + identities, + signing_levels: &signing_levels, + decode: &|payload: &[u8]| { + let properties = + decode_private_document_payload(document_type, payload, platform_version).map_err( + |error| PrivateContactRequestRejection::InvalidRequest(error.to_string()), + )?; + PrivateContactRequest::from_properties(properties) + .map_err(|error| PrivateContactRequestRejection::InvalidRequest(error.to_string())) + }, }; let mut result = PrivateContactRequestScan { @@ -509,7 +773,7 @@ pub fn private_contact_requests_from_scan( block_height: received.block_height, reason, }; - match read_received(&received, keys, sender_identities, &decode) { + match reader.read_received(&received) { Ok(request) => { let sender_id = Identifier::from(*received.entry.sender_identity_id()); if !seen.insert(( @@ -537,7 +801,7 @@ pub fn private_contact_requests_from_scan( } for sent in scan.sent { - match decode(sent.entry.payload()) { + match reader.read_sent(&sent) { Ok(request) => result.sent.push(SentPrivateContactRequest { position: sent.position, block_height: sent.block_height, @@ -562,77 +826,166 @@ pub fn private_contact_requests_from_scan( Ok(result) } -/// The request a received entry holds, once its sender is checked. -fn read_received( - received: &ReceivedPrivateDocumentEntry, - keys: &PrivateDocumentScanKeys, - sender_identities: &BTreeMap>, - decode: &impl Fn(&[u8]) -> Result, -) -> Result { - let sender_id = Identifier::from(*received.entry.sender_identity_id()); - if sender_id == Identifier::default() { - return Err(PrivateContactRequestRejection::AnonymousSender); - } - let request = decode(received.entry.payload())?; - let sender = sender_identities - .get(&sender_id) - .and_then(Option::as_ref) - .ok_or(PrivateContactRequestRejection::SenderNotFound)?; - let key_id = received.entry.sender_key_id(); - let unusable = |message: String| PrivateContactRequestRejection::SenderKeyNotUsable(message); - let key = sender - .get_public_key_by_id(key_id) - .ok_or_else(|| unusable(format!("the sender has no key {key_id}")))?; - if !sender_key_purpose_is_valid(key.purpose()) { - return Err(unusable(format!( - "the sender key {key_id} is a {} key, not an ENCRYPTION key", - key.purpose() - ))); +type DecodeRequest<'a> = + dyn Fn(&[u8]) -> Result + 'a; + +/// Reads the entries of one scan against the identities fetched for it. +struct EntryReader<'a> { + identities: &'a BTreeMap>, + signing_levels: &'a [SecurityLevel], + decode: &'a DecodeRequest<'a>, +} + +impl EntryReader<'_> { + fn identity(&self, identity_id: &Identifier) -> Option<&Identity> { + self.identities.get(identity_id).and_then(Option::as_ref) } - if key.key_type() != KeyType::ECDSA_SECP256K1 { - return Err(unusable(format!( - "the sender key {key_id} is a {} key, not ECDSA_SECP256K1", - key.key_type() - ))); + + /// The request a received entry holds, once its sender and recipient key are checked. + fn read_received( + &self, + received: &ReceivedPrivateDocumentEntry, + ) -> Result { + self.check_sender(&received.entry, received.block_time_ms)?; + let recipient_key_problem = match self.identity(&received.recipient_identity_id) { + None => Some("the recipient identity was not given".to_string()), + Some(recipient) => match recipient.get_public_key_by_id(received.recipient_key_id) { + None => Some(format!( + "the recipient has no key {}", + received.recipient_key_id + )), + Some(key) => disabled_problem(key, "recipient", Some(received.block_time_ms)), + }, + }; + if let Some(problem) = recipient_key_problem { + return Err(PrivateContactRequestRejection::RecipientKeyNotUsable( + problem, + )); + } + (self.decode)(received.entry.payload()) } - // A key disabled after the entry was appended still wrote it: keys are disabled, never - // removed, and the entry is immutable. - if let Some(disabled_at) = key.disabled_at() { - if disabled_at <= received.block_time_ms { - return Err(unusable(format!( - "the sender key {key_id} was disabled at {disabled_at}, before the entry" - ))); + + /// The request a sent entry holds, once it is checked to be the identity's own: someone + /// else holding its encryption key could build entries its outgoing viewing key recovers. + fn read_sent( + &self, + sent: &SentPrivateDocumentEntry, + ) -> Result { + if Identifier::from(*sent.entry.sender_identity_id()) != sent.sender_identity_id + || sent.entry.sender_key_id() != sent.outgoing_key_id + { + return Err(PrivateContactRequestRejection::SenderNotAuthenticated); } + self.check_sender(&sent.entry, sent.block_time_ms)?; + (self.decode)(sent.entry.payload()) } - let sender_public_key = PublicKey::from_slice(key.data().as_slice()).map_err(|error| { - unusable(format!( - "the sender key {key_id} is not a secp256k1 point: {error}" + + /// Checks that the identity an entry names as its sender wrote it, as the identity was + /// when the entry was appended at `block_time_ms`. + fn check_sender( + &self, + entry: &OpenedPrivateDocumentEntry, + block_time_ms: u64, + ) -> Result<(), PrivateContactRequestRejection> { + let sender_id = Identifier::from(*entry.sender_identity_id()); + match entry.auth_mode() { + PrivateDocumentAuthMode::Anonymous => { + return Err(PrivateContactRequestRejection::AnonymousSender) + } + // Anyone holding the recipient's key could have written a deniable MAC. + PrivateDocumentAuthMode::DeniableMac => { + return Err(PrivateContactRequestRejection::NotSigned) + } + PrivateDocumentAuthMode::Signature => {} + } + if sender_id == Identifier::default() { + return Err(PrivateContactRequestRejection::AnonymousSender); + } + let sender = self + .identity(&sender_id) + .ok_or(PrivateContactRequestRejection::SenderNotFound)?; + encryption_key(sender, entry.sender_key_id(), Some(block_time_ms)) + .map_err(PrivateContactRequestRejection::SenderKeyNotUsable)?; + let signer = entry + .recover_signer() + .map_err(|_| PrivateContactRequestRejection::SenderNotAuthenticated)?; + let mut problem = None; + for key in sender.public_keys().values() { + if !key_is(key, &signer) { + continue; + } + match signing_key_problem(key, self.signing_levels, Some(block_time_ms)) { + None => return Ok(()), + Some(key_problem) => { + problem.get_or_insert(key_problem); + } + } + } + Err(problem.map_or( + PrivateContactRequestRejection::SenderNotAuthenticated, + PrivateContactRequestRejection::SenderKeyNotUsable, )) - })?; - keys.verify_sender(received, &sender_public_key) - .map_err(|_| PrivateContactRequestRejection::SenderNotAuthenticated)?; - Ok(request) + } } impl Sdk { - /// Seals `request` from `sender` to the key `recipient_key_id` of `recipient`, as - /// [`seal_private_contact_request`] does, with the DashPay contract and a fresh ephemeral - /// seed. The entry is appended by a transition paid from the sender's notes. - pub async fn seal_private_contact_request( + /// Seals `request` from the encryption key `encryption_key_id` of `sender`, whose secret key + /// is given, to the key `recipient_key_id` of `recipient`, as + /// [`seal_private_contact_request`] does: with the DashPay contract, a fresh ephemeral seed, + /// and `signer`'s signature with the first of the sender's keys that may sign the request + /// and that it holds. The entry is appended by a transition paid from the sender's notes. + #[allow(clippy::too_many_arguments)] + pub async fn seal_private_contact_request( &self, - sender: &PrivateContactRequestSender<'_>, + sender: &Identity, + encryption_key_id: KeyID, + encryption_secret_key: &SecretKey, + signer: &S, recipient: &Identity, recipient_key_id: KeyID, request: &PrivateContactRequest, - ) -> Result { + ) -> Result + where + S: Signer + ?Sized, + { let dashpay_contract = self.fetch_dashpay_contract().await?; + let levels = private_contact_request_signing_security_levels(&dashpay_contract)?; + let signing_key = sender + .public_keys() + .values() + .find(|key| { + signing_key_problem(key, &levels, None).is_none() && signer.can_sign_with(key) + }) + .ok_or_else(|| { + Error::Generic(format!( + "the signer holds no key of {} that signs a private contact request: an \ + enabled AUTHENTICATION key at {levels:?}", + sender.id() + )) + })?; + let signing_data = private_contact_request_signing_data( + &dashpay_contract, + sender, + encryption_key_id, + recipient, + recipient_key_id, + request, + )?; + let signature = + private_contact_request_signature(signer, signing_key, &signing_data).await?; // A seed whose hash is no secret key, a chance of about 2^-128, fails the seal, and a // retry draws another. let mut esk_seed = [0u8; 32]; StdRng::from_os_rng().fill_bytes(&mut esk_seed); let sealed = seal_private_contact_request( &dashpay_contract, - sender, + &PrivateContactRequestSender { + identity: sender, + encryption_key_id, + encryption_secret_key, + signing_key_id: signing_key.id(), + signature: &signature, + }, recipient, recipient_key_id, request, @@ -644,8 +997,8 @@ impl Sdk { /// Reads the page of DashPay's `privateContactRequest` store from `start_position` and /// returns the requests it holds for the wallet whose `keys` are given: those to its - /// identities, each sender checked against the identity key the entry names, and those its - /// identities sent. The sender identities are fetched once each. + /// identities and those its identities sent, each checked against the identities' keys as + /// [`private_contact_requests_from_scan`] says. The identities are fetched once each. /// /// A wallet keeps [`PrivateContactRequestScan::next_position`] as its cursor once it has /// stored what the page held, and scans again from it until @@ -665,17 +1018,11 @@ impl Sdk { keys, ) .await?; - let mut sender_identities = BTreeMap::new(); - for sender_id in private_contact_request_senders(&scan) { - sender_identities.insert(sender_id, Identity::fetch(self, sender_id).await?); + let mut identities = BTreeMap::new(); + for identity_id in private_contact_request_identities(&scan) { + identities.insert(identity_id, Identity::fetch(self, identity_id).await?); } - private_contact_requests_from_scan( - &dashpay_contract, - scan, - keys, - &sender_identities, - self.version(), - ) + private_contact_requests_from_scan(&dashpay_contract, scan, &identities, self.version()) } } @@ -683,11 +1030,14 @@ impl Sdk { mod tests { use super::*; use crate::platform::private_document_entries::{PrivateDocumentEntries, PrivateDocumentEntry}; + use dpp::address_funds::AddressWitness; use dpp::data_contract::document_type::accessors::DocumentTypeV0Getters; use dpp::data_contract::document_type::DocumentPropertyType; use dpp::identity::identity_public_key::v0::IdentityPublicKeyV0; - use dpp::identity::{IdentityV0, Purpose, SecurityLevel}; + use dpp::identity::IdentityV0; use dpp::system_data_contracts::{load_system_data_contract, SystemDataContract}; + use dpp::ProtocolError; + use platform_encryption::sign_private_document_signing_data; const SENDER_ID: [u8; 32] = [0xA0; 32]; const RECIPIENT_ID: [u8; 32] = [0xB0; 32]; @@ -713,7 +1063,12 @@ mod tests { IdentityPublicKey::V0(IdentityPublicKeyV0 { id, purpose, - security_level: SecurityLevel::MEDIUM, + // Signing keys sign at HIGH, which DashPay's types require. + security_level: if purpose == Purpose::AUTHENTICATION { + SecurityLevel::HIGH + } else { + SecurityLevel::MEDIUM + }, contract_bounds: None, key_type, read_only: false, @@ -731,7 +1086,14 @@ mod tests { }) } - /// The sender, whose encryption key 1 is `secret_key(1)`, disabled at `disabled_at`. + /// `identity` with `key` added, or replacing its key of the same id. + fn with_key(mut identity: Identity, key: IdentityPublicKey) -> Identity { + identity.add_public_key(key); + identity + } + + /// The sender, whose signing key 0 is `secret_key(9)` and whose encryption key 1 is + /// `secret_key(1)`, disabled at `disabled_at`. fn sender_identity(disabled_at: Option) -> Identity { identity( SENDER_ID, @@ -816,6 +1178,30 @@ mod tests { } } + /// The signature by `signing_secret` of `request` from the sender's encryption key 1 to the + /// recipient's key 2, as an identity signer makes it. + fn signature( + contract: &DataContract, + sender: &Identity, + request: &PrivateContactRequest, + signing_secret: &SecretKey, + ) -> [u8; 65] { + sign_private_document_signing_data( + &private_contact_request_signing_data( + contract, + sender, + 1, + &recipient_identity(), + 2, + request, + ) + .expect("the keys check"), + signing_secret, + ) + } + + /// `request` from the sender's encryption key 1, signed by its key 0, to the recipient's + /// key 2. fn seal( contract: &DataContract, sender: &Identity, @@ -828,6 +1214,8 @@ mod tests { identity: sender, encryption_key_id: 1, encryption_secret_key: &secret_key(1), + signing_key_id: 0, + signature: &signature(contract, sender, request, &secret_key(9)), }, &recipient_identity(), 2, @@ -838,6 +1226,41 @@ mod tests { .to_vec() } + /// An entry of `payload` to `recipient_key` of `recipient_id`, from `sender`, sealed with + /// the sender's outgoing viewing key: one no wallet of these tests' would build. + fn seal_raw( + contract: &DataContract, + recipient_id: [u8; 32], + recipient_key: &SecretKey, + sender: &PrivateDocumentSender, + payload: &[u8], + ) -> Vec { + seal_private_document_entry( + &PrivateDocumentTarget { + contract_id: contract.id().to_buffer(), + document_type_name: PRIVATE_CONTACT_REQUEST_DOCUMENT_TYPE_NAME, + }, + 589, + &PrivateDocumentRecipient { + identity_id: recipient_id, + public_key: &PublicKey::from_secret_key(recipient_key), + }, + sender, + payload, + &private_contact_request_outgoing_viewing_key(&secret_key(1)), + &[9; 32], + ) + .expect("the payload fits") + } + + fn payload(contract: &DataContract, request: &PrivateContactRequest) -> Vec { + encode_private_document_payload( + private_contact_request_type(contract).expect("DashPay v3"), + request.to_properties().expect("in bounds"), + ) + .expect("the payload encodes") + } + fn page(entries: Vec>) -> PrivateDocumentEntries { PrivateDocumentEntries { total_count: entries.len() as u64, @@ -854,40 +1277,53 @@ mod tests { } } - /// The recipient's keys, and the sender's outgoing viewing key. - fn scan_keys() -> PrivateDocumentScanKeys { + /// The recipient's keys alone. + fn recipient_keys() -> PrivateDocumentScanKeys { PrivateDocumentScanKeys::new() .with_recipient(Identifier::from(RECIPIENT_ID), &[(2, secret_key(2))]) - .with_sender( - Identifier::from(SENDER_ID), - private_contact_request_outgoing_viewing_key(&secret_key(1)), - ) } + /// The recipient's keys, and the sender's outgoing viewing key, of its encryption key 1. + fn scan_keys() -> PrivateDocumentScanKeys { + recipient_keys().with_sender( + Identifier::from(SENDER_ID), + 1, + private_contact_request_outgoing_viewing_key(&secret_key(1)), + ) + } + + /// Reads `entries` with `keys`, fetching the identities from `identities`: one not there + /// does not exist. fn read( contract: &DataContract, entries: Vec>, - sender: Option, + keys: &PrivateDocumentScanKeys, + identities: &[Identity], ) -> PrivateContactRequestScan { - let keys = scan_keys(); let target = PrivateDocumentTarget { contract_id: contract.id().to_buffer(), document_type_name: PRIVATE_CONTACT_REQUEST_DOCUMENT_TYPE_NAME, }; let scan = keys.scan_page(&target, 0, &page(entries)); - let senders = private_contact_request_senders(&scan); - let sender_identities = senders + let fetched = private_contact_request_identities(&scan) .into_iter() - .map(|sender_id| (sender_id, sender.clone())) + .map(|identity_id| { + let identity = identities + .iter() + .find(|identity| identity.id() == identity_id) + .cloned(); + (identity_id, identity) + }) .collect(); - private_contact_requests_from_scan( - contract, - scan, - &keys, - &sender_identities, - PlatformVersion::latest(), - ) - .expect("DashPay declares private contact requests") + private_contact_requests_from_scan(contract, scan, &fetched, PlatformVersion::latest()) + .expect("DashPay declares private contact requests") + } + + fn reasons(scan: PrivateContactRequestScan) -> Vec { + scan.rejected + .into_iter() + .map(|rejected| rejected.reason) + .collect() } /// The bounds the request checks are DashPay's: the schema's sizes, as dpp parses them. @@ -1060,7 +1496,12 @@ mod tests { let entry = seal(&contract, &sender, &largest_request(), 1); assert_eq!(entry.len(), 589); - let scan = read(&contract, vec![entry], Some(sender)); + let scan = read( + &contract, + vec![entry], + &scan_keys(), + &[sender, recipient_identity()], + ); assert_eq!(scan.rejected, vec![]); assert_eq!(scan.received.len(), 1); let received = &scan.received[0]; @@ -1087,92 +1528,332 @@ mod tests { assert!(scan.is_complete()); } - /// A received request is refused when its sender does not exist, when the key it names was - /// disabled before it was appended, or when the identity's key under that id did not write - /// it; a key disabled after it was appended still wrote it. + /// A received request is refused when its sender does not exist; when the encryption key it + /// names is not an enabled encryption key of the sender; or when no key of the sender that + /// may sign DashPay's documents, enabled then, signed it. A key disabled after the entry was + /// appended was enabled when it was written. #[test] fn should_refuse_a_request_whose_sender_does_not_check() { let contract = dashpay(); let entry = seal(&contract, &sender_identity(None), &smallest_request(1), 1); - let reasons = |sender: Option| { - read(&contract, vec![entry.clone()], sender) - .rejected - .into_iter() - .map(|rejected| rejected.reason) - .collect::>() + let reasons_with = |sender: Option| { + let mut identities = vec![recipient_identity()]; + identities.extend(sender); + reasons(read( + &contract, + vec![entry.clone()], + &recipient_keys(), + &identities, + )) + }; + let not_usable = |reasons: Vec| { + matches!( + reasons.as_slice(), + [PrivateContactRequestRejection::SenderKeyNotUsable(_)] + ) + }; + let signing_key = |security_level, disabled_at| { + IdentityPublicKey::V0(IdentityPublicKeyV0 { + id: 0, + purpose: Purpose::AUTHENTICATION, + security_level, + contract_bounds: None, + key_type: KeyType::ECDSA_SECP256K1, + read_only: false, + data: BinaryData::new( + PublicKey::from_secret_key(&secret_key(9)) + .serialize() + .to_vec(), + ), + disabled_at, + }) }; + assert_eq!(reasons_with(Some(sender_identity(None))), vec![]); assert_eq!( - reasons(None), + reasons_with(None), vec![PrivateContactRequestRejection::SenderNotFound] ); - assert!(matches!( - reasons(Some(sender_identity(Some(BLOCK_TIME_MS)))).as_slice(), - [PrivateContactRequestRejection::SenderKeyNotUsable(_)] - )); + + // The encryption key named, disabled before and after the entry. + assert!(not_usable(reasons_with(Some(sender_identity(Some( + BLOCK_TIME_MS + )))))); assert_eq!( - reasons(Some(sender_identity(Some(BLOCK_TIME_MS + 1)))), + reasons_with(Some(sender_identity(Some(BLOCK_TIME_MS + 1)))), vec![] ); - // The identity's key 1 is another key than the one that wrote the MAC. - let impostor = identity( - SENDER_ID, - vec![key( + // Key 1 is not an encryption key. + assert!(not_usable(reasons_with(Some(with_key( + sender_identity(None), + key( 1, - Purpose::ENCRYPTION, + Purpose::AUTHENTICATION, KeyType::ECDSA_SECP256K1, - &secret_key(5), + &secret_key(1), None, - )], - ); + ), + ))))); + + // The signing key, disabled before and after the entry, and at a level that signs no + // DashPay document. + assert!(not_usable(reasons_with(Some(with_key( + sender_identity(None), + signing_key(SecurityLevel::HIGH, Some(BLOCK_TIME_MS)), + ))))); assert_eq!( - reasons(Some(impostor)), - vec![PrivateContactRequestRejection::SenderNotAuthenticated] + reasons_with(Some(with_key( + sender_identity(None), + signing_key(SecurityLevel::HIGH, Some(BLOCK_TIME_MS + 1)), + ))), + vec![] ); - // Key 1 is not an encryption key. - let wrong_purpose = identity( - SENDER_ID, - vec![key( - 1, - Purpose::AUTHENTICATION, + assert!(not_usable(reasons_with(Some(with_key( + sender_identity(None), + signing_key(SecurityLevel::MEDIUM, None), + ))))); + assert!(not_usable(reasons_with(Some(with_key( + sender_identity(None), + signing_key(SecurityLevel::MASTER, None), + ))))); + // The signing key's public key under another purpose is no signing key. + assert!(not_usable(reasons_with(Some(with_key( + sender_identity(None), + key( + 0, + Purpose::ENCRYPTION, KeyType::ECDSA_SECP256K1, - &secret_key(1), + &secret_key(9), None, - )], + ), + ))))); + // The identity holds no key that signed it. + assert_eq!( + reasons_with(Some(with_key( + sender_identity(None), + key( + 0, + Purpose::AUTHENTICATION, + KeyType::ECDSA_SECP256K1, + &secret_key(5), + None, + ), + ))), + vec![PrivateContactRequestRejection::SenderNotAuthenticated] + ); + // A hash160 signing key signs too: the signature recovers its key. + assert_eq!( + reasons_with(Some(with_key( + sender_identity(None), + IdentityPublicKey::V0(IdentityPublicKeyV0 { + id: 0, + purpose: Purpose::AUTHENTICATION, + security_level: SecurityLevel::CRITICAL, + contract_bounds: None, + key_type: KeyType::ECDSA_HASH160, + read_only: false, + data: BinaryData::new( + ripemd160_sha256(&PublicKey::from_secret_key(&secret_key(9)).serialize()) + .to_vec(), + ), + disabled_at: None, + }), + ))), + vec![] + ); + } + + /// A stolen recipient key forges nothing: an entry with a deniable MAC, which the + /// recipient's key computes, is refused, and so is one the recipient signed itself naming + /// the sender. + #[test] + fn should_refuse_an_entry_a_stolen_recipient_key_could_have_written() { + let contract = dashpay(); + let request = smallest_request(1); + let mac = seal_raw( + &contract, + RECIPIENT_ID, + &secret_key(2), + &PrivateDocumentSender::DeniableMac { + identity_id: SENDER_ID, + key_id: 1, + secret_key: &secret_key(1), + }, + &payload(&contract, &request), + ); + let forged_signature = + signature(&contract, &sender_identity(None), &request, &secret_key(2)); + let forged = seal_raw( + &contract, + RECIPIENT_ID, + &secret_key(2), + &PrivateDocumentSender::Signature { + identity_id: SENDER_ID, + key_id: 1, + signature: &forged_signature, + }, + &payload(&contract, &request), + ); + assert_eq!( + reasons(read( + &contract, + vec![mac, forged], + &recipient_keys(), + &[sender_identity(None), recipient_identity()], + )), + vec![ + PrivateContactRequestRejection::NotSigned, + PrivateContactRequestRejection::SenderNotAuthenticated, + ] + ); + } + + /// A request is refused when the recipient key it is encrypted to was disabled before it + /// was appended, or when the recipient identity is not given to check it. + #[test] + fn should_refuse_a_request_to_a_recipient_key_disabled_before_it() { + let contract = dashpay(); + let entry = seal(&contract, &sender_identity(None), &smallest_request(1), 1); + let reasons_with = |recipient: Option| { + let mut identities = vec![sender_identity(None)]; + identities.extend(recipient); + reasons(read( + &contract, + vec![entry.clone()], + &recipient_keys(), + &identities, + )) + }; + let disabled_at = |disabled_at| { + with_key( + recipient_identity(), + key( + 2, + Purpose::DECRYPTION, + KeyType::ECDSA_SECP256K1, + &secret_key(2), + Some(disabled_at), + ), + ) + }; + assert!(matches!( + reasons_with(Some(disabled_at(BLOCK_TIME_MS))).as_slice(), + [PrivateContactRequestRejection::RecipientKeyNotUsable(_)] + )); + assert_eq!(reasons_with(Some(disabled_at(BLOCK_TIME_MS + 1))), vec![]); + assert!(matches!( + reasons_with(None).as_slice(), + [PrivateContactRequestRejection::RecipientKeyNotUsable(_)] + )); + } + + /// An entry the sender's outgoing viewing key recovers is the sender's only when it names + /// the identity and key that key is derived from and one of the identity's signing keys + /// signed it: someone holding the encryption key could build the others. + #[test] + fn should_refuse_a_sent_entry_the_identity_did_not_write() { + let contract = dashpay(); + let sender = sender_identity(None); + let request = smallest_request(1); + let signed_by = |identity_id: [u8; 32], key_id: KeyID, signing_secret: &SecretKey| { + let signing_data = private_document_signing_data( + &PrivateDocumentTarget { + contract_id: contract.id().to_buffer(), + document_type_name: PRIVATE_CONTACT_REQUEST_DOCUMENT_TYPE_NAME, + }, + &identity_id, + key_id, + &[0xD0; 32], + &payload(&contract, &request), + ); + let signature = sign_private_document_signing_data(&signing_data, signing_secret); + seal_raw( + &contract, + [0xD0; 32], + &secret_key(40), + &PrivateDocumentSender::Signature { + identity_id, + key_id, + signature: &signature, + }, + &payload(&contract, &request), + ) + }; + let entries = vec![ + signed_by(SENDER_ID, 1, &secret_key(9)), + // Another key than the outgoing viewing key's. + signed_by(SENDER_ID, 3, &secret_key(9)), + // Another identity. + signed_by([0xC0; 32], 1, &secret_key(9)), + // Signed by no key of the sender. + signed_by(SENDER_ID, 1, &secret_key(5)), + ]; + let scan = read(&contract, entries.clone(), &scan_keys(), &[sender]); + assert_eq!( + scan.sent + .iter() + .map(|sent| (sent.position, sent.recipient_id)) + .collect::>(), + vec![(0, Identifier::from([0xD0; 32]))] ); + assert_eq!( + reasons(scan), + vec![PrivateContactRequestRejection::SenderNotAuthenticated; 3] + ); + // The encryption key was disabled before the entry: a thief wrote it with the key. assert!(matches!( - reasons(Some(wrong_purpose)).as_slice(), + reasons(read( + &contract, + entries[..1].to_vec(), + &scan_keys(), + &[sender_identity(Some(BLOCK_TIME_MS))], + )) + .as_slice(), [PrivateContactRequestRejection::SenderKeyNotUsable(_)] )); } + /// The sender is checked before the payload is read: an entry from no known sender is + /// refused as such, whatever it holds. + #[test] + fn should_check_the_sender_before_reading_the_payload() { + let contract = dashpay(); + let garbage_signature = sign_private_document_signing_data(&[1; 32], &secret_key(5)); + let entry = seal_raw( + &contract, + RECIPIENT_ID, + &secret_key(2), + &PrivateDocumentSender::Signature { + identity_id: [0xC0; 32], + key_id: 1, + signature: &garbage_signature, + }, + &[0xFF; 40], + ); + assert_eq!( + reasons(read( + &contract, + vec![entry], + &recipient_keys(), + &[recipient_identity()], + )), + vec![PrivateContactRequestRejection::SenderNotFound] + ); + } + /// An anonymous entry is no contact request, and a second request from the same sender for /// the same account reference is a duplicate of the first, which is kept. #[test] fn should_refuse_an_anonymous_entry_and_a_duplicate_request() { let contract = dashpay(); let sender = sender_identity(None); - let document_type = private_contact_request_type(&contract).expect("DashPay v3"); - let anonymous = seal_private_document_entry( - &PrivateDocumentTarget { - contract_id: contract.id().to_buffer(), - document_type_name: PRIVATE_CONTACT_REQUEST_DOCUMENT_TYPE_NAME, - }, - 589, - &PrivateDocumentRecipient { - identity_id: RECIPIENT_ID, - public_key: &PublicKey::from_secret_key(&secret_key(2)), - }, + let anonymous = seal_raw( + &contract, + RECIPIENT_ID, + &secret_key(2), &PrivateDocumentSender::Anonymous, - &encode_private_document_payload( - document_type, - smallest_request(1).to_properties().expect("in bounds"), - ) - .expect("the payload encodes"), - &[0; 32], - &[9; 32], - ) - .expect("the payload fits"); + &payload(&contract, &smallest_request(1)), + ); let scan = read( &contract, @@ -1182,7 +1863,8 @@ mod tests { seal(&contract, &sender, &smallest_request(1), 2), seal(&contract, &sender, &smallest_request(2), 3), ], - Some(sender), + &recipient_keys(), + &[sender.clone(), recipient_identity()], ); assert_eq!( scan.received @@ -1209,53 +1891,213 @@ mod tests { } /// A request is sealed only from the sender's enabled ECDSA encryption key with its own - /// secret key, and only to an enabled ECDSA decryption or encryption key of the recipient. + /// secret key, signed by an enabled signing key of the sender at a level DashPay's documents + /// are signed at, and only to an enabled ECDSA decryption or encryption key of the + /// recipient. #[test] fn should_refuse_to_seal_with_keys_dashpay_does_not_use() { let contract = dashpay(); - let sender = sender_identity(None); - let attempt = |encryption_key_id: KeyID, secret: u8, recipient_key_id: KeyID| { + let request = smallest_request(1); + let attempt = |sender: &Identity, + encryption_key_id: KeyID, + secret: u8, + signing_key_id: KeyID, + signing_secret: u8, + recipient_key_id: KeyID| { + let signing_data = private_contact_request_signing_data( + &contract, + sender, + encryption_key_id, + &recipient_identity(), + recipient_key_id, + &request, + )?; seal_private_contact_request( &contract, &PrivateContactRequestSender { - identity: &sender, + identity: sender, encryption_key_id, encryption_secret_key: &secret_key(secret), + signing_key_id, + signature: &sign_private_document_signing_data( + &signing_data, + &secret_key(signing_secret), + ), }, &recipient_identity(), recipient_key_id, - &smallest_request(1), + &request, &[1; 32], ) }; - attempt(1, 1, 2).expect("the sender's encryption key to the recipient's decryption key"); - for (case, encryption_key_id, secret, recipient_key_id) in [ - ("the sender's authentication key", 0, 9, 2), - ("another secret key than the sender key's", 1, 5, 2), - ("a sender key the identity lacks", 7, 1, 2), - ("the recipient's authentication key", 1, 1, 0), - ("the recipient's BLS key", 1, 1, 3), - ("the recipient's disabled key", 1, 1, 4), - ("a recipient key the identity lacks", 1, 1, 9), + let sender = sender_identity(None); + attempt(&sender, 1, 1, 0, 9, 2).expect( + "the sender's encryption key, signed by its signing key, to the recipient's \ + decryption key", + ); + for (case, encryption_key_id, secret, signing_key_id, signing_secret, recipient_key_id) in [ + ( + "the sender's authentication key as the encryption key", + 0, + 9, + 0, + 9, + 2, + ), + ( + "another secret key than the encryption key's", + 1, + 5, + 0, + 9, + 2, + ), + ("an encryption key the identity lacks", 7, 1, 0, 9, 2), + ("the encryption key as the signing key", 1, 1, 1, 1, 2), + ( + "a signature by another key than the signing key", + 1, + 1, + 0, + 5, + 2, + ), + ("a signing key the identity lacks", 1, 1, 8, 9, 2), + ("the recipient's authentication key", 1, 1, 0, 9, 0), + ("the recipient's BLS key", 1, 1, 0, 9, 3), + ("the recipient's disabled key", 1, 1, 0, 9, 4), + ("a recipient key the identity lacks", 1, 1, 0, 9, 9), ] { assert!( - attempt(encryption_key_id, secret, recipient_key_id).is_err(), + attempt( + &sender, + encryption_key_id, + secret, + signing_key_id, + signing_secret, + recipient_key_id + ) + .is_err(), "{case}" ); } - let disabled = sender_identity(Some(1)); - assert!(seal_private_contact_request( + assert!( + attempt(&sender_identity(Some(1)), 1, 1, 0, 9, 2).is_err(), + "a disabled encryption key" + ); + let medium = with_key( + sender_identity(None), + IdentityPublicKey::V0(IdentityPublicKeyV0 { + id: 0, + purpose: Purpose::AUTHENTICATION, + security_level: SecurityLevel::MEDIUM, + contract_bounds: None, + key_type: KeyType::ECDSA_SECP256K1, + read_only: false, + data: BinaryData::new( + PublicKey::from_secret_key(&secret_key(9)) + .serialize() + .to_vec(), + ), + disabled_at: None, + }), + ); + assert!( + attempt(&medium, 1, 1, 0, 9, 2).is_err(), + "a MEDIUM signing key" + ); + } + + /// DashPay's documents are signed at HIGH or CRITICAL. + #[test] + fn should_sign_at_the_levels_dashpay_documents_are_signed_at() { + assert_eq!( + private_contact_request_signing_security_levels(&dashpay()).expect("DashPay v3"), + vec![SecurityLevel::CRITICAL, SecurityLevel::HIGH] + ); + } + + /// The signer a wallet signs its transitions with, as `SimpleSigner` signs: the data's + /// double SHA-256, in the compact form. + #[derive(Debug)] + struct TransitionSigner(BTreeMap); + + #[async_trait::async_trait] + impl Signer for TransitionSigner { + async fn sign( + &self, + key: &IdentityPublicKey, + data: &[u8], + ) -> Result { + let secret_key = self + .0 + .get(&key.id()) + .ok_or_else(|| ProtocolError::Generic(format!("no key {}", key.id())))?; + let signature = dpp::dashcore::signer::sign(data, &secret_key.to_secret_bytes()) + .map_err(|error| ProtocolError::Generic(error.to_string()))?; + Ok(BinaryData::new(signature.to_vec())) + } + + async fn sign_create_witness( + &self, + _key: &IdentityPublicKey, + _data: &[u8], + ) -> Result { + Err(ProtocolError::Generic("no witnesses".to_string())) + } + + fn can_sign_with(&self, key: &IdentityPublicKey) -> bool { + self.0.contains_key(&key.id()) + } + } + + /// The signer a wallet signs a public request's transition with signs a private one: its + /// signature of the signing data is the one the entry carries. + #[tokio::test] + async fn should_seal_with_the_signature_of_a_transition_signer() { + let contract = dashpay(); + let sender = sender_identity(None); + let request = largest_request(); + let signing_key = sender.get_public_key_by_id(0).expect("key 0").clone(); + let signer = TransitionSigner(BTreeMap::from([(0, secret_key(9))])); + let signing_data = private_contact_request_signing_data( + &contract, + &sender, + 1, + &recipient_identity(), + 2, + &request, + ) + .expect("the keys check"); + let signature = private_contact_request_signature(&signer, &signing_key, &signing_data) + .await + .expect("the signer signs"); + assert_eq!( + signature, + sign_private_document_signing_data(&signing_data, &secret_key(9)) + ); + let entry = seal_private_contact_request( &contract, &PrivateContactRequestSender { - identity: &disabled, + identity: &sender, encryption_key_id: 1, encryption_secret_key: &secret_key(1), + signing_key_id: 0, + signature: &signature, }, &recipient_identity(), 2, - &smallest_request(1), - &[1; 32], + &request, + &[3; 32], ) - .is_err()); + .expect("the request seals"); + let scan = read( + &contract, + vec![entry.to_vec()], + &recipient_keys(), + &[sender, recipient_identity()], + ); + assert_eq!(scan.rejected, vec![]); + assert_eq!(scan.received[0].request, request); } } diff --git a/packages/rs-sdk/src/platform/private_document_scan.rs b/packages/rs-sdk/src/platform/private_document_scan.rs index 9eca524c7ff..29be4814e32 100644 --- a/packages/rs-sdk/src/platform/private_document_scan.rs +++ b/packages/rs-sdk/src/platform/private_document_scan.rs @@ -7,9 +7,10 @@ //! fetches one proved page of the store from a cursor and sorts its entries. //! //! A scan only opens entries. What an entry claims about its sender is checked against the -//! sender identity's key with [`PrivateDocumentScanKeys::verify_sender`], and the payload is the -//! type's to decode and validate. `crate::platform::dashpay` does both for DashPay's private -//! contact requests. +//! sender identity's keys: a deniable MAC with [`PrivateDocumentScanKeys::verify_sender`], a +//! signature with [`OpenedPrivateDocumentEntry::recover_signer`]. The payload is the type's to +//! decode and validate. `crate::platform::dashpay` does both for DashPay's private contact +//! requests. use crate::platform::private_document_entries::{ PrivateDocumentEntries, PrivateDocumentEntriesQuery, PrivateDocumentEntry, @@ -31,9 +32,10 @@ struct ScanRecipient { keys: PrivateDocumentRecipientKeys, } -/// One identity of the wallet and the outgoing viewing key it seals entries with. +/// One identity of the wallet and an outgoing viewing key it seals entries with. struct ScanSender { identity_id: Identifier, + key_id: KeyID, outgoing_viewing_key: [u8; 32], } @@ -81,6 +83,9 @@ pub struct SentPrivateDocumentEntry { pub block_time_ms: u64, /// The identity whose outgoing viewing key recovered it. pub sender_identity_id: Identifier, + /// The key of that identity the outgoing viewing key is derived from. An entry naming + /// another sender identity or key was built by someone else holding the key. + pub outgoing_key_id: KeyID, /// The recovered entry, with its recipient and payload. pub entry: OpenedPrivateDocumentEntry, } @@ -132,20 +137,31 @@ impl PrivateDocumentScanKeys { /// identity's key id with its secret key. Entries to it are tried with the keys in this /// order. pub fn with_recipient(mut self, identity_id: Identifier, keys: &[(KeyID, SecretKey)]) -> Self { - let secret_keys: Vec = keys.iter().map(|(_, secret_key)| *secret_key).collect(); + let mut secret_keys: Vec = + keys.iter().map(|(_, secret_key)| *secret_key).collect(); self.recipients.push(ScanRecipient { identity_id, key_ids: keys.iter().map(|(key_id, _)| *key_id).collect(), keys: PrivateDocumentRecipientKeys::new(&secret_keys), }); + for secret_key in &mut secret_keys { + secret_key.non_secure_erase(); + } self } /// Adds `identity_id` as a sender, which sealed its entries with `outgoing_viewing_key` - /// (`platform_encryption::private_document_outgoing_viewing_key`). - pub fn with_sender(mut self, identity_id: Identifier, outgoing_viewing_key: [u8; 32]) -> Self { + /// (`platform_encryption::private_document_outgoing_viewing_key`), derived from its key + /// `key_id`. + pub fn with_sender( + mut self, + identity_id: Identifier, + key_id: KeyID, + outgoing_viewing_key: [u8; 32], + ) -> Self { self.senders.push(ScanSender { identity_id, + key_id, outgoing_viewing_key, }); self @@ -182,12 +198,25 @@ impl PrivateDocumentScanKeys { received: &ReceivedPrivateDocumentEntry, sender_public_key: &PublicKey, ) -> Result<(), PrivateDocumentEnvelopeError> { + // The identity may have been added more than once, with other keys each time: the + // check is the one of the keys that opened the entry. self.recipients .iter() - .find(|recipient| recipient.identity_id == received.recipient_identity_id) - .ok_or(PrivateDocumentEnvelopeError::RecipientSecretKeyRequired)? - .keys - .verify_sender(&received.entry, sender_public_key) + .filter(|recipient| recipient.identity_id == received.recipient_identity_id) + .map(|recipient| { + recipient + .keys + .verify_sender(&received.entry, sender_public_key) + }) + .find(|result| { + !matches!( + result, + Err(PrivateDocumentEnvelopeError::RecipientSecretKeyRequired) + ) + }) + .unwrap_or(Err( + PrivateDocumentEnvelopeError::RecipientSecretKeyRequired, + )) } fn scan_entry( @@ -260,6 +289,7 @@ impl PrivateDocumentScanKeys { block_height: entry.block_height, block_time_ms: entry.block_time_ms, sender_identity_id: sender.identity_id, + outgoing_key_id: sender.key_id, entry: recovered, }); break; @@ -395,7 +425,7 @@ mod tests { let keys = PrivateDocumentScanKeys::new() .with_recipient(alice, &[(2, secret_key(20)), (3, secret_key(21))]) .with_recipient(bob, &[(1, secret_key(30))]) - .with_sender(alice, ovk(0xA1)); + .with_sender(alice, 6, ovk(0xA1)); let entries = vec![ // 10: to Alice's key 3, from Carol seal(0xC0, alice.to_buffer(), 21, 0xC1, 1), @@ -427,9 +457,9 @@ mod tests { assert_eq!( scan.sent .iter() - .map(|sent| (sent.position, sent.sender_identity_id)) + .map(|sent| (sent.position, sent.sender_identity_id, sent.outgoing_key_id)) .collect::>(), - vec![(12, alice), (14, alice)] + vec![(12, alice, 6), (14, alice, 6)] ); assert_eq!(scan.sent[0].entry.recipient_identity_id(), &[0xD0; 32]); assert_eq!( @@ -490,6 +520,20 @@ mod tests { others.verify_sender(received, &public_key(0xC0)), Err(PrivateDocumentEnvelopeError::RecipientSecretKeyRequired) ); + + // Added twice with other keys each time, the identity is checked with the keys that + // opened the entry, not the first it was added with. + let twice = PrivateDocumentScanKeys::new() + .with_recipient(alice, &[(1, secret_key(19))]) + .with_recipient(alice, &[(2, secret_key(20))]); + let scan = twice.scan_page( + &target(), + 0, + &page(0, vec![seal(0xC0, alice.to_buffer(), 20, 0xC1, 1)], 1), + ); + twice + .verify_sender(&scan.received[0], &public_key(0xC0)) + .expect("the second key set opened it and checks it"); } /// A page at the store's end is empty: the cursor stays where it is. From 63e1f692c77d95f122236adc4f4e3cf45237facf Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Sun, 8 Nov 2026 17:22:30 +0700 Subject: [PATCH 006/117] fix(platform-wallet): sign private contact requests and keep them private across restarts - Private requests are signed by the identity signer, with a signing key of the sender at a level DashPay's documents are signed at, as public requests are. `send_private_contact_request` and `accept_private_contact_request` take the signer. The crypto provider seals with the signature and keeps the encryption key; `read_private_contact_requests` is gone, as checking an entry needs no secret key. - A private request is recorded at core height 0, which no contactRequest document has (DashPay requires `$createdAtCoreBlockHeight`). `ContactRequest::came_privately` and `DashPayState::private_contact` derive from the records, so the privacy of a contact holds after a restart; only the shielded addresses stay in memory, noted from the tracked request. - A public send to a contact whose requests came or went privately is refused, as is a public accept that would broadcast a reply. - Ingest passes over a request older than the tracked one (by its reference's version, then its time) on both sides, so a cold start's rescan of the private store, or a replayed entry, does not roll a contact back to a retired xpub. - Sending computes the shared key before paying, and nothing after paying fails the send; a record that does not reach disk is recovered by the next sync. Sent private records note core height 0 for the receiving account's scan checkpoint. - A sync whose ingest did not reach disk for an identity is not complete. Co-Authored-By: Claude Opus 5.5 --- packages/rs-platform-wallet/src/error.rs | 10 +- .../identity/network/contact_requests.rs | 238 ++++++++--- .../network/private_contact_requests.rs | 382 ++++++++++++------ .../managed_identity/contact_requests.rs | 41 +- .../state/managed_identity/dashpay.rs | 54 ++- .../identity/types/dashpay/contact_request.rs | 9 + .../src/wallet/platform_wallet.rs | 39 +- .../src/wallet/shielded/operations.rs | 4 +- 8 files changed, 542 insertions(+), 235 deletions(-) diff --git a/packages/rs-platform-wallet/src/error.rs b/packages/rs-platform-wallet/src/error.rs index 5ed4e79f788..3bfa657e0ce 100644 --- a/packages/rs-platform-wallet/src/error.rs +++ b/packages/rs-platform-wallet/src/error.rs @@ -655,10 +655,12 @@ pub enum PlatformWalletError { #[error("SDK error: {0}")] Sdk(#[from] dash_sdk::Error), - /// The contact request from this sender came privately (protocol - /// version 15): a `contactRequest` document in reply would publish the - /// relationship, so it is accepted with a private reply. - #[error("the contact request from {0} came privately; accept it with a private reply")] + /// The contact requests with this identity came or went privately + /// (protocol version 15): a `contactRequest` document to it, in reply or + /// as a rotation, would publish the relationship, so it is sent privately. + #[error( + "the contact requests with {0} came or went privately; reply to or rotate with it privately" + )] PrivateContactRequestNeedsPrivateReply(Identifier), /// No DPNS `domain` document exists for the requested name (exact diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs b/packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs index d2ce0bf8c05..438aa143488 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs @@ -23,7 +23,7 @@ use crate::wallet::identity::types::dashpay::established_contact::EstablishedCon use crate::wallet::identity::ManagedIdentity; use crate::wallet::persister::WalletPersister; use crate::wallet::platform_wallet::PlatformWalletInfo; -use dash_sdk::platform::dashpay::{PrivateContactRequest, PrivateContactRequestScan}; +use dash_sdk::platform::dashpay::PrivateContactRequest; use dash_sdk::platform::private_document_entries::PrivateDocumentEntries; use dash_sdk::platform::private_document_scan::PrivateDocumentScan; use dpp::identity::KeyID; @@ -170,27 +170,13 @@ pub trait ContactCryptoProvider { Err(private_documents_unsupported()) } - /// Reads the private contact requests of `scan`, a scan of DashPay's store made with the - /// same `keys`: decodes each, checks each received request's sender against the key - /// `sender_identities` holds under the id it names, with the key at `keys` that opened it - /// ([`dash_sdk::platform::dashpay::private_contact_requests_from_scan`]). - /// - /// Defaulted to an "unsupported" error, as [`Self::scan_private_document_page`]. - async fn read_private_contact_requests( - &self, - _keys: &PrivateDocumentKeyPaths, - _dashpay_contract: &dpp::prelude::DataContract, - _scan: PrivateDocumentScan, - _sender_identities: &BTreeMap>, - _platform_version: &dpp::version::PlatformVersion, - ) -> Result { - Err(private_documents_unsupported()) - } - /// Seals `request` from `sender`, whose encryption key is the one at - /// `sender_encryption_key_path`, to the key `recipient_key_id` of `recipient` - /// ([`dash_sdk::platform::dashpay::seal_private_contact_request`]). `esk_seed` is 32 fresh - /// random bytes, never reused. + /// `sender_encryption_key_path`, to the key `recipient_key_id` of `recipient`, with + /// `signature`, the signature of the request by the sender's key `signing_key_id` + /// ([`dash_sdk::platform::dashpay::seal_private_contact_request`]). The encryption key's + /// secret key, which the outgoing viewing key is derived from, stays in the signer; the + /// signature comes from the identity signer, as a transition's does. `esk_seed` is 32 + /// fresh random bytes, never reused. /// /// Defaulted to an "unsupported" error, as [`Self::scan_private_document_page`]. #[allow(clippy::too_many_arguments)] @@ -200,6 +186,8 @@ pub trait ContactCryptoProvider { _dashpay_contract: &dpp::prelude::DataContract, _sender: &Identity, _sender_encryption_key_id: KeyID, + _signing_key_id: KeyID, + _signature: &[u8; 65], _recipient: &Identity, _recipient_key_id: KeyID, _request: &PrivateContactRequest, @@ -401,50 +389,36 @@ impl ContactCryptoProvider for SeedCryptoProvider { )) } - async fn read_private_contact_requests( - &self, - keys: &PrivateDocumentKeyPaths, - dashpay_contract: &dpp::prelude::DataContract, - scan: PrivateDocumentScan, - sender_identities: &BTreeMap>, - platform_version: &dpp::version::PlatformVersion, - ) -> Result { - let keys = keys.scan_keys(|path| self.secret_key_at(path))?; - Ok( - dash_sdk::platform::dashpay::private_contact_requests_from_scan( - dashpay_contract, - scan, - &keys, - sender_identities, - platform_version, - )?, - ) - } - async fn seal_private_contact_request( &self, sender_encryption_key_path: &key_wallet::bip32::DerivationPath, dashpay_contract: &dpp::prelude::DataContract, sender: &Identity, sender_encryption_key_id: KeyID, + signing_key_id: KeyID, + signature: &[u8; 65], recipient: &Identity, recipient_key_id: KeyID, request: &PrivateContactRequest, esk_seed: &[u8; 32], ) -> Result { - let encryption_secret_key = self.secret_key_at(sender_encryption_key_path)?; - Ok(dash_sdk::platform::dashpay::seal_private_contact_request( + let mut encryption_secret_key = self.secret_key_at(sender_encryption_key_path)?; + let sealed = dash_sdk::platform::dashpay::seal_private_contact_request( dashpay_contract, &dash_sdk::platform::dashpay::PrivateContactRequestSender { identity: sender, encryption_key_id: sender_encryption_key_id, encryption_secret_key: &encryption_secret_key, + signing_key_id, + signature, }, recipient, recipient_key_id, request, esk_seed, - )?) + ); + encryption_secret_key.non_secure_erase(); + Ok(sealed?) } } @@ -600,6 +574,22 @@ impl DashPayView<'_, B> { S: Signer + Send + Sync, C: ContactCryptoProvider + Sync, { + // A contact whose requests came or went privately is kept off chain: a + // `contactRequest` document would publish the relationship. Replies + // and rotations go privately instead. + let private = { + let wm = self.wallet_manager.read().await; + wm.get_wallet_info(&self.wallet_id) + .and_then(|info| info.identity_manager.managed_identity(sender_identity_id)) + .and_then(|managed| managed.dashpay().private_contact(recipient_identity_id)) + .is_some() + }; + if private { + return Err(PlatformWalletError::PrivateContactRequestNeedsPrivateReply( + *recipient_identity_id, + )); + } + // 1-4c. The keys, the receiving xpub, the masked account reference and // the auto-accept proof, as a private request is built from them too. let PreparedContactRequest { @@ -1189,6 +1179,20 @@ fn record_and_collapse_sent_requests( newest_sent_per_recipient(requests) } +/// Whether `candidate` is older than `tracked`, a request between the same +/// pair: by its account reference's version, which a rotation bumps, then by +/// its creation time. A replay of an older request appended again later has a +/// newer time but an older version. +fn predates(candidate: &ContactRequest, tracked: &ContactRequest) -> bool { + ( + platform_encryption::account_reference_version(candidate.account_reference), + candidate.created_at, + ) < ( + platform_encryption::account_reference_version(tracked.account_reference), + tracked.created_at, + ) +} + /// Ingest one identity's collapsed **received** contact requests into local /// state, returning whether every write reached disk. /// @@ -1234,21 +1238,18 @@ pub(super) fn ingest_received_requests( // tracked incoming/established state is a re-ingest of a known doc; a // DIFFERENT reference from a known sender is a rotation request // (receive side) and must get through. - let tracked_reference = managed - .dashpay() - .incoming_contact_requests() - .get(&sender_id) - .map(|r| r.account_reference) - .or_else(|| { - managed - .dashpay() - .established_contacts() - .get(&sender_id) - .map(|c| c.incoming_request.account_reference) - }); + let tracked = managed.dashpay().incoming_request(&sender_id); + let tracked_reference = tracked.map(|r| r.account_reference); if tracked_reference == Some(contact_request.account_reference) { continue; } + // An older request than the tracked one is no rotation: one channel's + // sweep can replay a request the other channel already superseded (a + // cold start scans the private store from its start), and taking it + // would roll the contact back to a retired xpub. + if tracked.is_some_and(|tracked| predates(&contact_request, tracked)) { + continue; + } if tracked_reference.is_some() { // Rotation: supersede the tracked request. When an established @@ -1294,7 +1295,17 @@ pub(super) fn ingest_sent_requests( identity_id: Identifier, newest_by_recipient: std::collections::BTreeMap, ) -> bool { - for (_recipient_id, contact_request) in newest_by_recipient { + for (recipient_id, contact_request) in newest_by_recipient { + // An older request than the tracked one is a replay, as on the + // received side: superseding with it would re-derive the next rotation + // from a retired reference. + if managed + .dashpay() + .outgoing_request(&recipient_id) + .is_some_and(|tracked| predates(&contact_request, tracked)) + { + continue; + } if let Err(e) = managed.add_sent_contact_request(contact_request, persister) { tracing::error!( owner = %identity_id, error = %e, @@ -3735,17 +3746,25 @@ impl DashPayView<'_, B> { S: Signer + Send + Sync, C: ContactCryptoProvider + Sync, { - let received_privately = { + // A reply already sent, either way, is adopted without a broadcast, so + // only a reply still to send would publish anything. + let needs_private_reply = { let wm = self.wallet_manager.read().await; wm.get_wallet_info(&self.wallet_id) .and_then(|info| { info.identity_manager .managed_identity(&request.recipient_id) }) - .and_then(|managed| managed.dashpay().private_contact(&request.sender_id)) - .is_some_and(|private| private.received_privately && !private.sent_privately) + .is_some_and(|managed| { + let dashpay = managed.dashpay(); + (request.came_privately() + || dashpay + .incoming_request(&request.sender_id) + .is_some_and(ContactRequest::came_privately)) + && dashpay.outgoing_request(&request.sender_id).is_none() + }) }; - if received_privately { + if needs_private_reply { return Err(PlatformWalletError::PrivateContactRequestNeedsPrivateReply( request.sender_id, )); @@ -4676,6 +4695,103 @@ mod sweep_tests { assert_eq!(managed.dashpay().incoming_contact_requests().len(), 1); } + /// A request older than the tracked one, by its reference's version and + /// then its time, is passed over on both sides: one channel's sweep can + /// replay a request the other already superseded, and a replay appended + /// again later has a newer time but an older version. + #[test] + fn an_older_request_than_the_tracked_one_is_no_rotation() { + let our = 1u8; + let contact = 2u8; + let our_id = Identifier::from([our; 32]); + let contact_id = Identifier::from([contact; 32]); + let mut info = info_with_bare_identity(our); + let managed = info + .identity_manager + .managed_identity_mut(&our_id) + .expect("managed identity"); + let at = |sender: u8, recipient: u8, version: u32, account: u32, created_at: u64| { + let mut request = test_request( + sender, + recipient, + (version << ACCOUNT_REFERENCE_VERSION_SHIFT) | account, + ); + request.created_at = created_at; + request + }; + let tracked_incoming = |managed: &ManagedIdentity| { + managed + .dashpay() + .incoming_request(&contact_id) + .map(|request| request.account_reference) + }; + + let rotated = at(contact, our, 1, 5, 100); + for (case, candidate, expected) in [ + ( + "the rotated request", + rotated.clone(), + rotated.account_reference, + ), + ( + "a replay of the request it rotated past, appended later", + at(contact, our, 0, 4, 200), + rotated.account_reference, + ), + ( + "an earlier request of the same version", + at(contact, our, 1, 6, 50), + rotated.account_reference, + ), + ] { + assert!(ingest_received_requests( + managed, + &noop_persister(), + our_id, + newest_received_per_sender([candidate]), + &mut Vec::new(), + &mut Vec::new(), + )); + assert_eq!(tracked_incoming(managed), Some(expected), "{case}"); + } + let newer = at(contact, our, 2, 7, 300); + assert!(ingest_received_requests( + managed, + &noop_persister(), + our_id, + newest_received_per_sender([newer.clone()]), + &mut Vec::new(), + &mut Vec::new(), + )); + assert_eq!( + tracked_incoming(managed), + Some(newer.account_reference), + "a newer rotation is still taken" + ); + + let sent = at(our, contact, 1, 5, 100); + assert!(ingest_sent_requests( + managed, + &noop_persister(), + our_id, + newest_sent_per_recipient([sent.clone()]), + )); + assert!(ingest_sent_requests( + managed, + &noop_persister(), + our_id, + newest_sent_per_recipient([at(our, contact, 0, 4, 200)]), + )); + assert_eq!( + managed + .dashpay() + .outgoing_request(&contact_id) + .map(|request| request.account_reference), + Some(sent.account_reference), + "a replay of an older sent request does not supersede the tracked one" + ); + } + /// The first-ingest persist failure (`add_incoming_contact_request`). /// The request is not tracked and must not be reported as newly /// discovered — a caller that took it as real would act on a request that diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/private_contact_requests.rs b/packages/rs-platform-wallet/src/wallet/identity/network/private_contact_requests.rs index 19476f98e47..059ae38a48c 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/private_contact_requests.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/private_contact_requests.rs @@ -3,21 +3,26 @@ //! A private contact request is an entry of DashPay's `privateContactRequest` //! store, sealed to one of the recipient's DashPay keys and appended by a //! transition paid from the shielded pool, so nothing on chain names either -//! side. The sender names itself inside, with a deniable MAC by its encryption -//! key, and seals with the outgoing viewing key of that key, so a wallet -//! restored from seed recovers what it sent. +//! side. The sender names itself and its encryption key inside, signs it with +//! the identity signer it signs transitions with, and seals with the outgoing +//! viewing key of the encryption key, so a wallet restored from seed recovers +//! what it sent. //! //! The wallet keeps a private request as it keeps a public one, a //! [`ContactRequest`], so contacts establish, rotate and get their DashPay //! accounts the same way whichever way a request came. The entry carries the //! compact xpub and the label in the clear; the wallet encrypts them under the //! pair's DIP-15 shared key, as a `contactRequest` document carries them, with -//! IVs from the entry's commitment so a rescan rebuilds the same record. What a -//! `ContactRequest` has no field for (that the request came privately, and the -//! contact's shielded address) is kept in [`PrivateContact`]. +//! IVs from the entry's commitment so a rescan rebuilds the same record, and at +//! core height 0, which no `contactRequest` document has: that a request came +//! privately is the record's own ([`ContactRequest::came_privately`]), so it +//! holds across restarts. The contact's shielded address, which a +//! `ContactRequest` has no field for, is kept in memory and read with +//! [`PrivateContact`]. //! -//! All key material stays in the [`ContactCryptoProvider`]: the store is -//! opened, senders are checked and requests are sealed inside it. +//! The store is opened and requests are sealed inside the +//! [`ContactCryptoProvider`], which keeps the key material; requests are signed +//! by the identity signer. Checking an entry needs no secret key. //! //! [`PrivateContact`]: crate::wallet::identity::PrivateContact @@ -27,9 +32,11 @@ use std::future::Future; use dash_sdk::dapi_grpc::platform::v0::ResponseMetadata; use dash_sdk::platform::dashpay::{ - private_contact_request_outgoing_viewing_key, private_contact_request_senders, + private_contact_request_identities, private_contact_request_outgoing_viewing_key, + private_contact_request_signature, private_contact_request_signing_data, + private_contact_request_signing_security_levels, private_contact_requests_from_scan, recipient_key_purpose_is_valid, PrivateContactRequest, RejectedPrivateContactRequest, - PRIVATE_CONTACT_REQUEST_DOCUMENT_TYPE_NAME, + PRIVATE_CONTACT_REQUEST_DOCUMENT_TYPE_NAME, PRIVATE_CONTACT_REQUEST_SIGNING_KEY_TYPES, }; use dash_sdk::platform::private_document_entries::{ PrivateDocumentEntries, PrivateDocumentEntriesQuery, @@ -40,7 +47,8 @@ use dpp::data_contract::accessors::v0::DataContractV0Getters; use dpp::identity::accessors::IdentityGettersV0; use dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; use dpp::identity::identity_public_key::Purpose; -use dpp::identity::{Identity, KeyID, KeyType}; +use dpp::identity::signer::Signer; +use dpp::identity::{Identity, IdentityPublicKey, KeyID, KeyType}; use dpp::platform_value::BinaryData; use dpp::prelude::Identifier; use dpp::secp256k1::rand::rngs::StdRng; @@ -55,6 +63,7 @@ use super::contact_requests::{ newest_sent_per_recipient, tear_down_stale_external_accounts, AutoAcceptProofSource, ContactCryptoProvider, PreparedContactRequest, }; +use super::signing_key::AvailableSigningKey; use super::{dashpay_contract, DashPayView, IdentityWallet}; use crate::broadcaster::TransactionBroadcaster; use crate::error::PlatformWalletError; @@ -69,9 +78,9 @@ pub struct PrivateDocumentKeyPaths { /// Each identity entries may be sealed to, with the path of each of its /// keys an entry may be sealed to, by key id. pub recipients: Vec<(Identifier, Vec<(KeyID, DerivationPath)>)>, - /// Each identity that may have sealed entries, with the path of each of - /// its encryption keys: one outgoing viewing key each. - pub senders: Vec<(Identifier, DerivationPath)>, + /// Each identity that may have sealed entries, with the id and path of + /// each of its encryption keys: one outgoing viewing key each. + pub senders: Vec<(Identifier, KeyID, DerivationPath)>, } impl PrivateDocumentKeyPaths { @@ -90,12 +99,14 @@ impl PrivateDocumentKeyPaths { .collect::, E>>()?; keys = keys.with_recipient(*identity_id, &secret_keys); } - for (identity_id, path) in &self.senders { - let secret_key = derive(path)?; + for (identity_id, key_id, path) in &self.senders { + let mut secret_key = derive(path)?; keys = keys.with_sender( *identity_id, + *key_id, private_contact_request_outgoing_viewing_key(&secret_key), ); + secret_key.non_secure_erase(); } Ok(keys) } @@ -136,7 +147,9 @@ struct ScannedIdentity { /// DIP-15 shared key, as a `contactRequest` document carries them. The IVs are /// the two halves of the entry's `commitment`, so a rescan of the entry /// rebuilds the same record, and the wallet's comparisons of a contact's -/// encrypted xpub hold across passes. +/// encrypted xpub hold across passes. Its core height is 0, which marks it +/// private ([`ContactRequest::came_privately`]); our receiving account for the +/// contact is then scanned from the wallet's birth. #[allow(clippy::too_many_arguments)] pub(super) fn private_request_record( sender_id: Identifier, @@ -146,16 +159,12 @@ pub(super) fn private_request_record( request: &PrivateContactRequest, commitment: &[u8; 32], shared_key: &[u8; 32], - core_height_created_at: u32, created_at: u64, ) -> ContactRequest { - let (xpub_iv, label_iv) = commitment.split_at(16); - let xpub_iv: [u8; 16] = xpub_iv - .try_into() - .expect("a 32-byte commitment splits in two"); - let label_iv: [u8; 16] = label_iv - .try_into() - .expect("a 32-byte commitment splits in two"); + let mut xpub_iv = [0u8; 16]; + let mut label_iv = [0u8; 16]; + xpub_iv.copy_from_slice(&commitment[..16]); + label_iv.copy_from_slice(&commitment[16..]); let mut record = ContactRequest::new( sender_id, recipient_id, @@ -163,7 +172,7 @@ pub(super) fn private_request_record( recipient_key_index, request.account_reference, encrypt_extended_public_key(shared_key, &xpub_iv, &request.extended_public_key), - core_height_created_at, + 0, created_at, ); record.encrypted_account_label = request @@ -197,8 +206,9 @@ impl DashPayView<'_, B> { /// Send a private contact request from `sender_identity_id` to /// `recipient_identity_id`: the request a `contactRequest` document would /// carry (our receiving xpub for the pair, the masked account reference, - /// the label and the auto-accept proof) plus our `shielded_address`, sealed - /// in the signer to the recipient's DashPay key, then appended by `pay`, + /// the label and the auto-accept proof) plus our `shielded_address`, + /// signed by `signer` with one of the sender's signing keys, sealed in the + /// crypto provider to the recipient's DashPay key, then appended by `pay`, /// which pays for it from the shielded pool and returns the metadata of /// the proof that it was appended /// (`PlatformWallet::send_private_contact_request`). @@ -206,19 +216,24 @@ impl DashPayView<'_, B> { /// The keys and the account reference are chosen as for a document send, /// so a later request to the same recipient, either way, rotates past this /// one. Once `pay` succeeds the request is recorded as sent and our - /// receiving account for the contact is registered. + /// receiving account for the contact is registered. Nothing after `pay` + /// fails the send, as the payment cannot be taken back: a record that does + /// not reach disk is logged, and the next private sync recovers it from + /// the store with the outgoing viewing key. #[allow(clippy::too_many_arguments)] - pub async fn send_private_contact_request( + pub async fn send_private_contact_request( &self, sender_identity_id: &Identifier, recipient_identity_id: &Identifier, account_label: Option, auto_accept_proof: AutoAcceptProofSource, shielded_address: Option<[u8; 43]>, + signer: &S, crypto: &C, pay: F, ) -> Result where + S: Signer + Send + Sync, C: ContactCryptoProvider + Sync, F: FnOnce(BinaryData) -> Fut + Send, Fut: Future> + Send, @@ -258,7 +273,37 @@ impl DashPayView<'_, B> { shielded_address, }; + // The request is signed as a public request's transition is: by a + // signing key of the sender the signer holds, at a level the type's + // documents are signed at. let contract = dashpay_contract()?; + let signing_levels = private_contact_request_signing_security_levels(&contract)?; + let signing_key = sender_identity + .available_signing_key( + signer, + Purpose::AUTHENTICATION, + &signing_levels, + &PRIVATE_CONTACT_REQUEST_SIGNING_KEY_TYPES, + false, + )? + .cloned() + .ok_or_else(|| { + PlatformWalletError::InvalidIdentityData(format!( + "the sender identity has no enabled AUTHENTICATION key at {signing_levels:?} \ + to sign a private contact request with" + )) + })?; + let signing_data = private_contact_request_signing_data( + &contract, + &sender_identity, + sender_key_index, + &recipient_identity, + recipient_key_index, + &request, + )?; + let signature = + private_contact_request_signature(signer, &signing_key, &signing_data).await?; + let mut esk_seed = [0u8; 32]; StdRng::from_os_rng().fill_bytes(&mut esk_seed); let sealed = crypto @@ -267,6 +312,8 @@ impl DashPayView<'_, B> { &contract, &sender_identity, sender_key_index, + signing_key.id(), + &signature, &recipient_identity, recipient_key_index, &request, @@ -285,14 +332,13 @@ impl DashPayView<'_, B> { "the provider sealed an entry shorter than its commitment".to_string(), ) })?; - - let metadata = pay(entry).await?; - + // Everything the record needs is computed before paying. let shared_secret = crypto .ecdh_shared_secret(&sender_enc_path, &recipient_enc_pubkey) .await?; - // Its core height is unknown, so our receiving account for the contact - // is scanned from the wallet's birth (`receiving_scan_checkpoint`). + + let metadata = pay(entry).await?; + let contact_request = private_request_record( *sender_identity_id, *recipient_identity_id, @@ -301,51 +347,69 @@ impl DashPayView<'_, B> { &request, &commitment, &shared_secret, - 0, metadata.time_ms, ); - - { + let recorded = { let mut wm = self.wallet_manager.write().await; - let managed = wm - .get_wallet_info_mut(&self.wallet_id) - .ok_or_else(|| PlatformWalletError::WalletNotFound(hex::encode(self.wallet_id)))? - .identity_manager - .managed_identity_mut(sender_identity_id) - .ok_or(PlatformWalletError::IdentityNotFound(*sender_identity_id))?; - managed - .add_sent_contact_request(contact_request.clone(), &self.persister) - .map_err(|e| { - PlatformWalletError::Persistence(format!( - "sent private contact request not persisted: {e}" - )) - })?; - managed.note_private_contact(*recipient_identity_id, false, None); + match wm.get_wallet_info_mut(&self.wallet_id).and_then(|info| { + info.identity_manager + .managed_identity_mut(sender_identity_id) + }) { + None => Err("the sender identity is no longer in the wallet".to_string()), + Some(managed) => { + managed.note_sent_request_core_height(*recipient_identity_id, 0); + managed + .add_sent_contact_request(contact_request.clone(), &self.persister) + .map_err(|e| e.to_string()) + } + } + }; + match recorded { + Ok(()) => { + if let Err(e) = self + .register_contact_account( + sender_identity_id, + recipient_identity_id, + account_index, + contact_xpub_ext, + ) + .await + { + tracing::warn!( + sender = %sender_identity_id, + recipient = %recipient_identity_id, + error = %e, + "private contact request sent; its receiving account is not registered \ + yet, the next sync registers it" + ); + } + } + Err(e) => tracing::warn!( + sender = %sender_identity_id, + recipient = %recipient_identity_id, + error = %e, + "private contact request sent but not recorded; the next private sync \ + recovers it from the store" + ), } - - self.register_contact_account( - sender_identity_id, - recipient_identity_id, - account_index, - contact_xpub_ext, - ) - .await?; - Ok(contact_request) } /// Accept a contact request that came privately, replying privately: as /// [`Self::accept_contact_request_with_external_signer`], with the reply - /// sent by [`Self::send_private_contact_request`], `pay` paying for it and - /// `shielded_address` the address the contact may pay us at privately. - pub async fn accept_private_contact_request( + /// sent by [`Self::send_private_contact_request`], signed by `signer`, + /// `pay` paying for it and `shielded_address` the address the contact may + /// pay us at privately. + pub async fn accept_private_contact_request( &self, request: &ContactRequest, shielded_address: Option<[u8; 43]>, + signer: &S, crypto: &C, pay: F, ) -> Result where + S: Signer + Send + Sync, C: ContactCryptoProvider + Sync, F: FnOnce(BinaryData) -> Fut + Send, Fut: Future> + Send, @@ -357,6 +421,7 @@ impl DashPayView<'_, B> { None, AutoAcceptProofSource::None, shielded_address, + signer, crypto, pay, ) @@ -371,7 +436,7 @@ impl DashPayView<'_, B> { /// every `ECDSA_SECP256K1` decryption or encryption key, disabled ones /// included, as an entry sealed before a key was disabled still opens with /// it, and every such encryption key as a sender. - async fn private_contact_request_identities( + async fn private_scan_identities( &self, ) -> Result<(Vec, PrivateDocumentKeyPaths), PlatformWalletError> { let wm = self.wallet_manager.read().await; @@ -405,7 +470,7 @@ impl DashPayView<'_, B> { } recipient_paths.push((*key_id, path(*key_id)?)); if key.purpose() == Purpose::ENCRYPTION { - paths.senders.push((identity_id, path(*key_id)?)); + paths.senders.push((identity_id, *key_id, path(*key_id)?)); } } if recipient_paths.is_empty() { @@ -425,9 +490,11 @@ impl DashPayView<'_, B> { /// Read DashPay's private contact request store for the wallet's /// identities, at most `max_pages` pages from the earliest of their /// cursors, and ingest what it holds for them: requests to them, each - /// sender checked against the identity key the entry names, and requests - /// they sent, recovered from the store alone. Contacts establish, and their - /// DashPay accounts are queued, as the document sync does. + /// checked against the keys of its sender and of the identity it is to, + /// and requests they sent, recovered from the store alone and checked to + /// be theirs. Contacts establish, and their DashPay accounts are queued, + /// as the document sync does. A request older than the one tracked with + /// the same contact, either way and from either channel, is passed over. /// /// A request that came privately carries no auto-accept proof into the /// wallet: accepting it replies privately, which pays from the shielded @@ -441,7 +508,7 @@ impl DashPayView<'_, B> { where C: ContactCryptoProvider + Sync, { - let (mut identities, paths) = self.private_contact_request_identities().await?; + let (mut identities, paths) = self.private_scan_identities().await?; let mut report = PrivateContactSyncReport::default(); let Some(start) = identities.iter().map(|identity| identity.position).min() else { report.complete = true; @@ -464,6 +531,9 @@ impl DashPayView<'_, B> { ) }; let contract = dashpay_contract()?; + // Fetched once a pass: the senders, the wallet's own identities (whose + // keys a request is checked against as they are on chain), and the + // recipients of the requests they sent. let mut fetched_identities: BTreeMap> = BTreeMap::new(); let mut position = start; report.next_position = start; @@ -474,13 +544,13 @@ impl DashPayView<'_, B> { PRIVATE_CONTACT_REQUEST_DOCUMENT_TYPE_NAME, position, ); - let (page, metadata) = - PrivateDocumentEntries::fetch_with_metadata(&self.sdk, query, None).await?; - let page = page.ok_or_else(|| { - PlatformWalletError::InvalidIdentityData(format!( - "no page of DashPay's private contact requests from position {position}" - )) - })?; + let page = PrivateDocumentEntries::fetch(&self.sdk, query) + .await? + .ok_or_else(|| { + PlatformWalletError::InvalidIdentityData(format!( + "no page of DashPay's private contact requests from position {position}" + )) + })?; report.pages_read += 1; let scan = crypto @@ -492,20 +562,17 @@ impl DashPayView<'_, B> { &page, ) .await?; - for sender_id in private_contact_request_senders(&scan) { - if let Entry::Vacant(entry) = fetched_identities.entry(sender_id) { - entry.insert(Identity::fetch(&self.sdk, sender_id).await?); + for identity_id in private_contact_request_identities(&scan) { + if let Entry::Vacant(entry) = fetched_identities.entry(identity_id) { + entry.insert(Identity::fetch(&self.sdk, identity_id).await?); } } - let read = crypto - .read_private_contact_requests( - &paths, - &contract, - scan, - &fetched_identities, - self.sdk.version(), - ) - .await?; + let read = private_contact_requests_from_scan( + &contract, + scan, + &fetched_identities, + self.sdk.version(), + )?; for sent in &read.sent { if let Entry::Vacant(entry) = fetched_identities.entry(sent.recipient_id) { entry.insert(Identity::fetch(&self.sdk, sent.recipient_id).await?); @@ -514,7 +581,6 @@ impl DashPayView<'_, B> { // The wallet's records of the page's requests, under the shared // keys the signer computes. - let core_height = metadata.core_chain_locked_height; let mut records: Vec<(Identifier, ContactRequest, bool, Option<[u8; 43]>)> = Vec::new(); for received in &read.received { let Some(Some(sender)) = fetched_identities.get(&received.sender_id) else { @@ -536,7 +602,6 @@ impl DashPayView<'_, B> { &received.request, &received.commitment, &shared_secret, - core_height, received.block_time_ms, ); record.auto_accept_proof = None; @@ -575,8 +640,6 @@ impl DashPayView<'_, B> { &sent.recipient_public_key, ) .await?; - // Its core height is unknown: our receiving account for the - // contact is scanned from the wallet's birth. let record = private_request_record( sent.sender_id, sent.recipient_id, @@ -585,7 +648,6 @@ impl DashPayView<'_, B> { &sent.request, &sent.commitment, &shared_secret, - 0, sent.block_time_ms, ); records.push((sent.sender_id, record, false, None)); @@ -616,16 +678,19 @@ impl DashPayView<'_, B> { continue; }; let mut received = Vec::new(); + let mut addresses = Vec::new(); let mut sent = Vec::new(); for (owner, record, is_received, shielded_address) in &records { if *owner != identity_id { continue; } if *is_received { - managed.note_private_contact(record.sender_id, true, *shielded_address); + addresses.push((record.clone(), *shielded_address)); received.push(record.clone()); } else { - managed.note_private_contact(record.recipient_id, false, None); + // Our receiving account for the contact is + // scanned from the wallet's birth. + managed.note_sent_request_core_height(record.recipient_id, 0); sent.push(record.clone()); } } @@ -648,6 +713,11 @@ impl DashPayView<'_, B> { identity_id, newest_sent_per_recipient(sent), ); + // The address of the request tracked from each contact, + // once ingest decided which that is. + for (record, shielded_address) in &addresses { + managed.note_private_shielded_address(record, *shielded_address); + } if received_ok && sent_ok && !identity.failed { managed.advance_private_contact_requests_position( identity.position, @@ -674,7 +744,9 @@ impl DashPayView<'_, B> { report.next_position = read.next_position; position = read.next_position; if read.is_complete() { - report.complete = true; + // A pass whose ingest did not reach disk for an identity is + // not complete: that identity reads its entries again. + report.complete = report.unpersisted_identities.is_empty(); break; } } @@ -716,7 +788,8 @@ mod tests { const BOB: [u8; 32] = [0xB1; 32]; /// The time of the block that appends the test entries. const BLOCK_TIME_MS: u64 = 1_700_000_000_000; - /// The key ids of each test identity's DashPay keys. + /// The key ids of each test identity's signing key and DashPay keys. + const SIGNING_KEY_ID: KeyID = 0; const ENCRYPTION_KEY_ID: KeyID = 1; const DECRYPTION_KEY_ID: KeyID = 2; @@ -786,42 +859,59 @@ mod tests { .to_seed("") } - /// The identity `id` whose DashPay keys are those of identity index 0 of - /// `phrase`'s seed: its encryption key 1 and decryption key 2. - fn identity(phrase: &str, id: [u8; 32]) -> Identity { + /// The secret key `key_id` of identity index 0 of `phrase`'s seed. + fn identity_secret_key(phrase: &str, key_id: KeyID) -> SecretKey { let wallet = key_wallet::wallet::Wallet::from_seed_bytes( seed(phrase), Network::Testnet, WalletAccountCreationOptions::None, ) .expect("test wallet"); - let key = |key_id: KeyID, purpose: Purpose| { - let path = identity_auth_derivation_path_for_type( - Network::Testnet, - KeyDerivationType::ECDSA, - 0, - key_id, - ) - .expect("identity key path"); - let xprv = wallet - .derive_extended_private_key(&path) - .expect("identity key"); - let secret_key = - SecretKey::from_secret_bytes(*xprv.private_key.to_bytes()).expect("secret key"); + let path = identity_auth_derivation_path_for_type( + Network::Testnet, + KeyDerivationType::ECDSA, + 0, + key_id, + ) + .expect("identity key path"); + let xprv = wallet + .derive_extended_private_key(&path) + .expect("identity key"); + SecretKey::from_secret_bytes(*xprv.private_key.to_bytes()).expect("secret key") + } + + /// The identity `id` whose keys are those of identity index 0 of + /// `phrase`'s seed: its HIGH signing key 0, encryption key 1 and + /// decryption key 2. + fn identity(phrase: &str, id: [u8; 32]) -> Identity { + let key = |key_id: KeyID, purpose: Purpose, security_level: SecurityLevel| { IdentityPublicKey::V0(IdentityPublicKeyV0 { id: key_id, purpose, - security_level: SecurityLevel::MEDIUM, + security_level, contract_bounds: None, key_type: KeyType::ECDSA_SECP256K1, read_only: false, - data: BinaryData::new(PublicKey::from_secret_key(&secret_key).serialize().to_vec()), + data: BinaryData::new( + PublicKey::from_secret_key(&identity_secret_key(phrase, key_id)) + .serialize() + .to_vec(), + ), disabled_at: None, }) }; let keys = [ - key(ENCRYPTION_KEY_ID, Purpose::ENCRYPTION), - key(DECRYPTION_KEY_ID, Purpose::DECRYPTION), + key(SIGNING_KEY_ID, Purpose::AUTHENTICATION, SecurityLevel::HIGH), + key( + ENCRYPTION_KEY_ID, + Purpose::ENCRYPTION, + SecurityLevel::MEDIUM, + ), + key( + DECRYPTION_KEY_ID, + Purpose::DECRYPTION, + SecurityLevel::MEDIUM, + ), ]; Identity::V0(IdentityV0 { id: Identifier::from(id), @@ -831,6 +921,20 @@ mod tests { }) } + /// The signer holding `identity`'s signing key, as a host's signer holds + /// the keys it signs transitions with. + fn signer(phrase: &str, identity: &Identity) -> simple_signer::signer::SimpleSigner { + let mut signer = simple_signer::signer::SimpleSigner::default(); + signer.add_identity_public_key( + identity + .get_public_key_by_id(SIGNING_KEY_ID) + .expect("the signing key") + .clone(), + identity_secret_key(phrase, SIGNING_KEY_ID).to_secret_bytes(), + ); + signer + } + /// A wallet of `phrase` holding `own` at identity index 0, on a mock SDK that /// answers identity fetches with `known` and page 0 of DashPay's private /// contact request store with `entries`. @@ -949,6 +1053,8 @@ mod tests { let bob = identity(BOB_PHRASE, BOB); let alice_crypto = SeedCryptoProvider::from_seed(seed(ALICE_PHRASE), Network::Testnet); let bob_crypto = SeedCryptoProvider::from_seed(seed(BOB_PHRASE), Network::Testnet); + let alice_signer = signer(ALICE_PHRASE, &alice); + let bob_signer = signer(BOB_PHRASE, &bob); let shielded_address = [0x55; 43]; let (_alice_manager, alice_wallet) = wallet(ALICE_PHRASE, &alice, &[&bob], vec![]).await; @@ -962,6 +1068,7 @@ mod tests { Some("Alice".to_string()), AutoAcceptProofSource::None, Some(shielded_address), + &alice_signer, &alice_crypto, keep_entry(&kept), ) @@ -972,6 +1079,7 @@ mod tests { assert_eq!(sent.sender_key_index, ENCRYPTION_KEY_ID); assert_eq!(sent.recipient_key_index, DECRYPTION_KEY_ID); assert_eq!(sent.created_at, BLOCK_TIME_MS); + assert!(sent.came_privately(), "recorded at core height 0"); managed(&alice_wallet, ALICE, |managed| { assert_eq!( managed.dashpay().sent_contact_requests().get(&bob.id()), @@ -981,11 +1089,28 @@ mod tests { managed .dashpay() .private_contact(&bob.id()) - .expect("noted") + .expect("the request went privately") .sent_privately ); }) .await; + // Alice's public request to Bob would publish what she sent privately. + assert!(matches!( + alice_wallet + .identity() + .dashpay() + .send_contact_request_with_external_signer( + &alice.id(), + &bob.id(), + None, + AutoAcceptProofSource::None, + &alice_signer, + &alice_crypto, + ) + .await, + Err(PlatformWalletError::PrivateContactRequestNeedsPrivateReply(recipient)) + if recipient == bob.id() + )); let (_bob_manager, bob_wallet) = wallet(BOB_PHRASE, &bob, &[&alice, &bob], vec![entry.to_vec()]).await; @@ -1018,6 +1143,7 @@ mod tests { assert_eq!(received.recipient_key_index, DECRYPTION_KEY_ID); assert_eq!(received.account_reference, sent.account_reference); assert_eq!(received.created_at, BLOCK_TIME_MS); + assert!(received.came_privately(), "recorded at core height 0"); assert_eq!(received.auto_accept_proof, None); // The shared key Bob's decryption key and Alice's encryption key agree // on opens the xpub and label, as it opens a document's. @@ -1058,10 +1184,9 @@ mod tests { ); // A public reply would publish the relationship. - let signer = simple_signer::signer::SimpleSigner::default(); assert!(matches!( bob_view - .accept_contact_request_with_external_signer(&received, &signer, &bob_crypto) + .accept_contact_request_with_external_signer(&received, &bob_signer, &bob_crypto) .await, Err(PlatformWalletError::PrivateContactRequestNeedsPrivateReply(sender)) if sender == alice.id() @@ -1069,7 +1194,13 @@ mod tests { let reply = Arc::new(Mutex::new(Vec::new())); let established = bob_view - .accept_private_contact_request(&received, None, &bob_crypto, keep_entry(&reply)) + .accept_private_contact_request( + &received, + None, + &bob_signer, + &bob_crypto, + keep_entry(&reply), + ) .await .expect("Bob accepts privately"); assert_eq!(established.contact_identity_id, alice.id()); @@ -1130,6 +1261,7 @@ mod tests { let alice = identity(ALICE_PHRASE, ALICE); let bob = identity(BOB_PHRASE, BOB); let alice_crypto = SeedCryptoProvider::from_seed(seed(ALICE_PHRASE), Network::Testnet); + let alice_signer = signer(ALICE_PHRASE, &alice); let (_manager, alice_wallet) = wallet(ALICE_PHRASE, &alice, &[&bob], vec![]).await; let kept = Arc::new(Mutex::new(Vec::new())); @@ -1142,6 +1274,7 @@ mod tests { None, AutoAcceptProofSource::None, None, + &alice_signer, &alice_crypto, keep_entry(&kept), ) @@ -1149,8 +1282,9 @@ mod tests { .expect("Alice sends the request"); let entry = kept.lock().expect("kept entries").remove(0); + // The restored wallet checks its own identity's keys as on chain. let (_restored_manager, restored) = - wallet(ALICE_PHRASE, &alice, &[&bob], vec![entry.to_vec()]).await; + wallet(ALICE_PHRASE, &alice, &[&alice, &bob], vec![entry.to_vec()]).await; let report = restored .identity() .dashpay() @@ -1187,11 +1321,11 @@ mod tests { &request, &commitment, &[9; 32], - 0, BLOCK_TIME_MS, ) }; assert_eq!(record([1; 32]), record([1; 32])); + assert!(record([1; 32]).came_privately()); assert_ne!( record([1; 32]).encrypted_public_key, record([2; 32]).encrypted_public_key, diff --git a/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/contact_requests.rs b/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/contact_requests.rs index 6094079713f..ae586e2d388 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/contact_requests.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/contact_requests.rs @@ -807,24 +807,37 @@ impl ManagedIdentity { } } - /// Record what a private contact request with `contact` carries beyond the - /// [`ContactRequest`]: `received` when it came from the contact, else it - /// went to the contact. A request without a shielded address keeps the one - /// already known. - pub(crate) fn note_private_contact( + /// Record the shielded address `request`, a private request from + /// `request.sender_id`, carries, when it is the request tracked from that + /// contact: an older request ingest passed over does not replace the + /// address of the newer one. + pub(crate) fn note_private_shielded_address( &mut self, - contact: Identifier, - received: bool, + request: &ContactRequest, shielded_address: Option<[u8; 43]>, ) { - let entry = self.dashpay.private_contacts.entry(contact).or_default(); - if received { - entry.received_privately = true; - if shielded_address.is_some() { - entry.shielded_address = shielded_address; + let tracked = self + .dashpay + .incoming_request(&request.sender_id) + .is_some_and(|tracked| { + tracked.came_privately() + && tracked.account_reference == request.account_reference + && tracked.encrypted_public_key == request.encrypted_public_key + }); + if !tracked { + return; + } + match shielded_address { + Some(address) => { + self.dashpay + .private_shielded_addresses + .insert(request.sender_id, address); + } + None => { + self.dashpay + .private_shielded_addresses + .remove(&request.sender_id); } - } else { - entry.sent_privately = true; } } diff --git a/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/dashpay.rs b/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/dashpay.rs index 0b626a58165..23b8ed5fac8 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/dashpay.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/dashpay.rs @@ -13,14 +13,15 @@ use crate::wallet::identity::{ use dpp::prelude::Identifier; use std::collections::{BTreeMap, BTreeSet}; -/// What the private contact requests between an identity and a contact -/// (protocol version 15) carry beyond what a [`ContactRequest`] holds. -#[derive(Debug, Clone, Default, PartialEq, Eq)] +/// How an identity and a contact exchanged private contact requests (protocol +/// version 15), and what those carry beyond what a [`ContactRequest`] holds. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] pub struct PrivateContact { - /// The contact's request to this identity came privately, so accepting it - /// replies privately too. + /// The contact's tracked request to this identity came privately + /// ([`ContactRequest::came_privately`]), so accepting it replies privately + /// too. pub received_privately: bool, - /// This identity's request to the contact went privately. + /// This identity's tracked request to the contact went privately. pub sent_privately: bool, /// The Orchard address the contact asked to be paid at privately: an /// 11-byte diversifier and a 32-byte transmission key. @@ -103,11 +104,11 @@ pub struct DashPayState { /// [`ManagedIdentity::unignore_sender`]: super::ManagedIdentity::unignore_sender pub(super) private_contact_requests_position: u64, - /// What this identity's private contact requests carry beyond what a - /// [`ContactRequest`] holds, keyed by the contact: whether the requests - /// came or went privately, and the contact's shielded address. In memory - /// only, rebuilt by the scan a cold start runs. - pub(super) private_contacts: BTreeMap, + /// The shielded address each contact's tracked private request to this + /// identity carries. In memory only, rebuilt by the scan a cold start + /// runs. Whether a request came privately is the request's own + /// ([`ContactRequest::came_privately`]), so it holds across restarts. + pub(super) private_shielded_addresses: BTreeMap, /// Lowest Platform-assigned `$createdAtCoreBlockHeight` among OUR sent /// `contactRequest` docs to each recipient seen by a sweep this process. @@ -263,16 +264,39 @@ impl DashPayState { .or_else(|| self.sent_contact_requests.get(contact)) } + /// The contact's tracked request to us: the established contact's + /// incoming side, else a pending incoming request. + pub fn incoming_request(&self, contact: &Identifier) -> Option<&ContactRequest> { + self.established_contacts + .get(contact) + .map(|established| &established.incoming_request) + .or_else(|| self.incoming_contact_requests.get(contact)) + } + /// The position of DashPay's private contact request store to scan from /// next for this identity; see the field doc. pub fn private_contact_requests_position(&self) -> u64 { self.private_contact_requests_position } - /// What this identity's private contact requests with `contact` carry; see - /// the field doc. - pub fn private_contact(&self, contact: &Identifier) -> Option<&PrivateContact> { - self.private_contacts.get(contact) + /// How this identity and `contact` exchanged private requests, from the + /// tracked requests either way; `None` when neither went privately. + pub fn private_contact(&self, contact: &Identifier) -> Option { + let received_privately = self + .incoming_request(contact) + .is_some_and(ContactRequest::came_privately); + let sent_privately = self + .outgoing_request(contact) + .is_some_and(ContactRequest::came_privately); + (received_privately || sent_privately).then(|| PrivateContact { + received_privately, + sent_privately, + shielded_address: self + .private_shielded_addresses + .get(contact) + .copied() + .filter(|_| received_privately), + }) } /// Whether a sent-request sweep completed this process; see the field doc. diff --git a/packages/rs-platform-wallet/src/wallet/identity/types/dashpay/contact_request.rs b/packages/rs-platform-wallet/src/wallet/identity/types/dashpay/contact_request.rs index c039823fa34..d6ac8b83ec7 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/types/dashpay/contact_request.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/types/dashpay/contact_request.rs @@ -70,6 +70,15 @@ impl ContactRequest { } } + /// Whether the request came as a private contact request (protocol + /// version 15) rather than a `contactRequest` document. A document always + /// carries its Platform-assigned `$createdAtCoreBlockHeight`, which + /// DashPay requires and no Core chain puts at 0; a private request's entry + /// carries none, so the wallet records it at 0. + pub fn came_privately(&self) -> bool { + self.core_height_created_at == 0 + } + /// Check if this is an outgoing request for the given identity pub fn is_outgoing(&self, identity_id: &Identifier) -> bool { self.sender_id == *identity_id diff --git a/packages/rs-platform-wallet/src/wallet/platform_wallet.rs b/packages/rs-platform-wallet/src/wallet/platform_wallet.rs index 6de24a84afd..5d14a7c7f2c 100644 --- a/packages/rs-platform-wallet/src/wallet/platform_wallet.rs +++ b/packages/rs-platform-wallet/src/wallet/platform_wallet.rs @@ -45,11 +45,14 @@ use dpp::prelude::Identifier; #[cfg(feature = "shielded")] use { super::identity::network::dashpay_contract, + super::shielded::operations::private_document_create_from_pool_with_metadata, + super::shielded::{NetworkShieldedCoordinator, OrchardKeySet}, crate::{AutoAcceptProofSource, ContactCryptoProvider, ContactRequest, EstablishedContact}, dash_sdk::dapi_grpc::platform::v0::ResponseMetadata, dash_sdk::platform::dashpay::PRIVATE_CONTACT_REQUEST_DOCUMENT_TYPE_NAME, dpp::data_contract::accessors::v0::DataContractV0Getters, dpp::platform_value::BinaryData, + dpp::shielded::builder::OrchardProver, }; /// Unique identifier for a wallet (32-byte hash). @@ -1542,15 +1545,16 @@ impl PlatformWallet { /// `sender_identity_id` to `recipient_identity_id`, paying for it from /// `account`'s shielded notes: nothing on chain names either identity. The /// request carries `account_label`, `auto_accept_proof` and our - /// `shielded_address` for the contact, and is sealed by `crypto`, which - /// keeps the keys. `seed` supplies the transient spend authority (see + /// `shielded_address` for the contact, is signed by `signer` as the + /// sender's transitions are, and is sealed by `crypto`, which keeps the + /// encryption key. `seed` supplies the transient spend authority (see /// [`shielded_transfer_to`](Self::shielded_transfer_to)). See /// [`DashPayView::send_private_contact_request`](crate::DashPayView::send_private_contact_request). #[cfg(feature = "shielded")] #[allow(clippy::too_many_arguments)] - pub async fn send_private_contact_request( + pub async fn send_private_contact_request( &self, - coordinator: &Arc, + coordinator: &Arc, seed: &[u8], account: u32, sender_identity_id: &Identifier, @@ -1558,12 +1562,14 @@ impl PlatformWallet { account_label: Option, auto_accept_proof: AutoAcceptProofSource, shielded_address: Option<[u8; 43]>, + signer: &S, crypto: &C, prover: P, ) -> Result where + S: Signer + Send + Sync, C: ContactCryptoProvider + Sync, - P: dpp::shielded::builder::OrchardProver + Send + Sync, + P: OrchardProver + Send + Sync, { let keyset = self.derive_spend_keyset(seed, account).await?; self.identity @@ -1574,6 +1580,7 @@ impl PlatformWallet { account_label, auto_accept_proof, shielded_address, + signer, crypto, |entry| { self.pay_private_contact_request(coordinator, keyset, account, entry, prover) @@ -1585,29 +1592,31 @@ impl PlatformWallet { /// Accept a DashPay contact request that came privately, replying /// privately and paying for the reply from `account`'s shielded notes, as /// [`send_private_contact_request`](Self::send_private_contact_request) - /// pays. `shielded_address` is the address the contact may pay us at - /// privately. See + /// pays and signing it with `signer`. `shielded_address` is the address + /// the contact may pay us at privately. See /// [`DashPayView::accept_private_contact_request`](crate::DashPayView::accept_private_contact_request). #[cfg(feature = "shielded")] #[allow(clippy::too_many_arguments)] - pub async fn accept_private_contact_request( + pub async fn accept_private_contact_request( &self, - coordinator: &Arc, + coordinator: &Arc, seed: &[u8], account: u32, request: &ContactRequest, shielded_address: Option<[u8; 43]>, + signer: &S, crypto: &C, prover: P, ) -> Result where + S: Signer + Send + Sync, C: ContactCryptoProvider + Sync, - P: dpp::shielded::builder::OrchardProver + Send + Sync, + P: OrchardProver + Send + Sync, { let keyset = self.derive_spend_keyset(seed, account).await?; self.identity .dashpay() - .accept_private_contact_request(request, shielded_address, crypto, |entry| { + .accept_private_contact_request(request, shielded_address, signer, crypto, |entry| { self.pay_private_contact_request(coordinator, keyset, account, entry, prover) }) .await @@ -1617,16 +1626,16 @@ impl PlatformWallet { /// paying from `account`'s notes, and return the metadata of the proof /// that it was appended. #[cfg(feature = "shielded")] - async fn pay_private_contact_request( + async fn pay_private_contact_request( &self, - coordinator: &Arc, - keyset: super::shielded::OrchardKeySet, + coordinator: &Arc, + keyset: OrchardKeySet, account: u32, entry: BinaryData, prover: P, ) -> Result { let dashpay_id = dashpay_contract()?.id(); - super::shielded::operations::private_document_create_from_pool_with_metadata( + private_document_create_from_pool_with_metadata( &self.sdk, coordinator.store(), Some(&self.persister), diff --git a/packages/rs-platform-wallet/src/wallet/shielded/operations.rs b/packages/rs-platform-wallet/src/wallet/shielded/operations.rs index a1a16d01cb9..73cfc46bab9 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/operations.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/operations.rs @@ -57,6 +57,7 @@ use dpp::identity::identity_nonce::{ use dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; use dpp::identity::signer::Signer; use dpp::identity::{Identity, IdentityPublicKey}; +use dpp::platform_value::BinaryData; use dpp::prelude::{Identifier, IdentityNonce}; use dpp::shielded::builder::{ build_identity_create_from_shielded_pool_transition, @@ -1584,7 +1585,7 @@ pub(in crate::wallet) async fn private_document_create_from_pool_with_metadata< account: u32, data_contract_id: Identifier, document_type_name: &str, - entries: Vec, + entries: Vec, prover: &P, ) -> Result { let views = keys.viewing_keys(); @@ -3820,7 +3821,6 @@ mod redrive_tests { use dpp::consensus::state::shielded::nullifier_already_spent_error::NullifierAlreadySpentError; use dpp::consensus::state::state_error::StateError; use dpp::consensus::ConsensusError; - use dpp::platform_value::BinaryData; use dpp::serialization::PlatformSerializable; use dpp::state_transition::shield_from_identity_transition::v0::ShieldFromIdentityTransitionV0; From 6db333ec6a479fa4352146555aca5c08df3a758d Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Sun, 8 Nov 2026 19:08:17 +0700 Subject: [PATCH 007/117] fix(sdk): bind the recipient key into a private request's signature Review fixes: - The envelope's authenticated data names the recipient key `ser(R)`, so a body re-encrypted to another key of the same identity (an old key's holder re-targeting a signed request) authenticates no sender. `private_document_signing_data` takes the recipient. The MAC and signature vectors are re-pinned; nothing is deployed. - A signing key bound to a contract signs only inside its bounds: DashPay, or its `privateContactRequest` type. A contract group bound signs none, as a client cannot read the group's members as they were. The seal and the reader apply the same rule; `private_contact_request_signing_keys` lists the keys that qualify. - `PrivateContactRequest::from_properties` drops a null only under an optional property's name, so an unknown property set to null is refused. - Recovered sent requests are deduplicated per sender, recipient and account reference, apart from the received ones. - Senders added with the same outgoing viewing key: an entry recovers as the one it names. - Docs: the transition's proof shows the nullifiers spent, not that its entries were appended; fetching a sender right after its page tells the serving node which identity the wallet looked up. Co-Authored-By: Claude Opus 5.5 --- .../src/private_document_envelope.rs | 47 ++- .../dashpay/private_contact_request.rs | 290 ++++++++++++++++-- .../src/platform/private_document_scan.rs | 39 ++- ...vate_document_create_from_shielded_pool.rs | 8 +- 4 files changed, 339 insertions(+), 45 deletions(-) diff --git a/packages/rs-platform-encryption/src/private_document_envelope.rs b/packages/rs-platform-encryption/src/private_document_envelope.rs index 831f56afb5d..3ad0def302a 100644 --- a/packages/rs-platform-encryption/src/private_document_envelope.rs +++ b/packages/rs-platform-encryption/src/private_document_envelope.rs @@ -39,10 +39,13 @@ //! //! ```text //! m = H("Dash/PrivDoc/v1/auth" || version || auth mode || contract_id || u32le(len(type)) -//! || type || sender identity id || sender key id || recipient identity id +//! || type || sender identity id || sender key id || recipient identity id || ser(R) //! || payload length || payload) //! ``` //! +//! `m` names the recipient key, so an entry re-encrypted to another key of the same identity +//! carries no valid authentication. +//! //! - anonymous (0): sender fields and `auth` are zeros; //! - deniable MAC (1): `auth` is //! `HMAC-SHA256(H("Dash/PrivDoc/v1/mac" || ser(sender key * R)), m)`, then zeros. The @@ -322,12 +325,13 @@ impl OpenedPrivateDocumentEntry { } /// What the sender of a signed entry signs, `m` above: an identity signer signs it as data, -/// that is its double SHA-256. The fields are the entry's, so it is computed before sealing. +/// that is its double SHA-256. The fields are the entry's, `recipient` included, so it is +/// computed before sealing. pub fn private_document_signing_data( target: &PrivateDocumentTarget, sender_identity_id: &[u8; 32], sender_key_id: u32, - recipient_identity_id: &[u8; 32], + recipient: &PrivateDocumentRecipient, payload: &[u8], ) -> [u8; 32] { auth_message( @@ -335,7 +339,8 @@ pub fn private_document_signing_data( PrivateDocumentAuthMode::Signature, sender_identity_id, sender_key_id, - recipient_identity_id, + &recipient.identity_id, + &recipient.public_key.serialize(), payload, ) } @@ -440,6 +445,7 @@ pub fn seal_private_document_entry( &sender_identity_id, sender_key_id, &recipient.identity_id, + &recipient_key, payload, ); let mut auth = [0u8; AUTH_LEN]; @@ -711,6 +717,7 @@ fn parse_body( &sender_identity_id, sender_key_id, &recipient_identity_id, + &recipient_public_key.serialize(), payload, ); Ok(OpenedPrivateDocumentEntry { @@ -791,6 +798,7 @@ fn auth_message( sender_identity_id: &[u8; 32], sender_key_id: u32, recipient_identity_id: &[u8; 32], + recipient_key: &[u8; 33], payload: &[u8], ) -> [u8; 32] { hash(&[ @@ -802,6 +810,7 @@ fn auth_message( sender_identity_id, &sender_key_id.to_le_bytes(), recipient_identity_id, + recipient_key, &(payload.len() as u16).to_le_bytes(), payload, ]) @@ -879,7 +888,10 @@ mod tests { &target(), &SENDER_IDENTITY, 4, - &RECIPIENT_IDENTITY, + &PrivateDocumentRecipient { + identity_id: RECIPIENT_IDENTITY, + public_key: &public_key(2), + }, &payload(), ), secret_key, @@ -1019,6 +1031,27 @@ mod tests { ); let opened = open(&entry, &[secret_key(2)]).expect("it opens"); assert_ne!(opened.recover_signer(), Ok(public_key(1))); + // The signature names the recipient key: the same body sealed to another key of the + // recipient identity is signed by no one. + let retargeted = seal_private_document_entry( + &target(), + ENTRY_SIZE, + &PrivateDocumentRecipient { + identity_id: RECIPIENT_IDENTITY, + public_key: &public_key(3), + }, + &PrivateDocumentSender::Signature { + identity_id: SENDER_IDENTITY, + key_id: 4, + signature: &signature, + }, + &payload(), + &outgoing_viewing_key(), + &[9; 32], + ) + .expect("the payload fits"); + let opened = open(&retargeted, &[secret_key(3)]).expect("it opens"); + assert_ne!(opened.recover_signer(), Ok(public_key(1))); let mut not_a_signature = signature; not_a_signature[0] = 27; assert_eq!( @@ -1361,8 +1394,8 @@ mod tests { pinned, [ "abb644128f3e73aade8b6210573fba942709e7e44eaeffef92dc02d5187f37d3", - "bedf5084aca776ae520e1395e4a4f92628f4589f04b244630acc890649f74a82", - "17ce85ed9211640dc929b27aee7c4349208bda374cf9594f83e6c71b71d1d407", + "7250a6613ff5aa57f28cc57bd3cdd37824e4f2d3ae634e623e53d032387ce261", + "05f544901039b5b112a0c55863db0bfd37951156f0639e94d65eb209da106502", ] ); } diff --git a/packages/rs-sdk/src/platform/dashpay/private_contact_request.rs b/packages/rs-sdk/src/platform/dashpay/private_contact_request.rs index 24d50f64b96..067ee7fb3d1 100644 --- a/packages/rs-sdk/src/platform/dashpay/private_contact_request.rs +++ b/packages/rs-sdk/src/platform/dashpay/private_contact_request.rs @@ -37,6 +37,7 @@ use dpp::data_contract::document_type::{ use dpp::data_contract::DataContract; use dpp::identity::accessors::IdentityGettersV0; use dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; +use dpp::identity::identity_public_key::contract_bounds::ContractBounds; use dpp::identity::signer::Signer; use dpp::identity::{Identity, IdentityPublicKey, KeyID, KeyType, Purpose, SecurityLevel}; use dpp::platform_value::{BinaryData, Value}; @@ -122,8 +123,12 @@ impl PrivateContactRequest { pub fn from_properties(mut properties: BTreeMap) -> Result { let invalid = |message: String| Error::Generic(format!("private contact request: {message}")); - // An absent optional property may read back as null. - properties.retain(|_, value| !value.is_null()); + // An absent optional property may read back as null. A null under any other name is + // left for the unknown property check below. + properties.retain(|name, value| { + !(value.is_null() + && [ACCOUNT_LABEL, AUTO_ACCEPT_PROOF, SHIELDED_ADDRESS].contains(&name.as_str())) + }); let mut take_bytes = |name: &str| -> Result>, Error> { properties .remove(name) @@ -354,6 +359,7 @@ pub fn seal_private_contact_request( if let Some(problem) = signing_key_problem( signing_key, &private_contact_request_signing_security_levels(dashpay_contract)?, + dashpay_contract.id(), None, ) { return Err(Error::Generic(problem)); @@ -447,7 +453,10 @@ impl SealInputs { }, &sender.id().to_buffer(), encryption_key_id, - &recipient.id().to_buffer(), + &PrivateDocumentRecipient { + identity_id: recipient.id().to_buffer(), + public_key: &recipient_public_key, + }, &payload, ); Ok(Self { @@ -526,12 +535,14 @@ fn disabled_problem(key: &IdentityPublicKey, role: &str, at_ms: Option) -> } } -/// Why `key` may not sign a private contact request at `at_ms` (now when `None`), if it may -/// not: it must be an `AUTHENTICATION` key at one of `levels`, of a signing key type, and -/// enabled then. +/// Why `key` may not sign a private contact request of the DashPay contract `dashpay_id` at +/// `at_ms` (now when `None`), if it may not: it must be an `AUTHENTICATION` key at one of +/// `levels`, of a signing key type, bound to no other contract or document type, and enabled +/// then. fn signing_key_problem( key: &IdentityPublicKey, levels: &[SecurityLevel], + dashpay_id: Identifier, at_ms: Option, ) -> Option { if key.purpose() != Purpose::AUTHENTICATION { @@ -555,9 +566,46 @@ fn signing_key_problem( key.key_type() )); } + // A bound key signs only inside its bounds, as a transition's does. A group bound depends + // on the group's members when the entry was appended, which a client cannot read, so it + // signs no private request. + match key.contract_bounds() { + None => {} + Some(ContractBounds::SingleContract { id }) if *id == dashpay_id => {} + Some(ContractBounds::SingleContractDocumentType { + id, + document_type_name, + }) if *id == dashpay_id + && document_type_name == PRIVATE_CONTACT_REQUEST_DOCUMENT_TYPE_NAME => {} + Some(bounds) => { + return Some(format!( + "the signing key {} is bound to the {} {}, not to DashPay's \ + {PRIVATE_CONTACT_REQUEST_DOCUMENT_TYPE_NAME}", + key.id(), + bounds.contract_bounds_type_string(), + bounds.identifier() + )) + } + } disabled_problem(key, "signing", at_ms) } +/// The keys of `identity` that may sign a private contact request now, in key id order: its +/// enabled `AUTHENTICATION` keys at a level the type's documents are signed at, of a signing +/// key type, bound to no other contract or document type. A wallet signs with the first its +/// signer holds. +pub fn private_contact_request_signing_keys<'a>( + dashpay_contract: &DataContract, + identity: &'a Identity, +) -> Result, Error> { + let levels = private_contact_request_signing_security_levels(dashpay_contract)?; + Ok(identity + .public_keys() + .values() + .filter(|key| signing_key_problem(key, &levels, dashpay_contract.id(), None).is_none()) + .collect()) +} + /// Whether the identity key `key` is the secp256k1 key `public_key`. fn key_is(key: &IdentityPublicKey, public_key: &PublicKey) -> bool { match key.key_type() { @@ -660,7 +708,8 @@ pub enum PrivateContactRequestRejection { /// position to read again. InvalidRequest(String), /// An earlier entry of the scan holds a request from the same sender to the same - /// recipient for the same account reference, which is kept. + /// recipient for the same account reference, which is kept. Received and sent requests are + /// counted apart, so a request the wallet both sent and received is kept on both sides. Duplicate, } @@ -700,6 +749,12 @@ impl PrivateContactRequestScan { /// The identities whose keys [`private_contact_requests_from_scan`] checks the entries of /// `scan` against: the senders received entries name, the wallet's identities they are to, and /// the wallet's identities that sent the recovered ones. +/// +/// Fetching a sender right after the page that holds its request tells the node that serves +/// both which identity the wallet looked up, and so that someone it knows sent the wallet a +/// request. A wallet that knows a sender already (a contact, or one fetched for other reasons) +/// passes the identity it holds, and fetches the others from another node or later, as its +/// privacy needs. pub fn private_contact_request_identities(scan: &PrivateDocumentScan) -> BTreeSet { scan.received .iter() @@ -741,6 +796,7 @@ pub fn private_contact_requests_from_scan( let reader = EntryReader { identities, signing_levels: &signing_levels, + dashpay_id: dashpay_contract.id(), decode: &|payload: &[u8]| { let properties = decode_private_document_payload(document_type, payload, platform_version).map_err( @@ -800,24 +856,37 @@ pub fn private_contact_requests_from_scan( } } + let mut seen_sent = BTreeSet::new(); for sent in scan.sent { + let reject = |reason| RejectedPrivateContactRequest { + position: sent.position, + block_height: sent.block_height, + reason, + }; match reader.read_sent(&sent) { - Ok(request) => result.sent.push(SentPrivateContactRequest { - position: sent.position, - block_height: sent.block_height, - block_time_ms: sent.block_time_ms, - sender_id: sent.sender_identity_id, - sender_key_id: sent.entry.sender_key_id(), - recipient_id: Identifier::from(*sent.entry.recipient_identity_id()), - recipient_public_key: *sent.entry.recipient_public_key(), - commitment: *sent.entry.commitment(), - request, - }), - Err(reason) => rejected.push(RejectedPrivateContactRequest { - position: sent.position, - block_height: sent.block_height, - reason, - }), + Ok(request) => { + let recipient_id = Identifier::from(*sent.entry.recipient_identity_id()); + if !seen_sent.insert(( + sent.sender_identity_id, + recipient_id, + request.account_reference, + )) { + rejected.push(reject(PrivateContactRequestRejection::Duplicate)); + continue; + } + result.sent.push(SentPrivateContactRequest { + position: sent.position, + block_height: sent.block_height, + block_time_ms: sent.block_time_ms, + sender_id: sent.sender_identity_id, + sender_key_id: sent.entry.sender_key_id(), + recipient_id, + recipient_public_key: *sent.entry.recipient_public_key(), + commitment: *sent.entry.commitment(), + request, + }); + } + Err(reason) => rejected.push(reject(reason)), } } @@ -833,6 +902,7 @@ type DecodeRequest<'a> = struct EntryReader<'a> { identities: &'a BTreeMap>, signing_levels: &'a [SecurityLevel], + dashpay_id: Identifier, decode: &'a DecodeRequest<'a>, } @@ -914,7 +984,12 @@ impl EntryReader<'_> { if !key_is(key, &signer) { continue; } - match signing_key_problem(key, self.signing_levels, Some(block_time_ms)) { + match signing_key_problem( + key, + self.signing_levels, + self.dashpay_id, + Some(block_time_ms), + ) { None => return Ok(()), Some(key_problem) => { problem.get_or_insert(key_problem); @@ -949,17 +1024,13 @@ impl Sdk { S: Signer + ?Sized, { let dashpay_contract = self.fetch_dashpay_contract().await?; - let levels = private_contact_request_signing_security_levels(&dashpay_contract)?; - let signing_key = sender - .public_keys() - .values() - .find(|key| { - signing_key_problem(key, &levels, None).is_none() && signer.can_sign_with(key) - }) + let signing_key = private_contact_request_signing_keys(&dashpay_contract, sender)? + .into_iter() + .find(|key| signer.can_sign_with(key)) .ok_or_else(|| { Error::Generic(format!( "the signer holds no key of {} that signs a private contact request: an \ - enabled AUTHENTICATION key at {levels:?}", + enabled AUTHENTICATION key at HIGH or CRITICAL, bound to no other contract", sender.id() )) })?; @@ -1004,6 +1075,11 @@ impl Sdk { /// stored what the page held, and scans again from it until /// [`PrivateContactRequestScan::is_complete`]. A failed fetch fails the scan, to be /// retried from the same cursor. + /// + /// The identities are fetched right after the page, which tells the serving node which + /// identities the wallet looked up (see [`private_contact_request_identities`]). A wallet + /// that cares calls [`Sdk::scan_private_document_page`] and + /// [`private_contact_requests_from_scan`] itself, with identities it already holds. pub async fn scan_private_contact_requests( &self, start_position: u64, @@ -1471,6 +1547,7 @@ mod tests { "an unknown property", with("note", Value::Text("hi".to_string())), ), + ("an unknown property set to null", with("note", Value::Null)), ("no account reference", without(ACCOUNT_REFERENCE)), ("no extended public key", without(EXTENDED_PUBLIC_KEY)), ] { @@ -1763,7 +1840,10 @@ mod tests { }, &identity_id, key_id, - &[0xD0; 32], + &PrivateDocumentRecipient { + identity_id: [0xD0; 32], + public_key: &PublicKey::from_secret_key(&secret_key(40)), + }, &payload(&contract, &request), ); let signature = sign_private_document_signing_data(&signing_data, signing_secret); @@ -2008,6 +2088,150 @@ mod tests { ); } + /// A signing key bound to a contract signs only inside its bounds: DashPay, or DashPay's + /// private contact requests. A group bound, whose members a client cannot read as they + /// were, signs none. The seal and the reader apply the same rule. + #[test] + fn should_sign_only_with_a_key_bound_to_dashpay_or_unbound() { + let contract = dashpay(); + let dashpay_id = contract.id(); + let other_id = Identifier::from([0x77; 32]); + let bound = |bounds: Option| { + with_key( + sender_identity(None), + IdentityPublicKey::V0(IdentityPublicKeyV0 { + id: 0, + purpose: Purpose::AUTHENTICATION, + security_level: SecurityLevel::HIGH, + contract_bounds: bounds, + key_type: KeyType::ECDSA_SECP256K1, + read_only: false, + data: BinaryData::new( + PublicKey::from_secret_key(&secret_key(9)) + .serialize() + .to_vec(), + ), + disabled_at: None, + }), + ) + }; + let document_type = |id, name: &str| { + Some(ContractBounds::SingleContractDocumentType { + id, + document_type_name: name.to_string(), + }) + }; + let entry = seal(&contract, &sender_identity(None), &smallest_request(1), 1); + for (case, bounds, allowed) in [ + ( + "bound to DashPay", + Some(ContractBounds::SingleContract { id: dashpay_id }), + true, + ), + ( + "bound to DashPay's private contact requests", + document_type(dashpay_id, PRIVATE_CONTACT_REQUEST_DOCUMENT_TYPE_NAME), + true, + ), + ( + "bound to another contract", + Some(ContractBounds::SingleContract { id: other_id }), + false, + ), + ( + "bound to another DashPay type", + document_type(dashpay_id, "contactRequest"), + false, + ), + ( + "bound to a contract group", + Some(ContractBounds::ContractGroup { id: other_id }), + false, + ), + ] { + let sender = bound(bounds); + let reasons = reasons(read( + &contract, + vec![entry.clone()], + &recipient_keys(), + &[sender.clone(), recipient_identity()], + )); + let request = smallest_request(1); + let sealed = seal_private_contact_request( + &contract, + &PrivateContactRequestSender { + identity: &sender, + encryption_key_id: 1, + encryption_secret_key: &secret_key(1), + signing_key_id: 0, + signature: &signature(&contract, &sender, &request, &secret_key(9)), + }, + &recipient_identity(), + 2, + &request, + &[1; 32], + ); + let signing_keys = private_contact_request_signing_keys(&contract, &sender) + .expect("DashPay v3") + .len(); + if allowed { + assert_eq!(reasons, vec![], "{case}: read"); + assert!(sealed.is_ok(), "{case}: seal"); + assert_eq!(signing_keys, 1, "{case}: listed"); + } else { + assert!( + matches!( + reasons.as_slice(), + [PrivateContactRequestRejection::SenderKeyNotUsable(_)] + ), + "{case}: read {reasons:?}" + ); + assert!(sealed.is_err(), "{case}: seal"); + assert_eq!(signing_keys, 0, "{case}: not listed"); + } + } + } + + /// Two recovered sent requests to the same recipient for the same account reference are + /// one request: the first is kept. A request the wallet both sent and received is kept on + /// both sides. + #[test] + fn should_keep_the_first_of_two_sent_requests_for_one_account_reference() { + let contract = dashpay(); + let sender = sender_identity(None); + let entries = vec![ + seal(&contract, &sender, &smallest_request(1), 1), + seal(&contract, &sender, &smallest_request(1), 2), + ]; + let scan = read( + &contract, + entries, + &scan_keys(), + &[sender, recipient_identity()], + ); + assert_eq!( + scan.sent + .iter() + .map(|sent| sent.position) + .collect::>(), + vec![0] + ); + assert_eq!( + scan.received + .iter() + .map(|received| received.position) + .collect::>(), + vec![0] + ); + assert_eq!( + reasons(scan), + vec![ + PrivateContactRequestRejection::Duplicate, + PrivateContactRequestRejection::Duplicate, + ] + ); + } + /// DashPay's documents are signed at HIGH or CRITICAL. #[test] fn should_sign_at_the_levels_dashpay_documents_are_signed_at() { diff --git a/packages/rs-sdk/src/platform/private_document_scan.rs b/packages/rs-sdk/src/platform/private_document_scan.rs index 29be4814e32..42a338f34f2 100644 --- a/packages/rs-sdk/src/platform/private_document_scan.rs +++ b/packages/rs-sdk/src/platform/private_document_scan.rs @@ -284,12 +284,23 @@ impl PrivateDocumentScanKeys { &sender.outgoing_viewing_key, ) { Ok(Some(recovered)) => { + // Senders added with the same outgoing viewing key all recover the entry: + // it belongs to the one it names, when there is one. + let named = self + .senders + .iter() + .filter(|other| other.outgoing_viewing_key == sender.outgoing_viewing_key) + .find(|other| { + other.identity_id.as_bytes() == recovered.sender_identity_id() + && other.key_id == recovered.sender_key_id() + }) + .unwrap_or(sender); scan.sent.push(SentPrivateDocumentEntry { position: entry.position, block_height: entry.block_height, block_time_ms: entry.block_time_ms, - sender_identity_id: sender.identity_id, - outgoing_key_id: sender.key_id, + sender_identity_id: named.identity_id, + outgoing_key_id: named.key_id, entry: recovered, }); break; @@ -536,6 +547,30 @@ mod tests { .expect("the second key set opened it and checks it"); } + /// Two senders added with the same outgoing viewing key: an entry recovers as the sender + /// it names, not the first added. + #[test] + fn should_recover_an_entry_as_the_sender_it_names_when_two_share_a_key() { + let alice = Identifier::from([0xA0; 32]); + let carol = Identifier::from([0xC0; 32]); + let keys = PrivateDocumentScanKeys::new() + .with_sender(alice, 4, ovk(0xC1)) + .with_sender(carol, 4, ovk(0xC1)); + let scan = keys.scan_page( + &target(), + 0, + &page(0, vec![seal(0xC0, [0xD0; 32], 40, 0xC1, 1)], 1), + ); + assert_eq!(scan.rejected, vec![]); + assert_eq!( + scan.sent + .iter() + .map(|sent| (sent.sender_identity_id, sent.outgoing_key_id)) + .collect::>(), + vec![(carol, 4)] + ); + } + /// A page at the store's end is empty: the cursor stays where it is. #[test] fn should_keep_the_cursor_on_an_empty_page_at_the_store_s_end() { diff --git a/packages/rs-sdk/src/platform/transition/private_document_create_from_shielded_pool.rs b/packages/rs-sdk/src/platform/transition/private_document_create_from_shielded_pool.rs index 665a52f46ee..7e26d7bf5aa 100644 --- a/packages/rs-sdk/src/platform/transition/private_document_create_from_shielded_pool.rs +++ b/packages/rs-sdk/src/platform/transition/private_document_create_from_shielded_pool.rs @@ -33,9 +33,11 @@ pub trait PrivateDocumentCreateFromShieldedPool { bundle: OrchardBundleParams, ) -> Result; - /// Build, broadcast, and wait for proven execution. Returns the `VerifiedShieldedNullifiers` - /// proof result (the spent nullifiers), so a wallet marks its notes spent only once the - /// entries are proven appended. + /// Build, broadcast, and wait for the proof of the affected state. Returns the + /// `VerifiedShieldedNullifiers` proof result: it proves the bundle's nullifiers spent, which + /// is when a wallet marks its notes spent. It does not prove that this transition was the + /// spend, nor that its entries were appended: another transition spending the same notes + /// proves the same. A wallet that needs the entries reads the store. #[allow(clippy::too_many_arguments)] async fn private_document_create_from_shielded_pool( &self, From 3d510b8ad97d3de8d428116c51d12181ecdd1f47 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Sun, 8 Nov 2026 19:11:10 +0700 Subject: [PATCH 008/117] fix(sdk): export private_contact_request_signing_keys Co-Authored-By: Claude Opus 5.5 --- packages/rs-sdk/src/platform/dashpay/mod.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/rs-sdk/src/platform/dashpay/mod.rs b/packages/rs-sdk/src/platform/dashpay/mod.rs index 17aa6196751..456d9d6a6b8 100644 --- a/packages/rs-sdk/src/platform/dashpay/mod.rs +++ b/packages/rs-sdk/src/platform/dashpay/mod.rs @@ -25,10 +25,10 @@ pub use invitation::{ pub use private_contact_request::{ private_contact_request_identities, private_contact_request_outgoing_viewing_key, private_contact_request_signature, private_contact_request_signing_data, - private_contact_request_signing_security_levels, private_contact_requests_from_scan, - seal_private_contact_request, PrivateContactRequest, PrivateContactRequestRejection, - PrivateContactRequestScan, PrivateContactRequestSender, ReceivedPrivateContactRequest, - RejectedPrivateContactRequest, SentPrivateContactRequest, + private_contact_request_signing_keys, private_contact_request_signing_security_levels, + private_contact_requests_from_scan, seal_private_contact_request, PrivateContactRequest, + PrivateContactRequestRejection, PrivateContactRequestScan, PrivateContactRequestSender, + ReceivedPrivateContactRequest, RejectedPrivateContactRequest, SentPrivateContactRequest, PRIVATE_CONTACT_REQUEST_ACCOUNT_LABEL_MAX_BYTES, PRIVATE_CONTACT_REQUEST_AUTO_ACCEPT_PROOF_MAX_LEN, PRIVATE_CONTACT_REQUEST_AUTO_ACCEPT_PROOF_MIN_LEN, PRIVATE_CONTACT_REQUEST_DOCUMENT_TYPE_NAME, From f2483191a0380dd743a56e968c132a671ac02609 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Sun, 8 Nov 2026 19:13:18 +0700 Subject: [PATCH 009/117] fix(platform-wallet): sign private requests only with keys the SDK allows The private send picks the first signing key the signer holds among `private_contact_request_signing_keys`, which adds the contract bounds rule of #5809 to the level and type rules: a key bound to another contract or document type, or to a contract group, is never chosen, as the seal would refuse it. Co-Authored-By: Claude Opus 5.5 --- .../network/private_contact_requests.rs | 40 +++++++++---------- 1 file changed, 18 insertions(+), 22 deletions(-) diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/private_contact_requests.rs b/packages/rs-platform-wallet/src/wallet/identity/network/private_contact_requests.rs index 059ae38a48c..a443bf7838d 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/private_contact_requests.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/private_contact_requests.rs @@ -34,9 +34,9 @@ use dash_sdk::dapi_grpc::platform::v0::ResponseMetadata; use dash_sdk::platform::dashpay::{ private_contact_request_identities, private_contact_request_outgoing_viewing_key, private_contact_request_signature, private_contact_request_signing_data, - private_contact_request_signing_security_levels, private_contact_requests_from_scan, + private_contact_request_signing_keys, private_contact_requests_from_scan, recipient_key_purpose_is_valid, PrivateContactRequest, RejectedPrivateContactRequest, - PRIVATE_CONTACT_REQUEST_DOCUMENT_TYPE_NAME, PRIVATE_CONTACT_REQUEST_SIGNING_KEY_TYPES, + PRIVATE_CONTACT_REQUEST_DOCUMENT_TYPE_NAME, }; use dash_sdk::platform::private_document_entries::{ PrivateDocumentEntries, PrivateDocumentEntriesQuery, @@ -63,7 +63,7 @@ use super::contact_requests::{ newest_sent_per_recipient, tear_down_stale_external_accounts, AutoAcceptProofSource, ContactCryptoProvider, PreparedContactRequest, }; -use super::signing_key::AvailableSigningKey; +use super::signing_key::first_available; use super::{dashpay_contract, DashPayView, IdentityWallet}; use crate::broadcaster::TransactionBroadcaster; use crate::error::PlatformWalletError; @@ -273,26 +273,22 @@ impl DashPayView<'_, B> { shielded_address, }; - // The request is signed as a public request's transition is: by a - // signing key of the sender the signer holds, at a level the type's - // documents are signed at. + // The request is signed as a public request's transition is: by the + // first signing key of the sender the signer holds, of those the SDK + // lets sign it (level, type, contract bounds). let contract = dashpay_contract()?; - let signing_levels = private_contact_request_signing_security_levels(&contract)?; - let signing_key = sender_identity - .available_signing_key( - signer, - Purpose::AUTHENTICATION, - &signing_levels, - &PRIVATE_CONTACT_REQUEST_SIGNING_KEY_TYPES, - false, - )? - .cloned() - .ok_or_else(|| { - PlatformWalletError::InvalidIdentityData(format!( - "the sender identity has no enabled AUTHENTICATION key at {signing_levels:?} \ - to sign a private contact request with" - )) - })?; + let signing_key = first_available( + private_contact_request_signing_keys(&contract, &sender_identity)?.into_iter(), + signer, + )? + .cloned() + .ok_or_else(|| { + PlatformWalletError::InvalidIdentityData( + "the sender identity has no enabled AUTHENTICATION key at HIGH or CRITICAL, \ + bound to no other contract, to sign a private contact request with" + .to_string(), + ) + })?; let signing_data = private_contact_request_signing_data( &contract, &sender_identity, From cc2c7bc4dcccc57ec84351488233821058f25760 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Sun, 8 Nov 2026 20:12:14 +0700 Subject: [PATCH 010/117] feat(platform-wallet): accept a contact request replying the way it came `accept_contact_request_replying_in_kind` (on `DashPayView`, and on `PlatformWallet` with the `shielded` feature) is the one accept a host calls: a request that came privately, with no reply sent yet, is accepted with a private reply paid from the shielded pool; any other is accepted with a document, or by adopting the reply already sent. The choice was the hosts' (catch code 68, then call the private accept); it is the wallet's now. `PlatformWallet` derives the spend authority only for a private reply. The explicitly public and private accepts are unchanged. Co-Authored-By: Claude Opus 5.5 --- .../identity/network/contact_requests.rs | 41 ++--- .../network/private_contact_requests.rs | 155 +++++++++++++++++- .../src/wallet/platform_wallet.rs | 40 +++++ 3 files changed, 216 insertions(+), 20 deletions(-) diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs b/packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs index 438aa143488..a41ded7856e 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs @@ -3746,25 +3746,7 @@ impl DashPayView<'_, B> { S: Signer + Send + Sync, C: ContactCryptoProvider + Sync, { - // A reply already sent, either way, is adopted without a broadcast, so - // only a reply still to send would publish anything. - let needs_private_reply = { - let wm = self.wallet_manager.read().await; - wm.get_wallet_info(&self.wallet_id) - .and_then(|info| { - info.identity_manager - .managed_identity(&request.recipient_id) - }) - .is_some_and(|managed| { - let dashpay = managed.dashpay(); - (request.came_privately() - || dashpay - .incoming_request(&request.sender_id) - .is_some_and(ContactRequest::came_privately)) - && dashpay.outgoing_request(&request.sender_id).is_none() - }) - }; - if needs_private_reply { + if self.needs_private_reply(request).await { return Err(PlatformWalletError::PrivateContactRequestNeedsPrivateReply( request.sender_id, )); @@ -3784,6 +3766,27 @@ impl DashPayView<'_, B> { .await } + /// Whether accepting `request` takes a private reply: it, or the request + /// tracked from its sender, came privately, and we have sent the sender + /// no request yet. A reply already sent, either way, is adopted without a + /// broadcast, so only a reply still to send would publish anything. + pub(super) async fn needs_private_reply(&self, request: &ContactRequest) -> bool { + let wm = self.wallet_manager.read().await; + wm.get_wallet_info(&self.wallet_id) + .and_then(|info| { + info.identity_manager + .managed_identity(&request.recipient_id) + }) + .is_some_and(|managed| { + let dashpay = managed.dashpay(); + (request.came_privately() + || dashpay + .incoming_request(&request.sender_id) + .is_some_and(ContactRequest::came_privately)) + && dashpay.outgoing_request(&request.sender_id).is_none() + }) + } + /// Accept `request`, with `send_reply` sending our reciprocal request to /// its sender unless one already exists: the body of /// [`Self::accept_contact_request_with_external_signer`], shared with the diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/private_contact_requests.rs b/packages/rs-platform-wallet/src/wallet/identity/network/private_contact_requests.rs index a443bf7838d..d54a3374843 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/private_contact_requests.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/private_contact_requests.rs @@ -427,6 +427,36 @@ impl DashPayView<'_, B> { .await } + /// Accept `request` replying the way it came: privately, paid by `pay`, + /// when it came privately and we have sent its sender no request yet + /// ([`Self::accept_private_contact_request`]); otherwise with a + /// `contactRequest` document, or by adopting the reply already sent + /// ([`Self::accept_contact_request_with_external_signer`]). The one accept + /// a host calls: which reply a request takes is the wallet's decision. + /// `pay` runs only for a private reply. + pub async fn accept_contact_request_replying_in_kind( + &self, + request: &ContactRequest, + shielded_address: Option<[u8; 43]>, + signer: &S, + crypto: &C, + pay: F, + ) -> Result + where + S: Signer + Send + Sync, + C: ContactCryptoProvider + Sync, + F: FnOnce(BinaryData) -> Fut + Send, + Fut: Future> + Send, + { + if self.needs_private_reply(request).await { + self.accept_private_contact_request(request, shielded_address, signer, crypto, pay) + .await + } else { + self.accept_contact_request_with_external_signer(request, signer, crypto) + .await + } + } + /// The wallet's identities a pass reads the store for (those with an HD /// index, whose keys the signer can derive) and the paths of their keys: /// every `ECDSA_SECP256K1` decryption or encryption key, disabled ones @@ -1188,9 +1218,10 @@ mod tests { if sender == alice.id() )); + // The accept a host calls replies the way the request came. let reply = Arc::new(Mutex::new(Vec::new())); let established = bob_view - .accept_private_contact_request( + .accept_contact_request_replying_in_kind( &received, None, &bob_signer, @@ -1250,6 +1281,128 @@ mod tests { } } + /// A public request from `sender` to `recipient` for `account_reference`, + /// as a `contactRequest` document is recorded: with its core height. + fn public_request( + sender: &Identity, + recipient: &Identity, + account_reference: u32, + ) -> ContactRequest { + ContactRequest::new( + sender.id(), + recipient.id(), + ENCRYPTION_KEY_ID, + DECRYPTION_KEY_ID, + account_reference, + vec![7; 96], + 100, + BLOCK_TIME_MS, + ) + } + + /// The accept a host calls replies to a public request publicly, without + /// paying: by adopting the reply already sent, or with a document whose + /// broadcast error comes back as it is. A request the wallet does not + /// hold is refused either way, and nothing is paid. + #[tokio::test] + async fn should_accept_a_public_request_replying_in_kind() { + let alice = identity(ALICE_PHRASE, ALICE); + let bob = identity(BOB_PHRASE, BOB); + let bob_crypto = SeedCryptoProvider::from_seed(seed(BOB_PHRASE), Network::Testnet); + let bob_signer = signer(BOB_PHRASE, &bob); + let incoming = public_request(&alice, &bob, 3); + let add = |wallet: Arc, requests: Vec<(bool, ContactRequest)>| async move { + let iw = wallet.identity(); + let mut wm = iw.wallet_manager.write().await; + let managed = wm + .get_wallet_info_mut(&wallet.wallet_id()) + .expect("wallet info") + .identity_manager + .managed_identity_mut(&Identifier::from(BOB)) + .expect("Bob"); + for (incoming, request) in requests { + if incoming { + managed + .add_incoming_contact_request(request, &iw.persister) + .expect("incoming request"); + } else { + managed + .add_sent_contact_request(request, &iw.persister) + .expect("sent request"); + } + } + }; + + // Already reciprocated: adopted, no broadcast and no payment. + let (_manager, reciprocated) = wallet(BOB_PHRASE, &bob, &[&alice, &bob], vec![]).await; + add( + Arc::clone(&reciprocated), + vec![ + (true, incoming.clone()), + (false, public_request(&bob, &alice, 4)), + ], + ) + .await; + let paid = Arc::new(Mutex::new(Vec::new())); + let established = reciprocated + .identity() + .dashpay() + .accept_contact_request_replying_in_kind( + &incoming, + None, + &bob_signer, + &bob_crypto, + keep_entry(&paid), + ) + .await + .expect("the reply already sent is adopted"); + assert_eq!(established.contact_identity_id, alice.id()); + assert!(paid.lock().expect("paid").is_empty(), "nothing is paid"); + + // Not reciprocated: a public reply, whose broadcast this mock refuses. + let (_manager, fresh) = wallet(BOB_PHRASE, &bob, &[&alice, &bob], vec![]).await; + add(Arc::clone(&fresh), vec![(true, incoming.clone())]).await; + let paid = Arc::new(Mutex::new(Vec::new())); + let result = fresh + .identity() + .dashpay() + .accept_contact_request_replying_in_kind( + &incoming, + None, + &bob_signer, + &bob_crypto, + keep_entry(&paid), + ) + .await; + assert!( + matches!(&result, Err(error) + if !matches!(error, PlatformWalletError::PrivateContactRequestNeedsPrivateReply(_))), + "the public reply's error comes back: {result:?}" + ); + assert!(paid.lock().expect("paid").is_empty(), "nothing is paid"); + + // A private request from a sender the wallet holds no request from. + let carol = identity(ALICE_PHRASE, [0xC1; 32]); + let mut unknown = public_request(&carol, &bob, 5); + unknown.core_height_created_at = 0; + let paid = Arc::new(Mutex::new(Vec::new())); + assert!(matches!( + fresh + .identity() + .dashpay() + .accept_contact_request_replying_in_kind( + &unknown, + None, + &bob_signer, + &bob_crypto, + keep_entry(&paid), + ) + .await, + Err(PlatformWalletError::ContactRequestNotFound(sender)) if sender == carol.id() + )); + assert!(paid.lock().expect("paid").is_empty(), "nothing is paid"); + } + /// A wallet restored from seed recovers the private requests it sent from /// the store alone, recording each as it recorded it when sending. #[tokio::test] diff --git a/packages/rs-platform-wallet/src/wallet/platform_wallet.rs b/packages/rs-platform-wallet/src/wallet/platform_wallet.rs index 5d14a7c7f2c..f825d9a62e7 100644 --- a/packages/rs-platform-wallet/src/wallet/platform_wallet.rs +++ b/packages/rs-platform-wallet/src/wallet/platform_wallet.rs @@ -1622,6 +1622,46 @@ impl PlatformWallet { .await } + /// Accept a DashPay contact request replying the way it came: privately, + /// paid from `account`'s shielded notes, when it came privately and no + /// reply exists yet, else with a `contactRequest` document signed by + /// `signer`. The spend authority is derived from `seed` only when the + /// reply is private. See + /// [`DashPayView::accept_contact_request_replying_in_kind`](crate::DashPayView::accept_contact_request_replying_in_kind). + #[cfg(feature = "shielded")] + #[allow(clippy::too_many_arguments)] + pub async fn accept_contact_request_replying_in_kind( + &self, + coordinator: &Arc, + seed: &[u8], + account: u32, + request: &ContactRequest, + shielded_address: Option<[u8; 43]>, + signer: &S, + crypto: &C, + prover: P, + ) -> Result + where + S: Signer + Send + Sync, + C: ContactCryptoProvider + Sync, + P: OrchardProver + Send + Sync, + { + self.identity + .dashpay() + .accept_contact_request_replying_in_kind( + request, + shielded_address, + signer, + crypto, + |entry| async move { + let keyset = self.derive_spend_keyset(seed, account).await?; + self.pay_private_contact_request(coordinator, keyset, account, entry, prover) + .await + }, + ) + .await + } + /// Append the sealed private contact request `entry` to DashPay's store, /// paying from `account`'s notes, and return the metadata of the proof /// that it was appended. From d4efa2e5db79f84e3c369d973988d0c17c6eaf71 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Sun, 8 Nov 2026 21:52:13 +0700 Subject: [PATCH 011/117] fix(sdk): validate private requests with dpp and check proved identity heights Review fixes: - Requests are checked against their type's schema with dpp's document validation (`DataContract::validate_document_properties`), before encoding when sealing and after decoding when reading, as the payload codec asks. The SDK's own copies of the label and proof bounds are gone; `to_properties` is a typed conversion. The SDK enables dpp's `validation` feature. `seal_private_contact_request` and `private_contact_request_signing_data` take `platform_version` last. - The reader takes each identity with the height its proof is of (`ProvedIdentity`). `private_contact_request_identities` lists, per identity, the latest block whose entry it checks; a snapshot proved before it, which a node may serve within the SDK's height tolerance, or one not given, fails the read instead of judging the entry against keys as they were before it. Only signed entries need identities. - `Sdk::fetch_private_contact_request_identities` fetches them four at a time with their metadata and fetches a stale one once more. - Both SDK helpers establish protocol version 15 before resolving DashPay, so an unpinned SDK starting below it does not read a DashPay without the private type. - `PrivateDocumentScanKeys::derive` builds scan keys from key paths with a derivation callback, wiping what it derives, for signers that keep keys. Co-Authored-By: Claude Opus 5.5 --- packages/rs-sdk/Cargo.toml | 3 + packages/rs-sdk/src/platform/dashpay/mod.rs | 4 +- .../dashpay/private_contact_request.rs | 453 +++++++++++++++--- .../src/platform/private_document_scan.rs | 94 +++- 4 files changed, 475 insertions(+), 79 deletions(-) diff --git a/packages/rs-sdk/Cargo.toml b/packages/rs-sdk/Cargo.toml index 15641ca1472..444dc3c2f54 100644 --- a/packages/rs-sdk/Cargo.toml +++ b/packages/rs-sdk/Cargo.toml @@ -9,6 +9,9 @@ arc-swap = { version = "1.7.1" } chrono = { version = "0.4.38" } dpp = { path = "../rs-dpp", default-features = false, features = [ "dash-sdk-features", + # DashPay private contact requests are checked against their type's schema as a document's + # properties are (`DataContract::validate_document_properties`). + "validation", ] } dapi-grpc = { path = "../dapi-grpc", default-features = false } rs-dapi-client = { path = "../rs-dapi-client", default-features = false } diff --git a/packages/rs-sdk/src/platform/dashpay/mod.rs b/packages/rs-sdk/src/platform/dashpay/mod.rs index 456d9d6a6b8..4c663a40376 100644 --- a/packages/rs-sdk/src/platform/dashpay/mod.rs +++ b/packages/rs-sdk/src/platform/dashpay/mod.rs @@ -28,8 +28,8 @@ pub use private_contact_request::{ private_contact_request_signing_keys, private_contact_request_signing_security_levels, private_contact_requests_from_scan, seal_private_contact_request, PrivateContactRequest, PrivateContactRequestRejection, PrivateContactRequestScan, PrivateContactRequestSender, - ReceivedPrivateContactRequest, RejectedPrivateContactRequest, SentPrivateContactRequest, - PRIVATE_CONTACT_REQUEST_ACCOUNT_LABEL_MAX_BYTES, + ProvedIdentity, ReceivedPrivateContactRequest, RejectedPrivateContactRequest, + SentPrivateContactRequest, PRIVATE_CONTACT_REQUEST_ACCOUNT_LABEL_MAX_BYTES, PRIVATE_CONTACT_REQUEST_AUTO_ACCEPT_PROOF_MAX_LEN, PRIVATE_CONTACT_REQUEST_AUTO_ACCEPT_PROOF_MIN_LEN, PRIVATE_CONTACT_REQUEST_DOCUMENT_TYPE_NAME, PRIVATE_CONTACT_REQUEST_SHIELDED_ADDRESS_LEN, PRIVATE_CONTACT_REQUEST_SIGNING_KEY_TYPES, diff --git a/packages/rs-sdk/src/platform/dashpay/private_contact_request.rs b/packages/rs-sdk/src/platform/dashpay/private_contact_request.rs index 067ee7fb3d1..74108ad8ed2 100644 --- a/packages/rs-sdk/src/platform/dashpay/private_contact_request.rs +++ b/packages/rs-sdk/src/platform/dashpay/private_contact_request.rs @@ -31,9 +31,11 @@ use crate::platform::{Fetch, Identifier}; use crate::{Error, Sdk}; use dpp::data_contract::accessors::v0::DataContractV0Getters; use dpp::data_contract::document_type::accessors::{DocumentTypeV0Getters, DocumentTypeV2Getters}; +use dpp::data_contract::document_type::property_constraints::DocumentSystemValues; use dpp::data_contract::document_type::{ decode_private_document_payload, encode_private_document_payload, DocumentTypeRef, }; +use dpp::data_contract::validate_document::DataContractDocumentValidationMethodsV0; use dpp::data_contract::DataContract; use dpp::identity::accessors::IdentityGettersV0; use dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; @@ -45,7 +47,9 @@ use dpp::secp256k1::rand::rngs::StdRng; use dpp::secp256k1::rand::{RngCore, SeedableRng}; use dpp::secp256k1::{PublicKey, SecretKey}; use dpp::util::hash::ripemd160_sha256; +use dpp::version::feature_initial_protocol_versions::PRIVATE_DOCUMENT_CREATE_FROM_SHIELDED_POOL_INITIAL_PROTOCOL_VERSION; use dpp::version::PlatformVersion; +use futures::{StreamExt, TryStreamExt}; use platform_encryption::{ private_document_outgoing_viewing_key, private_document_signing_data, recover_private_document_signer, seal_private_document_entry, OpenedPrivateDocumentEntry, @@ -53,6 +57,7 @@ use platform_encryption::{ PrivateDocumentSender, PrivateDocumentTarget, COMPACT_XPUB_LEN, }; use std::collections::{BTreeMap, BTreeSet}; +use std::sync::Arc; use zeroize::Zeroize; /// The DashPay document type of private contact requests. @@ -91,11 +96,9 @@ pub struct PrivateContactRequest { } impl PrivateContactRequest { - /// The request as the type's properties, refused when a value is out of the schema's - /// bounds. dpp's schema validation is not part of the SDK's features, so the bounds the - /// fields' types do not hold are checked here. - pub fn to_properties(&self) -> Result, Error> { - self.check_bounds()?; + /// The request as the type's properties. Their bounds are the schema's, which sealing + /// checks with dpp's document validation. + pub fn to_properties(&self) -> BTreeMap { let mut properties = BTreeMap::from([ ( ACCOUNT_REFERENCE.to_string(), @@ -115,11 +118,12 @@ impl PrivateContactRequest { if let Some(address) = &self.shielded_address { properties.insert(SHIELDED_ADDRESS.to_string(), Value::Bytes(address.to_vec())); } - Ok(properties) + properties } /// The request a private contact request's properties hold, refused when one is missing, - /// unknown, of the wrong type or out of the schema's bounds. + /// unknown, of the wrong type or of the wrong size for its field. The schema's other bounds + /// are checked with dpp's document validation, which reading does before this. pub fn from_properties(mut properties: BTreeMap) -> Result { let invalid = |message: String| Error::Generic(format!("private contact request: {message}")); @@ -182,34 +186,30 @@ impl PrivateContactRequest { }) .transpose()?, }; - request.check_bounds()?; Ok(request) } +} - fn check_bounds(&self) -> Result<(), Error> { - if let Some(label) = &self.account_label { - if label.len() > PRIVATE_CONTACT_REQUEST_ACCOUNT_LABEL_MAX_BYTES { - return Err(Error::Generic(format!( - "private contact request: {ACCOUNT_LABEL} is {} bytes, more than \ - {PRIVATE_CONTACT_REQUEST_ACCOUNT_LABEL_MAX_BYTES}", - label.len() - ))); - } - } - if let Some(proof) = &self.auto_accept_proof { - if !(PRIVATE_CONTACT_REQUEST_AUTO_ACCEPT_PROOF_MIN_LEN - ..=PRIVATE_CONTACT_REQUEST_AUTO_ACCEPT_PROOF_MAX_LEN) - .contains(&proof.len()) - { - return Err(Error::Generic(format!( - "private contact request: {AUTO_ACCEPT_PROOF} is {} bytes, not \ - {PRIVATE_CONTACT_REQUEST_AUTO_ACCEPT_PROOF_MIN_LEN} to \ - {PRIVATE_CONTACT_REQUEST_AUTO_ACCEPT_PROOF_MAX_LEN}", - proof.len() - ))); - } - } - Ok(()) +/// Checks `properties` against the `privateContactRequest` schema of `dashpay_contract` as dpp +/// checks a document's properties: the schema's bounds and types, its required properties, and +/// the rest it says. The payload codec leaves this to the sender before encoding and to the +/// recipient after decoding. +fn validate_request_properties( + dashpay_contract: &DataContract, + properties: &BTreeMap, + platform_version: &PlatformVersion, +) -> Result<(), String> { + let result = dashpay_contract + .validate_document_properties( + PRIVATE_CONTACT_REQUEST_DOCUMENT_TYPE_NAME, + Value::from(properties.clone()), + &DocumentSystemValues::default(), + platform_version, + ) + .map_err(|error| error.to_string())?; + match result.errors.first() { + None => Ok(()), + Some(error) => Err(error.to_string()), } } @@ -259,6 +259,7 @@ pub fn private_contact_request_signing_data( recipient: &Identity, recipient_key_id: KeyID, request: &PrivateContactRequest, + platform_version: &PlatformVersion, ) -> Result<[u8; 32], Error> { Ok(SealInputs::new( dashpay_contract, @@ -267,6 +268,7 @@ pub fn private_contact_request_signing_data( recipient, recipient_key_id, request, + platform_version, )? .signing_data) } @@ -322,6 +324,8 @@ pub struct PrivateContactRequestSender<'a> { /// that may sign the type's documents. /// - The recipient key must be an enabled `ECDSA_SECP256K1` `DECRYPTION` or `ENCRYPTION` key. /// +/// - The request must satisfy the type's schema (dpp's document validation). +/// /// `esk_seed` must be 32 fresh random bytes for every entry, never reused, not even on a retry /// ([`Sdk::seal_private_contact_request`] draws one). pub fn seal_private_contact_request( @@ -331,6 +335,7 @@ pub fn seal_private_contact_request( recipient_key_id: KeyID, request: &PrivateContactRequest, esk_seed: &[u8; 32], + platform_version: &PlatformVersion, ) -> Result { let inputs = SealInputs::new( dashpay_contract, @@ -339,6 +344,7 @@ pub fn seal_private_contact_request( recipient, recipient_key_id, request, + platform_version, )?; if inputs.encryption_public_key != PublicKey::from_secret_key(sender.encryption_secret_key) { return Err(Error::Generic(format!( @@ -413,6 +419,7 @@ impl SealInputs { recipient: &Identity, recipient_key_id: KeyID, request: &PrivateContactRequest, + platform_version: &PlatformVersion, ) -> Result { let document_type = private_contact_request_type(dashpay_contract)?; let entry_size = document_type @@ -444,7 +451,10 @@ impl SealInputs { let recipient_public_key = ecdh_public_key(recipient_key, "recipient", None).map_err(Error::Generic)?; - let payload = encode_private_document_payload(document_type, request.to_properties()?)?; + let properties = request.to_properties(); + validate_request_properties(dashpay_contract, &properties, platform_version) + .map_err(|error| Error::Generic(format!("private contact request: {error}")))?; + let payload = encode_private_document_payload(document_type, properties)?; let contract_id = dashpay_contract.id().to_buffer(); let signing_data = private_document_signing_data( &PrivateDocumentTarget { @@ -746,41 +756,73 @@ impl PrivateContactRequestScan { } } +/// An identity a scan's entries are checked against, as fetched: `None` when it does not exist, +/// and the height of the block its proof is of. An entry is checked against a snapshot proved +/// at or after the block that appended it, so a key disabled in that block shows as disabled. +#[derive(Clone, Debug, PartialEq)] +pub struct ProvedIdentity { + /// The identity, `None` when the proof shows it does not exist. + pub identity: Option, + /// The height of the block the proof is of. + pub proved_at_height: u64, +} + +/// How many identities [`Sdk::fetch_private_contact_request_identities`] fetches at once. +const PRIVATE_CONTACT_REQUEST_IDENTITY_FETCHES: usize = 4; + /// The identities whose keys [`private_contact_requests_from_scan`] checks the entries of -/// `scan` against: the senders received entries name, the wallet's identities they are to, and -/// the wallet's identities that sent the recovered ones. +/// `scan` against, each with the height of the latest block whose entry it checks: a snapshot +/// of it must be proved at or after that block. They are the senders signed received entries +/// name, the wallet's identities those are to, and the wallet's identities that sent the +/// signed recovered ones. Anonymous entries and entries with a deniable MAC are refused without +/// reading an identity, so they add none. /// /// Fetching a sender right after the page that holds its request tells the node that serves /// both which identity the wallet looked up, and so that someone it knows sent the wallet a /// request. A wallet that knows a sender already (a contact, or one fetched for other reasons) /// passes the identity it holds, and fetches the others from another node or later, as its /// privacy needs. -pub fn private_contact_request_identities(scan: &PrivateDocumentScan) -> BTreeSet { - scan.received +pub fn private_contact_request_identities(scan: &PrivateDocumentScan) -> BTreeMap { + let signed = |entry: &OpenedPrivateDocumentEntry| { + entry.auth_mode() == PrivateDocumentAuthMode::Signature + }; + let mut needed: BTreeMap = BTreeMap::new(); + let mut need = |identity_id: Identifier, block_height: u64| { + if identity_id != Identifier::default() { + let height = needed.entry(identity_id).or_default(); + *height = (*height).max(block_height); + } + }; + for received in scan + .received .iter() - .flat_map(|received| { - [ - Identifier::from(*received.entry.sender_identity_id()), - received.recipient_identity_id, - ] - }) - .chain(scan.sent.iter().map(|sent| sent.sender_identity_id)) - .filter(|identity_id| *identity_id != Identifier::default()) - .collect() + .filter(|received| signed(&received.entry)) + { + need( + Identifier::from(*received.entry.sender_identity_id()), + received.block_height, + ); + need(received.recipient_identity_id, received.block_height); + } + for sent in scan.sent.iter().filter(|sent| signed(&sent.entry)) { + need(sent.sender_identity_id, sent.block_height); + } + needed } /// Reads the private contact requests of a scan of DashPay's `privateContactRequest` store. /// -/// `identities` holds the identities [`private_contact_request_identities`] lists, as fetched -/// (`None` for one that does not exist). Each entry, received or sent, is checked before its -/// payload is read: +/// `identities` holds the identities [`private_contact_request_identities`] lists, each proved +/// at or after the height it lists: a missing one, or one proved earlier, fails the read, to be +/// fetched again. Each entry, received or sent, is checked before its payload is read: /// - it must be signed, and its sender identity must exist; /// - the encryption key it names must be the sender's `ECDSA_SECP256K1` `ENCRYPTION` key, /// enabled when the entry was appended; /// - its signature must be by a key of the sender that may sign the type's documents, enabled /// then; /// - a received entry's recipient key must have been enabled then too; -/// - a sent entry must name the identity and key its outgoing viewing key is derived from. +/// - a sent entry must name the identity and key its outgoing viewing key is derived from; +/// - its payload must satisfy the type's schema (dpp's document validation). /// /// The first request from a sender to a recipient for an account reference is kept. A wallet /// keeps the requests it already holds from earlier scans, and drops a later one from the same @@ -788,9 +830,27 @@ pub fn private_contact_request_identities(scan: &PrivateDocumentScan) -> BTreeSe pub fn private_contact_requests_from_scan( dashpay_contract: &DataContract, scan: PrivateDocumentScan, - identities: &BTreeMap>, + identities: &BTreeMap, platform_version: &PlatformVersion, ) -> Result { + for (identity_id, height) in private_contact_request_identities(&scan) { + match identities.get(&identity_id) { + None => { + return Err(Error::Generic(format!( + "the identity {identity_id}, which an entry of the scan is checked against, \ + was not given" + ))) + } + Some(proved) if proved.proved_at_height < height => { + return Err(Error::Generic(format!( + "the identity {identity_id} was proved at block {}, before the block {height} \ + of an entry it checks: fetch it again", + proved.proved_at_height + ))) + } + Some(_) => {} + } + } let document_type = private_contact_request_type(dashpay_contract)?; let signing_levels = private_contact_request_signing_security_levels(dashpay_contract)?; let reader = EntryReader { @@ -802,6 +862,8 @@ pub fn private_contact_requests_from_scan( decode_private_document_payload(document_type, payload, platform_version).map_err( |error| PrivateContactRequestRejection::InvalidRequest(error.to_string()), )?; + validate_request_properties(dashpay_contract, &properties, platform_version) + .map_err(PrivateContactRequestRejection::InvalidRequest)?; PrivateContactRequest::from_properties(properties) .map_err(|error| PrivateContactRequestRejection::InvalidRequest(error.to_string())) }, @@ -900,7 +962,7 @@ type DecodeRequest<'a> = /// Reads the entries of one scan against the identities fetched for it. struct EntryReader<'a> { - identities: &'a BTreeMap>, + identities: &'a BTreeMap, signing_levels: &'a [SecurityLevel], dashpay_id: Identifier, decode: &'a DecodeRequest<'a>, @@ -908,7 +970,9 @@ struct EntryReader<'a> { impl EntryReader<'_> { fn identity(&self, identity_id: &Identifier) -> Option<&Identity> { - self.identities.get(identity_id).and_then(Option::as_ref) + self.identities + .get(identity_id) + .and_then(|proved| proved.identity.as_ref()) } /// The request a received entry holds, once its sender and recipient key are checked. @@ -918,7 +982,7 @@ impl EntryReader<'_> { ) -> Result { self.check_sender(&received.entry, received.block_time_ms)?; let recipient_key_problem = match self.identity(&received.recipient_identity_id) { - None => Some("the recipient identity was not given".to_string()), + None => Some("the recipient identity does not exist".to_string()), Some(recipient) => match recipient.get_public_key_by_id(received.recipient_key_id) { None => Some(format!( "the recipient has no key {}", @@ -1023,7 +1087,7 @@ impl Sdk { where S: Signer + ?Sized, { - let dashpay_contract = self.fetch_dashpay_contract().await?; + let dashpay_contract = self.fetch_private_contact_request_contract().await?; let signing_key = private_contact_request_signing_keys(&dashpay_contract, sender)? .into_iter() .find(|key| signer.can_sign_with(key)) @@ -1041,6 +1105,7 @@ impl Sdk { recipient, recipient_key_id, request, + self.version(), )?; let signature = private_contact_request_signature(signer, signing_key, &signing_data).await?; @@ -1061,15 +1126,67 @@ impl Sdk { recipient_key_id, request, &esk_seed, + self.version(), ); esk_seed.zeroize(); sealed } + /// The DashPay contract, with its `privateContactRequest` type: the network's protocol + /// version is established as 15 or later first, as an unpinned SDK starts below it and would + /// otherwise resolve a DashPay without the type. + async fn fetch_private_contact_request_contract(&self) -> Result, Error> { + self.require_protocol_version( + PRIVATE_DOCUMENT_CREATE_FROM_SHIELDED_POOL_INITIAL_PROTOCOL_VERSION, + "DashPay private contact requests", + ) + .await?; + self.fetch_dashpay_contract().await + } + + /// Fetches the identities `needed` lists ([`private_contact_request_identities`]), a few at + /// a time, each proved at or after the height listed: one proved earlier, which a node may + /// serve within the SDK's height tolerance, is fetched once more, and fails the fetch if it + /// still is. + pub async fn fetch_private_contact_request_identities( + &self, + needed: &BTreeMap, + ) -> Result, Error> { + futures::stream::iter(needed.iter()) + .map(|(identity_id, height)| async move { + let mut attempt = 0; + loop { + let (identity, metadata) = + Identity::fetch_with_metadata(self, *identity_id, None).await?; + if metadata.height >= *height { + return Ok(( + *identity_id, + ProvedIdentity { + identity, + proved_at_height: metadata.height, + }, + )); + } + attempt += 1; + if attempt == 2 { + return Err(Error::Generic(format!( + "the identity {identity_id} was proved at block {}, before the \ + block {height} of an entry it checks", + metadata.height + ))); + } + } + }) + .buffer_unordered(PRIVATE_CONTACT_REQUEST_IDENTITY_FETCHES) + .try_collect() + .await + } + /// Reads the page of DashPay's `privateContactRequest` store from `start_position` and /// returns the requests it holds for the wallet whose `keys` are given: those to its /// identities and those its identities sent, each checked against the identities' keys as - /// [`private_contact_requests_from_scan`] says. The identities are fetched once each. + /// [`private_contact_requests_from_scan`] says. The identities are fetched once each, a few + /// at a time, each proved at or after the entries it checks. /// /// A wallet keeps [`PrivateContactRequestScan::next_position`] as its cursor once it has /// stored what the page held, and scans again from it until @@ -1085,7 +1202,7 @@ impl Sdk { start_position: u64, keys: &PrivateDocumentScanKeys, ) -> Result { - let dashpay_contract = self.fetch_dashpay_contract().await?; + let dashpay_contract = self.fetch_private_contact_request_contract().await?; let scan = self .scan_private_document_page( dashpay_contract.id(), @@ -1094,10 +1211,9 @@ impl Sdk { keys, ) .await?; - let mut identities = BTreeMap::new(); - for identity_id in private_contact_request_identities(&scan) { - identities.insert(identity_id, Identity::fetch(self, identity_id).await?); - } + let identities = self + .fetch_private_contact_request_identities(&private_contact_request_identities(&scan)) + .await?; private_contact_requests_from_scan(&dashpay_contract, scan, &identities, self.version()) } } @@ -1119,6 +1235,8 @@ mod tests { const RECIPIENT_ID: [u8; 32] = [0xB0; 32]; /// The time of the block the test entries are appended in. const BLOCK_TIME_MS: u64 = 1_700_000_000_000; + /// The height the test identities are proved at, after every test entry's block. + const PROVED_AT_HEIGHT: u64 = 1_000; fn dashpay() -> DataContract { load_system_data_contract(SystemDataContract::Dashpay, PlatformVersion::latest()) @@ -1270,6 +1388,7 @@ mod tests { &recipient_identity(), 2, request, + PlatformVersion::latest(), ) .expect("the keys check"), signing_secret, @@ -1297,6 +1416,7 @@ mod tests { 2, request, &[seed; 32], + PlatformVersion::latest(), ) .expect("the request seals") .to_vec() @@ -1332,7 +1452,7 @@ mod tests { fn payload(contract: &DataContract, request: &PrivateContactRequest) -> Vec { encode_private_document_payload( private_contact_request_type(contract).expect("DashPay v3"), - request.to_properties().expect("in bounds"), + request.to_properties(), ) .expect("the payload encodes") } @@ -1382,13 +1502,19 @@ mod tests { }; let scan = keys.scan_page(&target, 0, &page(entries)); let fetched = private_contact_request_identities(&scan) - .into_iter() + .into_keys() .map(|identity_id| { let identity = identities .iter() .find(|identity| identity.id() == identity_id) .cloned(); - (identity_id, identity) + ( + identity_id, + ProvedIdentity { + identity, + proved_at_height: PROVED_AT_HEIGHT, + }, + ) }) .collect(); private_contact_requests_from_scan(contract, scan, &fetched, PlatformVersion::latest()) @@ -1459,11 +1585,8 @@ mod tests { let contract = dashpay(); let document_type = private_contact_request_type(&contract).expect("DashPay v3"); for request in [largest_request(), smallest_request(7)] { - let payload = encode_private_document_payload( - document_type, - request.to_properties().expect("in bounds"), - ) - .expect("the payload encodes"); + let payload = encode_private_document_payload(document_type, request.to_properties()) + .expect("the payload encodes"); let properties = decode_private_document_payload(document_type, &payload, PlatformVersion::latest()) .expect("the payload decodes"); @@ -1500,11 +1623,65 @@ mod tests { ..smallest_request(1) }, ]; + // dpp's document validation refuses them, when sealing and when reading. + let contract = dashpay(); + let sender = sender_identity(None); for request in &out_of_bounds { - assert!(request.to_properties().is_err(), "{request:?}"); + assert!( + private_contact_request_signing_data( + &contract, + &sender, + 1, + &recipient_identity(), + 2, + request, + PlatformVersion::latest(), + ) + .is_err(), + "{request:?} is refused when sealing" + ); + let payload = payload(&contract, request); + let signing_data = private_document_signing_data( + &PrivateDocumentTarget { + contract_id: contract.id().to_buffer(), + document_type_name: PRIVATE_CONTACT_REQUEST_DOCUMENT_TYPE_NAME, + }, + &SENDER_ID, + 1, + &PrivateDocumentRecipient { + identity_id: RECIPIENT_ID, + public_key: &PublicKey::from_secret_key(&secret_key(2)), + }, + &payload, + ); + let signature = sign_private_document_signing_data(&signing_data, &secret_key(9)); + let entry = seal_raw( + &contract, + RECIPIENT_ID, + &secret_key(2), + &PrivateDocumentSender::Signature { + identity_id: SENDER_ID, + key_id: 1, + signature: &signature, + }, + &payload, + ); + assert!( + matches!( + reasons(read( + &contract, + vec![entry], + &recipient_keys(), + &[sender.clone(), recipient_identity()], + )) + .as_slice(), + [PrivateContactRequestRejection::InvalidRequest(_)] + ), + "{request:?} is refused when reading" + ); } - let properties = || smallest_request(1).to_properties().expect("in bounds"); + let properties = || smallest_request(1).to_properties(); let with = |name: &str, value: Value| { let mut properties = properties(); properties.insert(name.to_string(), value); @@ -1535,10 +1712,6 @@ mod tests { "a shielded address of 44 bytes", with(SHIELDED_ADDRESS, Value::Bytes(vec![0; 44])), ), - ( - "a label too long", - with(ACCOUNT_LABEL, Value::Text("l".repeat(64))), - ), ( "a label of the wrong type", with(ACCOUNT_LABEL, Value::U32(1)), @@ -1991,6 +2164,7 @@ mod tests { &recipient_identity(), recipient_key_id, &request, + PlatformVersion::latest(), )?; seal_private_contact_request( &contract, @@ -2008,6 +2182,7 @@ mod tests { recipient_key_id, &request, &[1; 32], + PlatformVersion::latest(), ) }; let sender = sender_identity(None); @@ -2170,6 +2345,7 @@ mod tests { 2, &request, &[1; 32], + PlatformVersion::latest(), ); let signing_keys = private_contact_request_signing_keys(&contract, &sender) .expect("DashPay v3") @@ -2232,6 +2408,131 @@ mod tests { ); } + /// The identities a scan needs are those its signed entries name, each with the latest + /// block it checks; a reading against one proved before that block, or without one, fails + /// rather than judging the entry. + #[test] + fn should_read_only_against_identities_proved_after_the_entries() { + let contract = dashpay(); + let sender = sender_identity(None); + let request = smallest_request(1); + let mac = seal_raw( + &contract, + RECIPIENT_ID, + &secret_key(2), + &PrivateDocumentSender::DeniableMac { + identity_id: [0xC0; 32], + key_id: 1, + secret_key: &secret_key(1), + }, + &payload(&contract, &request), + ); + let target = PrivateDocumentTarget { + contract_id: contract.id().to_buffer(), + document_type_name: PRIVATE_CONTACT_REQUEST_DOCUMENT_TYPE_NAME, + }; + let scan = scan_keys().scan_page( + &target, + 0, + &page(vec![seal(&contract, &sender, &request, 1), mac]), + ); + // Entry 0 is in block 10; the MAC entry, naming 0xC0, needs no identity. + assert_eq!( + private_contact_request_identities(&scan), + BTreeMap::from([ + (Identifier::from(SENDER_ID), 10), + (Identifier::from(RECIPIENT_ID), 10), + ]) + ); + let proved = |height| { + BTreeMap::from([ + ( + Identifier::from(SENDER_ID), + ProvedIdentity { + identity: Some(sender.clone()), + proved_at_height: height, + }, + ), + ( + Identifier::from(RECIPIENT_ID), + ProvedIdentity { + identity: Some(recipient_identity()), + proved_at_height: 10, + }, + ), + ]) + }; + assert!(private_contact_requests_from_scan( + &contract, + scan.clone(), + &proved(10), + PlatformVersion::latest() + ) + .is_ok()); + assert!( + private_contact_requests_from_scan( + &contract, + scan.clone(), + &proved(9), + PlatformVersion::latest() + ) + .is_err(), + "a sender proved a block before its entry" + ); + let mut without_recipient = proved(10); + without_recipient.remove(&Identifier::from(RECIPIENT_ID)); + assert!( + private_contact_requests_from_scan( + &contract, + scan, + &without_recipient, + PlatformVersion::latest() + ) + .is_err(), + "an identity not given" + ); + } + + /// Below protocol version 15 the SDK refuses private contact requests before resolving + /// DashPay, whose older versions have no private type. + #[cfg(feature = "mocks")] + #[tokio::test] + async fn should_refuse_private_contact_requests_below_protocol_version_15() { + let sdk: Sdk = crate::SdkBuilder::new_mock() + .with_version(PlatformVersion::get(14).expect("protocol version 14")) + .build() + .expect("a mock sdk"); + let error = sdk + .scan_private_contact_requests(0, &PrivateDocumentScanKeys::new()) + .await + .expect_err("protocol version 14"); + assert!( + error + .to_string() + .contains("exists from protocol version 15"), + "{error}" + ); + let signer = TransitionSigner(BTreeMap::new()); + let error = sdk + .seal_private_contact_request( + &sender_identity(None), + 1, + &secret_key(1), + &signer, + &recipient_identity(), + 2, + &smallest_request(1), + ) + .await + .expect_err("protocol version 14"); + assert!( + error + .to_string() + .contains("exists from protocol version 15"), + "{error}" + ); + } + /// DashPay's documents are signed at HIGH or CRITICAL. #[test] fn should_sign_at_the_levels_dashpay_documents_are_signed_at() { @@ -2291,6 +2592,7 @@ mod tests { &recipient_identity(), 2, &request, + PlatformVersion::latest(), ) .expect("the keys check"); let signature = private_contact_request_signature(&signer, &signing_key, &signing_data) @@ -2313,6 +2615,7 @@ mod tests { 2, &request, &[3; 32], + PlatformVersion::latest(), ) .expect("the request seals"); let scan = read( diff --git a/packages/rs-sdk/src/platform/private_document_scan.rs b/packages/rs-sdk/src/platform/private_document_scan.rs index 42a338f34f2..8e61dcac639 100644 --- a/packages/rs-sdk/src/platform/private_document_scan.rs +++ b/packages/rs-sdk/src/platform/private_document_scan.rs @@ -20,8 +20,9 @@ use crate::{Error, Sdk}; use dpp::identity::KeyID; use dpp::secp256k1::{PublicKey, SecretKey}; use platform_encryption::{ - recover_sent_private_document_entry, OpenedPrivateDocumentEntry, PrivateDocumentEnvelopeError, - PrivateDocumentRecipientKeys, PrivateDocumentTarget, + private_document_outgoing_viewing_key, recover_sent_private_document_entry, + OpenedPrivateDocumentEntry, PrivateDocumentEnvelopeError, PrivateDocumentRecipientKeys, + PrivateDocumentTarget, }; use zeroize::Zeroize; @@ -167,6 +168,53 @@ impl PrivateDocumentScanKeys { self } + /// The scan keys a wallet's key paths derive to, with `derive` deriving the secret key at a + /// path: each of `recipients` with the keys entries to it may be sealed to, and each of + /// `senders` with the key its outgoing viewing key is derived from + /// (`platform_encryption::private_document_outgoing_viewing_key`). The secret keys derived + /// are wiped once used, on an error too. For a signer that keeps the keys: it builds the scan + /// keys and scans inside itself. + #[allow(clippy::type_complexity)] + pub fn derive( + recipients: &[(Identifier, Vec<(KeyID, P)>)], + senders: &[(Identifier, KeyID, P)], + mut derive: impl FnMut(&P) -> Result, + ) -> Result { + let mut keys = Self::new(); + for (identity_id, paths) in recipients { + let mut secret_keys = Vec::with_capacity(paths.len()); + let mut derived = Ok(()); + for (key_id, path) in paths { + match derive(path) { + Ok(secret_key) => secret_keys.push((*key_id, secret_key)), + Err(error) => { + derived = Err(error); + break; + } + } + } + if derived.is_ok() { + keys = keys.with_recipient(*identity_id, &secret_keys); + } + for (_, secret_key) in &mut secret_keys { + secret_key.non_secure_erase(); + } + derived?; + } + for (identity_id, key_id, path) in senders { + let mut secret_key = derive(path)?; + let mut secret = secret_key.to_secret_bytes(); + secret_key.non_secure_erase(); + keys = keys.with_sender( + *identity_id, + *key_id, + private_document_outgoing_viewing_key(&secret), + ); + secret.zeroize(); + } + Ok(keys) + } + /// Sorts the entries of `page`, read from `start_position` of the store of `target`. pub fn scan_page( &self, @@ -571,6 +619,48 @@ mod tests { ); } + /// Keys derived from paths scan as the same keys added directly, and a derivation that + /// fails fails the whole set. + #[test] + fn should_derive_scan_keys_from_paths() { + let alice = Identifier::from([0xA0; 32]); + let derived = PrivateDocumentScanKeys::derive( + &[(alice, vec![(2u32, 20u8)])], + &[(alice, 6, 0xA1)], + |byte| Ok::<_, ()>(secret_key(*byte)), + ) + .expect("every path derives"); + let added = PrivateDocumentScanKeys::new() + .with_recipient(alice, &[(2, secret_key(20))]) + .with_sender( + alice, + 6, + private_document_outgoing_viewing_key(&secret_key(0xA1).to_secret_bytes()), + ); + let entries = page( + 0, + vec![ + seal(0xC0, alice.to_buffer(), 20, 0xC1, 1), + seal(0xA0, [0xD0; 32], 40, 0xA1, 2), + ], + 2, + ); + assert_eq!( + derived.scan_page(&target(), 0, &entries), + added.scan_page(&target(), 0, &entries) + ); + assert!(PrivateDocumentScanKeys::derive( + &[(alice, vec![(2u32, 20u8), (3, 0)])], + &[], + |byte| if *byte == 0 { + Err(()) + } else { + Ok(secret_key(*byte)) + }, + ) + .is_err()); + } + /// A page at the store's end is empty: the cursor stays where it is. #[test] fn should_keep_the_cursor_on_an_empty_page_at_the_store_s_end() { From 7bd302aa42225c29f092309356f8e04424963a60 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Sun, 8 Nov 2026 22:05:13 +0700 Subject: [PATCH 012/117] fix(platform-wallet): keep paid private replies and pay a reply lazily Review fixes (with the SDK changes of #5809 merged in): - A private request whose store fails after it was paid for is kept in memory (`add_paid_sent_contact_request`): the contact it answers establishes, so a retried accept adopts it instead of paying for another reply, and the next add of the same request (the private sync recovers it from the store) stores it. Before, the failed store left no request and no contact, so the accept failed and a retry paid again. - `PlatformWallet::accept_contact_request_replying_in_kind` takes what a private reply is paid with as `payment`, a closure called only for a private reply (`PrivateReplyPayment`: coordinator, seed, account, prover). A public reply or an adopted one needs neither shielded support nor the seed. - The private sync checks each page against identities proved at or after its entries (`ProvedIdentity`), fetching a few at a time only those it lacks or holds from an older proof. - Scan keys are built with the SDK's `PrivateDocumentScanKeys::derive`; the crypto provider's seal takes `platform_version`, which the SDK's schema validation needs. Co-Authored-By: Claude Opus 5.5 --- packages/rs-platform-wallet/src/lib.rs | 2 +- .../identity/network/contact_requests.rs | 45 +++++++++ .../network/private_contact_requests.rs | 84 ++++++++-------- .../managed_identity/contact_requests.rs | 98 +++++++++++++++++-- .../state/managed_identity/dashpay.rs | 6 ++ packages/rs-platform-wallet/src/wallet/mod.rs | 4 +- .../src/wallet/platform_wallet.rs | 46 ++++++--- 7 files changed, 222 insertions(+), 63 deletions(-) diff --git a/packages/rs-platform-wallet/src/lib.rs b/packages/rs-platform-wallet/src/lib.rs index 82b91876319..0cd7448d1f1 100644 --- a/packages/rs-platform-wallet/src/lib.rs +++ b/packages/rs-platform-wallet/src/lib.rs @@ -97,7 +97,7 @@ pub use wallet::mnemonic::{ }; pub use wallet::platform_wallet::PlatformWalletInfo; #[cfg(feature = "shielded")] -pub use wallet::platform_wallet::ShieldedShieldPreflight; +pub use wallet::platform_wallet::{PrivateReplyPayment, ShieldedShieldPreflight}; pub use wallet::provider_key_at_index::{ProviderDerivedKey, ProviderKeyKind}; #[cfg(feature = "shielded")] pub use wallet::shielded::operations::shield_fee_reserve_credits; diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs b/packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs index a41ded7856e..b6b77912968 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs @@ -192,6 +192,7 @@ pub trait ContactCryptoProvider { _recipient_key_id: KeyID, _request: &PrivateContactRequest, _esk_seed: &[u8; 32], + _platform_version: &dpp::version::PlatformVersion, ) -> Result { Err(private_documents_unsupported()) } @@ -401,6 +402,7 @@ impl ContactCryptoProvider for SeedCryptoProvider { recipient_key_id: KeyID, request: &PrivateContactRequest, esk_seed: &[u8; 32], + platform_version: &dpp::version::PlatformVersion, ) -> Result { let mut encryption_secret_key = self.secret_key_at(sender_encryption_key_path)?; let sealed = dash_sdk::platform::dashpay::seal_private_contact_request( @@ -416,6 +418,7 @@ impl ContactCryptoProvider for SeedCryptoProvider { recipient_key_id, request, esk_seed, + platform_version, ); encryption_secret_key.non_secure_erase(); Ok(sealed?) @@ -4795,6 +4798,48 @@ mod sweep_tests { ); } + /// A paid sent request whose store fails is held in memory: the contact + /// it answers establishes, so a retry adopts it instead of paying again, + /// and the next add of the same request stores it, once. + #[test] + fn a_paid_sent_request_whose_store_fails_is_kept_and_stored_by_its_next_add() { + let our = 1u8; + let contact = 2u8; + let our_id = Identifier::from([our; 32]); + let contact_id = Identifier::from([contact; 32]); + let mut info = info_with_bare_identity(our); + let managed = info + .identity_manager + .managed_identity_mut(&our_id) + .expect("managed identity"); + managed + .add_incoming_contact_request(test_request(contact, our, 0), &noop_persister()) + .expect("incoming request"); + + managed.add_paid_sent_contact_request(test_request(our, contact, 5), &failing_persister()); + assert!( + managed + .dashpay() + .established_contacts() + .contains_key(&contact_id), + "the reply establishes the contact in memory" + ); + + let (persister, backend) = counting_persister(); + managed + .add_sent_contact_request(test_request(our, contact, 5), &persister) + .expect("the next add"); + assert_eq!(store_count(&backend), 1, "the next add stores it"); + managed + .add_sent_contact_request(test_request(our, contact, 5), &persister) + .expect("a re-add"); + assert_eq!( + store_count(&backend), + 1, + "once stored, a re-add is a no-op again" + ); + } + /// The first-ingest persist failure (`add_incoming_contact_request`). /// The request is not tracked and must not be reported as newly /// discovered — a caller that took it as real would act on a request that diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/private_contact_requests.rs b/packages/rs-platform-wallet/src/wallet/identity/network/private_contact_requests.rs index d54a3374843..8d00875ac4f 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/private_contact_requests.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/private_contact_requests.rs @@ -32,11 +32,10 @@ use std::future::Future; use dash_sdk::dapi_grpc::platform::v0::ResponseMetadata; use dash_sdk::platform::dashpay::{ - private_contact_request_identities, private_contact_request_outgoing_viewing_key, - private_contact_request_signature, private_contact_request_signing_data, - private_contact_request_signing_keys, private_contact_requests_from_scan, - recipient_key_purpose_is_valid, PrivateContactRequest, RejectedPrivateContactRequest, - PRIVATE_CONTACT_REQUEST_DOCUMENT_TYPE_NAME, + private_contact_request_identities, private_contact_request_signature, + private_contact_request_signing_data, private_contact_request_signing_keys, + private_contact_requests_from_scan, recipient_key_purpose_is_valid, PrivateContactRequest, + ProvedIdentity, RejectedPrivateContactRequest, PRIVATE_CONTACT_REQUEST_DOCUMENT_TYPE_NAME, }; use dash_sdk::platform::private_document_entries::{ PrivateDocumentEntries, PrivateDocumentEntriesQuery, @@ -89,26 +88,9 @@ impl PrivateDocumentKeyPaths { /// drop there. pub fn scan_keys( &self, - mut derive: impl FnMut(&DerivationPath) -> Result, + derive: impl FnMut(&DerivationPath) -> Result, ) -> Result { - let mut keys = PrivateDocumentScanKeys::new(); - for (identity_id, paths) in &self.recipients { - let secret_keys = paths - .iter() - .map(|(key_id, path)| Ok((*key_id, derive(path)?))) - .collect::, E>>()?; - keys = keys.with_recipient(*identity_id, &secret_keys); - } - for (identity_id, key_id, path) in &self.senders { - let mut secret_key = derive(path)?; - keys = keys.with_sender( - *identity_id, - *key_id, - private_contact_request_outgoing_viewing_key(&secret_key), - ); - secret_key.non_secure_erase(); - } - Ok(keys) + PrivateDocumentScanKeys::derive(&self.recipients, &self.senders, derive) } } @@ -296,6 +278,7 @@ impl DashPayView<'_, B> { &recipient_identity, recipient_key_index, &request, + self.sdk.version(), )?; let signature = private_contact_request_signature(signer, &signing_key, &signing_data).await?; @@ -314,6 +297,7 @@ impl DashPayView<'_, B> { recipient_key_index, &request, &esk_seed, + self.sdk.version(), ) .await; esk_seed.zeroize(); @@ -354,9 +338,11 @@ impl DashPayView<'_, B> { None => Err("the sender identity is no longer in the wallet".to_string()), Some(managed) => { managed.note_sent_request_core_height(*recipient_identity_id, 0); - managed - .add_sent_contact_request(contact_request.clone(), &self.persister) - .map_err(|e| e.to_string()) + // Paid for: held in memory even when its store fails, so the + // contact establishes and a retry adopts it rather than paying + // again. The next private sync stores it. + managed.add_paid_sent_contact_request(contact_request.clone(), &self.persister); + Ok(()) } } }; @@ -557,10 +543,13 @@ impl DashPayView<'_, B> { ) }; let contract = dashpay_contract()?; - // Fetched once a pass: the senders, the wallet's own identities (whose - // keys a request is checked against as they are on chain), and the - // recipients of the requests they sent. - let mut fetched_identities: BTreeMap> = BTreeMap::new(); + // Fetched once a pass, and again when a later page's entries are newer + // than the proof: the senders and the wallet's own identities, whose + // keys a request is checked against as they were when it was appended. + let mut proved_identities: BTreeMap = BTreeMap::new(); + // The recipients of the requests the wallet's identities sent, read for + // the key id of the key a request is sealed to. + let mut recipients: BTreeMap> = BTreeMap::new(); let mut position = start; report.next_position = start; @@ -588,19 +577,27 @@ impl DashPayView<'_, B> { &page, ) .await?; - for identity_id in private_contact_request_identities(&scan) { - if let Entry::Vacant(entry) = fetched_identities.entry(identity_id) { - entry.insert(Identity::fetch(&self.sdk, identity_id).await?); - } - } + let stale: BTreeMap = private_contact_request_identities(&scan) + .into_iter() + .filter(|(identity_id, height)| { + proved_identities + .get(identity_id) + .is_none_or(|proved| proved.proved_at_height < *height) + }) + .collect(); + proved_identities.extend( + self.sdk + .fetch_private_contact_request_identities(&stale) + .await?, + ); let read = private_contact_requests_from_scan( &contract, scan, - &fetched_identities, + &proved_identities, self.sdk.version(), )?; for sent in &read.sent { - if let Entry::Vacant(entry) = fetched_identities.entry(sent.recipient_id) { + if let Entry::Vacant(entry) = recipients.entry(sent.recipient_id) { entry.insert(Identity::fetch(&self.sdk, sent.recipient_id).await?); } } @@ -609,7 +606,10 @@ impl DashPayView<'_, B> { // keys the signer computes. let mut records: Vec<(Identifier, ContactRequest, bool, Option<[u8; 43]>)> = Vec::new(); for received in &read.received { - let Some(Some(sender)) = fetched_identities.get(&received.sender_id) else { + let Some(sender) = proved_identities + .get(&received.sender_id) + .and_then(|proved| proved.identity.as_ref()) + else { continue; }; let sender_key = identity_public_key(sender, received.sender_key_id)?; @@ -640,7 +640,7 @@ impl DashPayView<'_, B> { } for sent in &read.sent { let recipient_key = sent.recipient_public_key.serialize(); - let recipient_key_id = fetched_identities + let recipient_key_id = recipients .get(&sent.recipient_id) .and_then(Option::as_ref) .and_then(|recipient| { @@ -988,9 +988,11 @@ mod tests { entries: entries .into_iter() .zip(0u64..) + // The mock proves identities at height 0, which an entry's + // identities must be proved at or after. .map(|(data, position)| PrivateDocumentEntry { position, - block_height: 10 + position, + block_height: 0, block_time_ms: BLOCK_TIME_MS, data, }) diff --git a/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/contact_requests.rs b/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/contact_requests.rs index ae586e2d388..eb21d8ee398 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/contact_requests.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/contact_requests.rs @@ -76,16 +76,84 @@ impl ManagedIdentity { &mut self, request: ContactRequest, persister: &WalletPersister, + ) -> Result<(), crate::changeset::PersistenceError> { + self.add_sent_contact_request_with(request, persister, false) + } + + /// [`Self::add_sent_contact_request`] for a request already paid for, which must be held + /// whatever the store does: a store failure is logged and the request kept in memory, so + /// the contact establishes and a retry adopts it instead of paying again. The next add of + /// the same request stores it. + pub(crate) fn add_paid_sent_contact_request( + &mut self, + request: ContactRequest, + persister: &WalletPersister, + ) { + if let Err(error) = self.add_sent_contact_request_with(request, persister, true) { + // `keep_unstored` turns every store failure into a kept request. + tracing::error!(%error, "a paid sent contact request was refused"); + } + } + + /// Store `cs` for the request to `recipient_id`. With `keep_unstored`, a failure is kept + /// for the next add of the request instead of returned. + fn store_sent( + &mut self, + persister: &WalletPersister, + cs: ContactChangeSet, + recipient_id: Identifier, + keep_unstored: bool, + ) -> Result<(), crate::changeset::PersistenceError> { + match persister.store(cs.into()) { + Ok(()) => { + self.dashpay.unstored_sent.remove(&recipient_id); + Ok(()) + } + Err(error) if keep_unstored => { + tracing::warn!( + owner = %self.id(), + recipient = %recipient_id, + %error, + "paid sent contact request kept in memory; the next sync stores it" + ); + self.dashpay.unstored_sent.insert(recipient_id); + Ok(()) + } + Err(error) => Err(error), + } + } + + fn add_sent_contact_request_with( + &mut self, + request: ContactRequest, + persister: &WalletPersister, + keep_unstored: bool, ) -> Result<(), crate::changeset::PersistenceError> { let owner_id = self.id(); let recipient_id = request.recipient_id; // Sent-side guard / rotation supersede: already established. - if let Some(existing) = self.dashpay.established_contacts.get(&recipient_id) { + if let Some(existing) = self + .dashpay + .established_contacts + .get(&recipient_id) + .cloned() + { // Same outgoing reference → a re-ingest of a doc we already // track carries no new information; re-establishing would wipe - // user metadata, so it's a no-op. + // user metadata, so it's a no-op, unless it is still to store. if existing.outgoing_request.account_reference == request.account_reference { + if self.dashpay.unstored_sent.contains(&recipient_id) { + let mut cs = ContactChangeSet::default(); + cs.established.insert( + SentContactRequestKey { + owner_id, + recipient_id, + }, + existing, + ); + self.store_sent(persister, cs, recipient_id, keep_unstored)?; + } return Ok(()); } // Different outgoing reference → a rotation re-send. Advance @@ -106,7 +174,7 @@ impl ManagedIdentity { }, updated.clone(), ); - persister.store(cs.into())?; + self.store_sent(persister, cs, recipient_id, keep_unstored)?; self.dashpay .established_contacts .insert(recipient_id, updated); @@ -129,8 +197,24 @@ impl ManagedIdentity { // by the contract's `($ownerId, toUserId, accountReference)` // unique index. Persist BEFORE committing to memory, same as // the established branch and for the same retry reason. - if let Some(existing) = self.dashpay.sent_contact_requests.get(&recipient_id) { + if let Some(existing) = self + .dashpay + .sent_contact_requests + .get(&recipient_id) + .cloned() + { if existing.account_reference == request.account_reference { + if self.dashpay.unstored_sent.contains(&recipient_id) { + let mut cs = ContactChangeSet::default(); + cs.sent_requests.insert( + SentContactRequestKey { + owner_id, + recipient_id, + }, + ContactRequestEntry { request: existing }, + ); + self.store_sent(persister, cs, recipient_id, keep_unstored)?; + } return Ok(()); } let mut cs = ContactChangeSet::default(); @@ -143,7 +227,7 @@ impl ManagedIdentity { request: request.clone(), }, ); - persister.store(cs.into())?; + self.store_sent(persister, cs, recipient_id, keep_unstored)?; self.dashpay .sent_contact_requests .insert(recipient_id, request); @@ -191,7 +275,7 @@ impl ManagedIdentity { }, contact.clone(), ); - persister.store(cs.into())?; + self.store_sent(persister, cs, recipient_id, keep_unstored)?; self.dashpay.incoming_contact_requests.remove(&recipient_id); self.dashpay .established_contacts @@ -208,7 +292,7 @@ impl ManagedIdentity { request: request.clone(), }, ); - persister.store(cs.into())?; + self.store_sent(persister, cs, recipient_id, keep_unstored)?; self.dashpay .sent_contact_requests .insert(recipient_id, request); diff --git a/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/dashpay.rs b/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/dashpay.rs index 23b8ed5fac8..01a74222039 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/dashpay.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/dashpay.rs @@ -110,6 +110,12 @@ pub struct DashPayState { /// ([`ContactRequest::came_privately`]), so it holds across restarts. pub(super) private_shielded_addresses: BTreeMap, + /// Recipients whose tracked outgoing request is in memory but did not reach disk: a + /// private reply paid for before its store failed. The next add of the same request (the + /// private sync recovers it from the store) stores it. In memory only: after a restart the + /// scan from the store's start recovers the request anyway. + pub(super) unstored_sent: BTreeSet, + /// Lowest Platform-assigned `$createdAtCoreBlockHeight` among OUR sent /// `contactRequest` docs to each recipient seen by a sweep this process. /// diff --git a/packages/rs-platform-wallet/src/wallet/mod.rs b/packages/rs-platform-wallet/src/wallet/mod.rs index 748f17de197..bbf1c58caf4 100644 --- a/packages/rs-platform-wallet/src/wallet/mod.rs +++ b/packages/rs-platform-wallet/src/wallet/mod.rs @@ -26,11 +26,11 @@ pub use platform_addresses::{ PerAccountPlatformAddressState, PerWalletPlatformAddressState, PlatformAddressTag, PlatformAddressWallet, }; -#[cfg(feature = "shielded")] -pub use platform_wallet::ShieldedShieldPreflight; pub use platform_wallet::{ PlatformWallet, PlatformWalletInfo, WalletId, WalletStateReadGuard, WalletStateWriteGuard, }; +#[cfg(feature = "shielded")] +pub use platform_wallet::{PrivateReplyPayment, ShieldedShieldPreflight}; pub use provider_key_at_index::{ProviderDerivedKey, ProviderKeyKind}; #[cfg(feature = "shielded")] pub use shielded::operations::shield_fee_reserve_credits; diff --git a/packages/rs-platform-wallet/src/wallet/platform_wallet.rs b/packages/rs-platform-wallet/src/wallet/platform_wallet.rs index f825d9a62e7..6d426f35143 100644 --- a/packages/rs-platform-wallet/src/wallet/platform_wallet.rs +++ b/packages/rs-platform-wallet/src/wallet/platform_wallet.rs @@ -58,6 +58,19 @@ use { /// Unique identifier for a wallet (32-byte hash). pub type WalletId = [u8; 32]; +/// What a private contact request reply is paid with, which +/// [`PlatformWallet::accept_contact_request_replying_in_kind`] asks for only +/// when the reply is private: the shielded coordinator, the seed the spend +/// authority is derived from (dropped once it is), the shielded account and +/// the prover. +#[cfg(feature = "shielded")] +pub struct PrivateReplyPayment { + pub coordinator: Arc, + pub seed: Seed, + pub account: u32, + pub prover: P, +} + /// Cached capacity snapshot for shielding a Platform Payment account. /// /// The figures are computed from the same lexicographic address ordering and @@ -1623,28 +1636,30 @@ impl PlatformWallet { } /// Accept a DashPay contact request replying the way it came: privately, - /// paid from `account`'s shielded notes, when it came privately and no - /// reply exists yet, else with a `contactRequest` document signed by - /// `signer`. The spend authority is derived from `seed` only when the - /// reply is private. See + /// paid from a shielded account, when it came privately and no reply + /// exists yet, else with a `contactRequest` document signed by `signer`. + /// `payment` gives what a private reply is paid with (the shielded + /// coordinator, the seed for the spend authority, the account and the + /// prover) and is called only for a private reply, so a public reply or + /// an adopted one needs neither shielded support nor the seed. See /// [`DashPayView::accept_contact_request_replying_in_kind`](crate::DashPayView::accept_contact_request_replying_in_kind). #[cfg(feature = "shielded")] - #[allow(clippy::too_many_arguments)] - pub async fn accept_contact_request_replying_in_kind( + pub async fn accept_contact_request_replying_in_kind( &self, - coordinator: &Arc, - seed: &[u8], - account: u32, request: &ContactRequest, shielded_address: Option<[u8; 43]>, signer: &S, crypto: &C, - prover: P, + payment: R, ) -> Result where S: Signer + Send + Sync, C: ContactCryptoProvider + Sync, P: OrchardProver + Send + Sync, + Seed: AsRef<[u8]> + Send, + R: FnOnce() -> RFut + Send, + RFut: std::future::Future, PlatformWalletError>> + + Send, { self.identity .dashpay() @@ -1654,8 +1669,15 @@ impl PlatformWallet { signer, crypto, |entry| async move { - let keyset = self.derive_spend_keyset(seed, account).await?; - self.pay_private_contact_request(coordinator, keyset, account, entry, prover) + let PrivateReplyPayment { + coordinator, + seed, + account, + prover, + } = payment().await?; + let keyset = self.derive_spend_keyset(seed.as_ref(), account).await?; + drop(seed); + self.pay_private_contact_request(&coordinator, keyset, account, entry, prover) .await }, ) From 473a4405f6968fe2c13da7975f27e6893a1a6a64 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 9 Nov 2026 00:27:34 +0700 Subject: [PATCH 013/117] fix(sdk): refuse expired signing keys and refresh a DashPay without the private type Review fixes: - A signing key past its `expires_at` signs nothing: the reader checks it at the entry's block time with dpp's `is_expired_at` (the expiry instant is expired), and the key list and the seal check it at this device's clock, so an expired key is not offered. - `private_document_create_from_shielded_pool` establishes protocol version 15 before building the transition, which an older SDK's limits would refuse for any entries. - A DashPay the context provider holds from before the private type is refetched from the network and registered with the provider, which the page's proof is verified against. - The reader refuses an identity given under another identity's id, and a recipient key id that is not the key the entry is encrypted to. - The identity fetch iterates owned pairs, so its future is `Send` (rust-lang/rust#100013), and its retry logic is a function tests drive with scripted proofs. - An OS RNG failure is an error, not a panic. - The wasm-sdk contract fixture gives its indexed `rarity` string the `maxLength` dpp's now enabled validation requires of an indexed string. Co-Authored-By: Claude Opus 5.5 --- .../dashpay/private_contact_request.rs | 347 ++++++++++++++++-- ...vate_document_create_from_shielded_pool.rs | 31 ++ .../data-contract-v0-crypto-card-game.ts | 1 + 3 files changed, 344 insertions(+), 35 deletions(-) diff --git a/packages/rs-sdk/src/platform/dashpay/private_contact_request.rs b/packages/rs-sdk/src/platform/dashpay/private_contact_request.rs index 74108ad8ed2..c695aed08f2 100644 --- a/packages/rs-sdk/src/platform/dashpay/private_contact_request.rs +++ b/packages/rs-sdk/src/platform/dashpay/private_contact_request.rs @@ -29,6 +29,7 @@ use crate::platform::private_document_scan::{ }; use crate::platform::{Fetch, Identifier}; use crate::{Error, Sdk}; +use dash_context_provider::ContextProvider; use dpp::data_contract::accessors::v0::DataContractV0Getters; use dpp::data_contract::document_type::accessors::{DocumentTypeV0Getters, DocumentTypeV2Getters}; use dpp::data_contract::document_type::property_constraints::DocumentSystemValues; @@ -39,6 +40,7 @@ use dpp::data_contract::validate_document::DataContractDocumentValidationMethods use dpp::data_contract::DataContract; use dpp::identity::accessors::IdentityGettersV0; use dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; +use dpp::identity::identity_public_key::accessors::v1::IdentityPublicKeyGettersV1; use dpp::identity::identity_public_key::contract_bounds::ContractBounds; use dpp::identity::signer::Signer; use dpp::identity::{Identity, IdentityPublicKey, KeyID, KeyType, Purpose, SecurityLevel}; @@ -546,9 +548,11 @@ fn disabled_problem(key: &IdentityPublicKey, role: &str, at_ms: Option) -> } /// Why `key` may not sign a private contact request of the DashPay contract `dashpay_id` at -/// `at_ms` (now when `None`), if it may not: it must be an `AUTHENTICATION` key at one of -/// `levels`, of a signing key type, bound to no other contract or document type, and enabled -/// then. +/// `at_ms` (now, by this device's clock, when `None`), if it may not: it must be an +/// `AUTHENTICATION` key at one of `levels`, of a signing key type, bound to no other contract or +/// document type, enabled then and not expired then (the expiry instant itself is expired, as +/// dpp's `is_expired_at` says). The reader checks it at the entry's block time, which decides; +/// the seal and the key list check it now, so a key about to be refused is not offered. fn signing_key_problem( key: &IdentityPublicKey, levels: &[SecurityLevel], @@ -597,13 +601,37 @@ fn signing_key_problem( )) } } + let expiry_checked_at = at_ms.unwrap_or_else(now_ms); + if key.is_expired_at(expiry_checked_at) { + return Some(format!( + "the signing key {} expired at {}, by {expiry_checked_at}", + key.id(), + key.expires_at().unwrap_or_default() + )); + } disabled_problem(key, "signing", at_ms) } +/// This device's clock, in milliseconds since the Unix epoch: block times are measured the same +/// way. A clock before the epoch reads as 0. +fn now_ms() -> u64 { + #[cfg(target_arch = "wasm32")] + { + js_sys::Date::now() as u64 + } + #[cfg(not(target_arch = "wasm32"))] + { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|elapsed| elapsed.as_millis() as u64) + .unwrap_or_default() + } +} + /// The keys of `identity` that may sign a private contact request now, in key id order: its -/// enabled `AUTHENTICATION` keys at a level the type's documents are signed at, of a signing -/// key type, bound to no other contract or document type. A wallet signs with the first its -/// signer holds. +/// enabled, unexpired `AUTHENTICATION` keys at a level the type's documents are signed at, of a +/// signing key type, bound to no other contract or document type. A wallet signs with the first +/// its signer holds. pub fn private_contact_request_signing_keys<'a>( dashpay_contract: &DataContract, identity: &'a Identity, @@ -770,6 +798,44 @@ pub struct ProvedIdentity { /// How many identities [`Sdk::fetch_private_contact_request_identities`] fetches at once. const PRIVATE_CONTACT_REQUEST_IDENTITY_FETCHES: usize = 4; +/// [`Sdk::fetch_private_contact_request_identities`] with `fetch` proving one identity: each +/// identity `needed` lists is fetched, a few at a time, until proved at or after its height, +/// twice at most. A stale second proof or a failed fetch fails the whole fetch. +async fn fetch_proved_identities( + needed: &BTreeMap, + fetch: F, +) -> Result, Error> +where + F: Fn(Identifier) -> Fut + Sync, + Fut: std::future::Future> + Send, +{ + let fetch = &fetch; + // Owned pairs: a future borrowing the map's entries through the stream's closure is not + // `Send` (rust-lang/rust#100013), and hosts spawn this one. + let needed: Vec<(Identifier, u64)> = needed + .iter() + .map(|(identity_id, height)| (*identity_id, *height)) + .collect(); + futures::stream::iter(needed) + .map(|(identity_id, height)| async move { + let mut proved = fetch(identity_id).await?; + if proved.proved_at_height < height { + proved = fetch(identity_id).await?; + } + if proved.proved_at_height < height { + return Err(Error::Generic(format!( + "the identity {identity_id} was proved at block {}, before the block \ + {height} of an entry it checks", + proved.proved_at_height + ))); + } + Ok((identity_id, proved)) + }) + .buffer_unordered(PRIVATE_CONTACT_REQUEST_IDENTITY_FETCHES) + .try_collect() + .await +} + /// The identities whose keys [`private_contact_requests_from_scan`] checks the entries of /// `scan` against, each with the height of the latest block whose entry it checks: a snapshot /// of it must be proved at or after that block. They are the senders signed received entries @@ -841,6 +907,15 @@ pub fn private_contact_requests_from_scan( was not given" ))) } + Some(ProvedIdentity { + identity: Some(identity), + .. + }) if identity.id() != identity_id => { + return Err(Error::Generic(format!( + "the identity given for {identity_id} is {}", + identity.id() + ))) + } Some(proved) if proved.proved_at_height < height => { return Err(Error::Generic(format!( "the identity {identity_id} was proved at block {}, before the block {height} \ @@ -988,6 +1063,10 @@ impl EntryReader<'_> { "the recipient has no key {}", received.recipient_key_id )), + Some(key) if !key_is(key, received.entry.recipient_public_key()) => Some(format!( + "the recipient key {} is not the key the entry is encrypted to", + received.recipient_key_id + )), Some(key) => disabled_problem(key, "recipient", Some(received.block_time_ms)), }, }; @@ -1112,7 +1191,13 @@ impl Sdk { // A seed whose hash is no secret key, a chance of about 2^-128, fails the seal, and a // retry draws another. let mut esk_seed = [0u8; 32]; - StdRng::from_os_rng().fill_bytes(&mut esk_seed); + StdRng::try_from_os_rng() + .map_err(|error| { + Error::Generic(format!( + "private contact request: the OS RNG failed: {error}" + )) + })? + .fill_bytes(&mut esk_seed); let sealed = seal_private_contact_request( &dashpay_contract, &PrivateContactRequestSender { @@ -1141,7 +1226,22 @@ impl Sdk { "DashPay private contact requests", ) .await?; - self.fetch_dashpay_contract().await + let contract = self.fetch_dashpay_contract().await?; + if private_contact_request_type(&contract).is_ok() { + return Ok(contract); + } + // The context provider may hold a DashPay from before the type, kept from an older + // version: the network's is current. The provider gets it too, as the page's proof is + // verified against the contract the provider gives. + let contract_id = contract.id(); + let contract = DataContract::fetch(self, contract_id) + .await? + .map(Arc::new) + .ok_or_else(|| Error::Generic(format!("the DashPay contract {contract_id} is gone")))?; + if let Some(provider) = self.context_provider() { + provider.register_data_contract(Arc::clone(&contract)); + } + Ok(contract) } /// Fetches the identities `needed` lists ([`private_contact_request_identities`]), a few at @@ -1152,34 +1252,15 @@ impl Sdk { &self, needed: &BTreeMap, ) -> Result, Error> { - futures::stream::iter(needed.iter()) - .map(|(identity_id, height)| async move { - let mut attempt = 0; - loop { - let (identity, metadata) = - Identity::fetch_with_metadata(self, *identity_id, None).await?; - if metadata.height >= *height { - return Ok(( - *identity_id, - ProvedIdentity { - identity, - proved_at_height: metadata.height, - }, - )); - } - attempt += 1; - if attempt == 2 { - return Err(Error::Generic(format!( - "the identity {identity_id} was proved at block {}, before the \ - block {height} of an entry it checks", - metadata.height - ))); - } - } + fetch_proved_identities(needed, |identity_id| async move { + let (identity, metadata) = + Identity::fetch_with_metadata(self, identity_id, None).await?; + Ok(ProvedIdentity { + identity, + proved_at_height: metadata.height, }) - .buffer_unordered(PRIVATE_CONTACT_REQUEST_IDENTITY_FETCHES) - .try_collect() - .await + }) + .await } /// Reads the page of DashPay's `privateContactRequest` store from `start_position` and @@ -2533,6 +2614,202 @@ mod tests { ); } + /// A signing key with an expiry signs entries appended before it, not at or after it, and + /// is not offered once expired. + #[test] + fn should_refuse_a_signing_key_expired_when_the_entry_was_appended() { + let contract = dashpay(); + let entry = seal(&contract, &sender_identity(None), &smallest_request(1), 1); + let expiring = |expires_at| { + with_key( + sender_identity(None), + IdentityPublicKey::V1( + dpp::identity::identity_public_key::v1::IdentityPublicKeyV1 { + id: 0, + purpose: Purpose::AUTHENTICATION, + security_level: SecurityLevel::HIGH, + contract_bounds: None, + key_type: KeyType::ECDSA_SECP256K1, + read_only: false, + data: BinaryData::new( + PublicKey::from_secret_key(&secret_key(9)) + .serialize() + .to_vec(), + ), + disabled_at: None, + total_budget: None, + expires_at: Some(expires_at), + }, + ), + ) + }; + for (case, expires_at, accepted) in [ + ("after the entry", BLOCK_TIME_MS + 1, true), + ("at the entry", BLOCK_TIME_MS, false), + ("before the entry", BLOCK_TIME_MS - 1, false), + ] { + let reasons = reasons(read( + &contract, + vec![entry.clone()], + &recipient_keys(), + &[expiring(expires_at), recipient_identity()], + )); + if accepted { + assert_eq!(reasons, vec![], "{case}"); + } else { + assert!( + matches!( + reasons.as_slice(), + [PrivateContactRequestRejection::SenderKeyNotUsable(_)] + ), + "{case}: {reasons:?}" + ); + } + } + assert!( + private_contact_request_signing_keys(&contract, &expiring(1)) + .expect("DashPay v3") + .is_empty(), + "an expired key is not offered" + ); + } + + /// The recipient key an entry names must be the one it is encrypted to, and an identity + /// must be given under its own id. + #[test] + fn should_refuse_a_recipient_key_or_identity_given_under_another_id() { + let contract = dashpay(); + let sender = sender_identity(None); + let entry = seal(&contract, &sender, &smallest_request(1), 1); + // The recipient's decryption key is registered under its authentication key's id. + let misregistered = PrivateDocumentScanKeys::new() + .with_recipient(Identifier::from(RECIPIENT_ID), &[(0, secret_key(2))]); + assert!(matches!( + reasons(read( + &contract, + vec![entry.clone()], + &misregistered, + &[sender.clone(), recipient_identity()], + )) + .as_slice(), + [PrivateContactRequestRejection::RecipientKeyNotUsable(_)] + )); + + let target = PrivateDocumentTarget { + contract_id: contract.id().to_buffer(), + document_type_name: PRIVATE_CONTACT_REQUEST_DOCUMENT_TYPE_NAME, + }; + let scan = recipient_keys().scan_page(&target, 0, &page(vec![entry])); + let swapped = BTreeMap::from([ + ( + Identifier::from(SENDER_ID), + ProvedIdentity { + identity: Some(recipient_identity()), + proved_at_height: PROVED_AT_HEIGHT, + }, + ), + ( + Identifier::from(RECIPIENT_ID), + ProvedIdentity { + identity: Some(recipient_identity()), + proved_at_height: PROVED_AT_HEIGHT, + }, + ), + ]); + assert!(private_contact_requests_from_scan( + &contract, + scan, + &swapped, + PlatformVersion::latest() + ) + .is_err()); + } + + /// The fetch of a scan's identities refetches a stale proof once, keeps the second, fails + /// on two stale proofs or a failed fetch, and keeps a proof that the identity does not + /// exist. + #[tokio::test] + async fn should_fetch_identities_proved_after_their_entries() { + let id = Identifier::from(SENDER_ID); + let needed = BTreeMap::from([(id, 10)]); + let scripted = |answers: Vec>| { + let answers = std::sync::Mutex::new(answers.into_iter()); + move |_| { + let answer = answers + .lock() + .expect("answers") + .next() + .expect("no more fetches than answers"); + async move { answer } + } + }; + let proved = |identity: Option, height| { + Ok(ProvedIdentity { + identity, + proved_at_height: height, + }) + }; + + let fetched = fetch_proved_identities( + &needed, + scripted(vec![ + proved(None, 9), + proved(Some(sender_identity(None)), 10), + ]), + ) + .await + .expect("the second proof is recent enough"); + assert_eq!( + fetched.get(&id), + Some(&ProvedIdentity { + identity: Some(sender_identity(None)), + proved_at_height: 10, + }) + ); + assert!(fetch_proved_identities( + &needed, + scripted(vec![ + proved(Some(sender_identity(None)), 9), + proved(Some(sender_identity(None)), 9), + ]), + ) + .await + .is_err()); + assert!(fetch_proved_identities( + &needed, + scripted(vec![Err(Error::Generic("unreachable".to_string()))]), + ) + .await + .is_err()); + assert_eq!( + fetch_proved_identities(&needed, scripted(vec![proved(None, 11)])) + .await + .expect("a recent proof of absence"), + BTreeMap::from([( + id, + ProvedIdentity { + identity: None, + proved_at_height: 11, + }, + )]) + ); + } + + /// The scan's futures are `Send`, so a host can spawn them. + #[cfg(feature = "mocks")] + #[test] + fn should_give_futures_a_host_can_spawn() { + fn spawnable(_: T) {} + let sdk: Sdk = crate::SdkBuilder::new_mock() + .with_version(PlatformVersion::latest()) + .build() + .expect("a mock sdk"); + let keys = PrivateDocumentScanKeys::new(); + let needed = BTreeMap::new(); + spawnable(sdk.scan_private_contact_requests(0, &keys)); + spawnable(sdk.fetch_private_contact_request_identities(&needed)); + } + /// DashPay's documents are signed at HIGH or CRITICAL. #[test] fn should_sign_at_the_levels_dashpay_documents_are_signed_at() { diff --git a/packages/rs-sdk/src/platform/transition/private_document_create_from_shielded_pool.rs b/packages/rs-sdk/src/platform/transition/private_document_create_from_shielded_pool.rs index 7e26d7bf5aa..b5e57972902 100644 --- a/packages/rs-sdk/src/platform/transition/private_document_create_from_shielded_pool.rs +++ b/packages/rs-sdk/src/platform/transition/private_document_create_from_shielded_pool.rs @@ -10,6 +10,7 @@ use dpp::state_transition::private_document_create_from_shielded_pool_transition use dpp::state_transition::private_document_create_from_shielded_pool_transition::PrivateDocumentCreateFromShieldedPoolTransition; use dpp::state_transition::proof_result::StateTransitionProofResult; use dpp::state_transition::StateTransition; +use dpp::version::feature_initial_protocol_versions::PRIVATE_DOCUMENT_CREATE_FROM_SHIELDED_POOL_INITIAL_PROTOCOL_VERSION; /// Append sealed entries to the store of a private document type (type 29), paying the fee /// from shielded notes. The transition names no identity: the bundle's spends pay, and its @@ -92,6 +93,13 @@ impl PrivateDocumentCreateFromShieldedPool for Sdk { bundle: OrchardBundleParams, settings: Option, ) -> Result { + // An unpinned SDK starts below protocol version 15, whose limits admit no entries: the + // network's version is established before the transition is built against it. + self.require_protocol_version( + PRIVATE_DOCUMENT_CREATE_FROM_SHIELDED_POOL_INITIAL_PROTOCOL_VERSION, + "private documents", + ) + .await?; let state_transition = self.private_document_create_from_shielded_pool_transition( data_contract_id, document_type_name, @@ -156,4 +164,27 @@ mod tests { assert!(error.contains(refusal), "{refusal}: {error}"); } } + + /// Below protocol version 15 the send is refused before the transition is built, rather + /// than built against limits that admit no entries. + #[tokio::test] + async fn should_refuse_to_send_below_protocol_version_15() { + let sdk: Sdk = SdkBuilder::new_mock() + .with_version(PlatformVersion::get(14).expect("protocol version 14")) + .build() + .expect("expected a mock sdk"); + let error = sdk + .private_document_create_from_shielded_pool( + Identifier::new([4; 32]), + "privateContactRequest".to_string(), + vec![BinaryData::new(vec![1; 589])], + 114_140_000, + bundle(), + None, + ) + .await + .expect_err("protocol version 14") + .to_string(); + assert!(error.contains("exists from protocol version 15"), "{error}"); + } } diff --git a/packages/wasm-sdk/tests/unit/fixtures/data-contract-v0-crypto-card-game.ts b/packages/wasm-sdk/tests/unit/fixtures/data-contract-v0-crypto-card-game.ts index bbbf2915719..84e4ad99d4a 100644 --- a/packages/wasm-sdk/tests/unit/fixtures/data-contract-v0-crypto-card-game.ts +++ b/packages/wasm-sdk/tests/unit/fixtures/data-contract-v0-crypto-card-game.ts @@ -52,6 +52,7 @@ const contract = { rarity: { type: 'string', description: 'Rarity level of the card', + maxLength: 63, enum: [ 'common', 'uncommon', From d0db59fa1a7f0f4fa11a84e9dca010f8994cf65f Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 9 Nov 2026 00:37:55 +0700 Subject: [PATCH 014/117] fix(platform-wallet): record a private request only once read back, one at a time Review fixes (with the SDK changes of #5809 merged in): - A paid private request is recorded only as the store proves it: the type 29 proof shows the notes spent, not the entry appended, so a send reads the store back with a short private sync and returns the request recorded from it. Not read back yet, it returns the new `PrivateContactRequestPending`. - One private request to a recipient is in flight at a time: reserved before sealing, it keeps a retried or concurrent send or accept from paying again (`PrivateContactRequestPending`) while it is being paid for, while its payment is unconfirmed, and until a sync reads its entry back, which settles it. A payment that definitely failed releases it. Pending requests are kept in memory. - The keep-in-memory recording of a paid request whose store failed is gone: nothing is recorded outside the sync, whose failed ingest holds the identity's cursor while the request stays pending. - Collapsing a sweep's requests orders by the reference's version first, as passing over older requests does (`freshness`), so a rotation and a later replay of the request it rotated past collapse to the rotation. - A cursor rewound under a pass (unignoring a sender) is not advanced again that pass. - The recipients of recovered sent requests are proved at or after their entries, each fetched on its own: one that cannot be read holds back its senders' cursors rather than failing the pass or skipping the request. - An OS RNG failure is an error, not a panic. - The public send documents that its privacy refusal needs the private sync to have read the store on a restored device. Co-Authored-By: Claude Opus 5.5 --- packages/rs-platform-wallet/src/error.rs | 10 + .../identity/network/contact_requests.rs | 106 ++-- .../network/private_contact_requests.rs | 526 +++++++++++++++--- .../managed_identity/contact_requests.rs | 137 ++--- .../state/managed_identity/dashpay.rs | 18 +- 5 files changed, 574 insertions(+), 223 deletions(-) diff --git a/packages/rs-platform-wallet/src/error.rs b/packages/rs-platform-wallet/src/error.rs index 3bfa657e0ce..1130358d50b 100644 --- a/packages/rs-platform-wallet/src/error.rs +++ b/packages/rs-platform-wallet/src/error.rs @@ -663,6 +663,16 @@ pub enum PlatformWalletError { )] PrivateContactRequestNeedsPrivateReply(Identifier), + /// A private contact request to this identity is being paid for, or was + /// paid for and is not yet read back from DashPay's store: no other one is + /// sent until the private sync reads it back or its payment definitely + /// fails, as the store keeps every entry appended. + #[error( + "a private contact request to {0} is in flight or not yet read back from the store; sync \ + private contact requests to settle it" + )] + PrivateContactRequestPending(Identifier), + /// No DPNS `domain` document exists for the requested name (exact /// normalized-label lookup came back empty). Distinct from /// [`Self::InvalidParameter`]: the input was well-formed, the name diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs b/packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs index b6b77912968..d09a862dafb 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs @@ -563,6 +563,12 @@ impl DashPayView<'_, B> { /// for seedless / external-signable wallets: the raw ECDH scalar stays in /// the signer and only the (public) xpub, the shared secret, and the masked /// reference cross back. + /// + /// A recipient whose requests with this identity came or went privately is + /// refused ([`PlatformWalletError::PrivateContactRequestNeedsPrivateReply`]). + /// The refusal reads the requests the wallet tracks, so on a restored or + /// second device it holds once the private sync + /// (`sync_private_contact_requests`) has read the store. #[allow(clippy::type_complexity)] pub async fn send_contact_request_with_external_signer( &self, @@ -579,7 +585,9 @@ impl DashPayView<'_, B> { { // A contact whose requests came or went privately is kept off chain: a // `contactRequest` document would publish the relationship. Replies - // and rotations go privately instead. + // and rotations go privately instead. This reads the requests the + // wallet tracks, so on a restored or second device it holds only once + // the private sync has read the store. let private = { let wm = self.wallet_manager.read().await; wm.get_wallet_info(&self.wallet_id) @@ -1045,10 +1053,11 @@ fn query_lower_bound(high_water: Option) -> Option { /// Collapse a stream of parsed received contact requests to the single /// newest request per sender, keyed by `sender_id`. /// -/// "Newest" is the lexicographic max of `(created_at, account_reference)` -/// — created_at is the primary signal (a rotation request is broadcast -/// later), with account_reference as a deterministic tiebreak for the -/// degenerate same-timestamp case. +/// "Newest" is the lexicographic max of the account reference's version, +/// `created_at`, then `account_reference` ([`freshness`]): a rotation bumps +/// the version and is broadcast later, and a replay of an older request +/// appended again later keeps its older version. The reference is a +/// deterministic tiebreak for the degenerate same-timestamp case. /// /// This is the idempotency keystone of the recurring sync: on-chain /// `contactRequest` docs are immutable and never deleted, so a sender who @@ -1089,9 +1098,7 @@ pub(super) fn newest_received_per_sender( let sender = req.sender_id; let replace = newest .get(&sender) - .map(|cur| { - (req.created_at, req.account_reference) > (cur.created_at, cur.account_reference) - }) + .map(|cur| freshness(&req) > freshness(cur)) .unwrap_or(true); if replace { newest.insert(sender, req); @@ -1122,9 +1129,7 @@ pub(super) fn newest_sent_per_recipient( let recipient = req.recipient_id; let replace = newest .get(&recipient) - .map(|cur| { - (req.created_at, req.account_reference) > (cur.created_at, cur.account_reference) - }) + .map(|cur| freshness(&req) > freshness(cur)) .unwrap_or(true); if replace { newest.insert(recipient, req); @@ -1187,12 +1192,19 @@ fn record_and_collapse_sent_requests( /// its creation time. A replay of an older request appended again later has a /// newer time but an older version. fn predates(candidate: &ContactRequest, tracked: &ContactRequest) -> bool { + freshness(candidate) < freshness(tracked) +} + +/// How fresh a request between one pair is, for collapsing a sweep's requests +/// and for passing over older ones alike: by its account reference's version, +/// which a rotation bumps, then by its creation time, then by the reference +/// itself so that equal times order deterministically. A replay of an older +/// request appended again later has a newer time but an older version. +fn freshness(request: &ContactRequest) -> (u32, u64, u32) { ( - platform_encryption::account_reference_version(candidate.account_reference), - candidate.created_at, - ) < ( - platform_encryption::account_reference_version(tracked.account_reference), - tracked.created_at, + platform_encryption::account_reference_version(request.account_reference), + request.created_at, + request.account_reference, ) } @@ -4798,45 +4810,35 @@ mod sweep_tests { ); } - /// A paid sent request whose store fails is held in memory: the contact - /// it answers establishes, so a retry adopts it instead of paying again, - /// and the next add of the same request stores it, once. + /// Collapsing a sweep's requests orders them by version first, as ingest + /// passes over older ones: a rotation and a replay of the request it + /// rotated past, appended later, collapse to the rotation, both ways. #[test] - fn a_paid_sent_request_whose_store_fails_is_kept_and_stored_by_its_next_add() { - let our = 1u8; - let contact = 2u8; - let our_id = Identifier::from([our; 32]); - let contact_id = Identifier::from([contact; 32]); - let mut info = info_with_bare_identity(our); - let managed = info - .identity_manager - .managed_identity_mut(&our_id) - .expect("managed identity"); - managed - .add_incoming_contact_request(test_request(contact, our, 0), &noop_persister()) - .expect("incoming request"); - - managed.add_paid_sent_contact_request(test_request(our, contact, 5), &failing_persister()); - assert!( - managed - .dashpay() - .established_contacts() - .contains_key(&contact_id), - "the reply establishes the contact in memory" + fn a_sweep_collapses_to_the_highest_version_before_the_latest_time() { + let at = |sender: u8, recipient: u8, version: u32, account: u32, created_at: u64| { + let mut request = test_request( + sender, + recipient, + (version << ACCOUNT_REFERENCE_VERSION_SHIFT) | account, + ); + request.created_at = created_at; + request + }; + let rotation = at(2, 1, 1, 5, 200); + let replay = at(2, 1, 0, 4, 300); + assert_eq!( + newest_received_per_sender([rotation.clone(), replay.clone()]) + .get(&Identifier::from([2u8; 32])) + .map(|request| request.account_reference), + Some(rotation.account_reference) ); - - let (persister, backend) = counting_persister(); - managed - .add_sent_contact_request(test_request(our, contact, 5), &persister) - .expect("the next add"); - assert_eq!(store_count(&backend), 1, "the next add stores it"); - managed - .add_sent_contact_request(test_request(our, contact, 5), &persister) - .expect("a re-add"); + let sent_rotation = at(1, 2, 1, 5, 200); + let sent_replay = at(1, 2, 0, 4, 300); assert_eq!( - store_count(&backend), - 1, - "once stored, a re-add is a no-op again" + newest_sent_per_recipient([sent_replay, sent_rotation.clone()]) + .get(&Identifier::from([2u8; 32])) + .map(|request| request.account_reference), + Some(sent_rotation.account_reference) ); } diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/private_contact_requests.rs b/packages/rs-platform-wallet/src/wallet/identity/network/private_contact_requests.rs index 8d00875ac4f..cea0c725648 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/private_contact_requests.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/private_contact_requests.rs @@ -26,8 +26,7 @@ //! //! [`PrivateContact`]: crate::wallet::identity::PrivateContact -use std::collections::btree_map::Entry; -use std::collections::BTreeMap; +use std::collections::{BTreeMap, BTreeSet}; use std::future::Future; use dash_sdk::dapi_grpc::platform::v0::ResponseMetadata; @@ -68,6 +67,7 @@ use crate::broadcaster::TransactionBroadcaster; use crate::error::PlatformWalletError; use crate::wallet::identity::types::dashpay::contact_request::ContactRequest; use crate::wallet::identity::types::dashpay::established_contact::EstablishedContact; +use crate::wallet::identity::ManagedIdentity; /// The paths of the keys a wallet's identities read DashPay's private contact /// request store with, for a [`ContactCryptoProvider`] to derive inside the @@ -108,11 +108,17 @@ pub struct PrivateContactSyncReport { pub next_position: u64, /// Whether the pass read to the store's last entry. pub complete: bool, - /// Identities whose ingest did not reach disk: their cursor stays where - /// it was, so the next pass reads their entries again. + /// Identities whose ingest did not reach disk, or one of whose sent + /// requests could not be rebuilt for want of its recipient: their cursor + /// stays where it was, so the next pass reads their entries again. pub unpersisted_identities: Vec, } +/// How many pages of the store a send reads back after paying: enough for an +/// identity that synced recently, whose new entry is in the last page or two. +/// A request not read back within them stays pending for a later sync. +const POST_SEND_SYNC_PAGES: usize = 4; + /// One of the wallet's identities, as a pass reads the store for it. struct ScannedIdentity { identity_id: Identifier, @@ -122,6 +128,27 @@ struct ScannedIdentity { position: u64, /// Its ingest failed this pass: it is not advanced again until the next. failed: bool, + /// Its cursor was rewound under the pass (unignoring a sender): it is not + /// advanced again this pass, so the next reads from the rewound position. + rewound: bool, +} + +impl ScannedIdentity { + /// Advance `managed`'s cursor to `next_position` once a page's ingest for + /// it reached disk: unless it was rewound under the pass, which stops it + /// advancing for the rest of the pass, as a later page's advance would + /// otherwise overwrite the rewind. + fn advance(&mut self, managed: &mut ManagedIdentity, next_position: u64) { + if self.failed || self.rewound { + return; + } + if managed.dashpay().private_contact_requests_position() != self.position { + self.rewound = true; + return; + } + managed.advance_private_contact_requests_position(self.position, next_position); + self.position = managed.dashpay().private_contact_requests_position(); + } } /// The local record of a private request: a [`ContactRequest`] carrying the @@ -197,11 +224,21 @@ impl DashPayView<'_, B> { /// /// The keys and the account reference are chosen as for a document send, /// so a later request to the same recipient, either way, rotates past this - /// one. Once `pay` succeeds the request is recorded as sent and our - /// receiving account for the contact is registered. Nothing after `pay` - /// fails the send, as the payment cannot be taken back: a record that does - /// not reach disk is logged, and the next private sync recovers it from - /// the store with the outgoing viewing key. + /// one. + /// + /// One private request to a recipient is in flight at a time: it is + /// reserved before sealing, and while it is pending another is refused + /// with [`PlatformWalletError::PrivateContactRequestPending`], as the store + /// keeps every entry appended and a second payment could execute too. + /// + /// The proof `pay` waits for shows the notes spent, not the entry + /// appended, so a paid request is recorded only once read back: a private + /// sync of a few pages follows the payment, and records the request from + /// the store as it records any request the identity sent. If it is not + /// read back yet, the send returns `PrivateContactRequestPending` and the + /// request stays pending until a later sync reads it. An unconfirmed + /// payment stays pending the same way; one that definitely failed + /// releases it. The pending request is kept in memory only. #[allow(clippy::too_many_arguments)] pub async fn send_private_contact_request( &self, @@ -214,6 +251,79 @@ impl DashPayView<'_, B> { crypto: &C, pay: F, ) -> Result + where + S: Signer + Send + Sync, + C: ContactCryptoProvider + Sync, + F: FnOnce(BinaryData) -> Fut + Send, + Fut: Future> + Send, + { + let reserved = self + .with_managed(sender_identity_id, |managed| { + managed.reserve_private_reply(*recipient_identity_id) + }) + .await?; + if !reserved { + return Err(PlatformWalletError::PrivateContactRequestPending( + *recipient_identity_id, + )); + } + let sent = self + .send_reserved_private_contact_request( + sender_identity_id, + recipient_identity_id, + account_label, + auto_accept_proof, + shielded_address, + signer, + crypto, + pay, + ) + .await; + // A payment that may have executed keeps the request pending. + let keep_pending = matches!( + &sent, + Err(PlatformWalletError::PrivateContactRequestPending(_) + | PlatformWalletError::ShieldedSpendUnconfirmed { .. }) + ); + if !keep_pending { + self.with_managed(sender_identity_id, |managed| { + managed.release_private_reply(recipient_identity_id) + }) + .await?; + } + sent + } + + /// Run `f` on the managed identity `identity_id` under the wallet lock. + async fn with_managed( + &self, + identity_id: &Identifier, + f: impl FnOnce(&mut ManagedIdentity) -> R, + ) -> Result { + let mut wm = self.wallet_manager.write().await; + let managed = wm + .get_wallet_info_mut(&self.wallet_id) + .ok_or_else(|| PlatformWalletError::WalletNotFound(hex::encode(self.wallet_id)))? + .identity_manager + .managed_identity_mut(identity_id) + .ok_or(PlatformWalletError::IdentityNotFound(*identity_id))?; + Ok(f(managed)) + } + + /// The body of [`Self::send_private_contact_request`], once the request + /// to the recipient is reserved. + #[allow(clippy::too_many_arguments)] + async fn send_reserved_private_contact_request( + &self, + sender_identity_id: &Identifier, + recipient_identity_id: &Identifier, + account_label: Option, + auto_accept_proof: AutoAcceptProofSource, + shielded_address: Option<[u8; 43]>, + signer: &S, + crypto: &C, + pay: F, + ) -> Result where S: Signer + Send + Sync, C: ContactCryptoProvider + Sync, @@ -284,7 +394,13 @@ impl DashPayView<'_, B> { private_contact_request_signature(signer, &signing_key, &signing_data).await?; let mut esk_seed = [0u8; 32]; - StdRng::from_os_rng().fill_bytes(&mut esk_seed); + StdRng::try_from_os_rng() + .map_err(|error| { + PlatformWalletError::InvalidIdentityData(format!( + "the OS RNG failed while sealing a private contact request: {error}" + )) + })? + .fill_bytes(&mut esk_seed); let sealed = crypto .seal_private_contact_request( &sender_enc_path, @@ -312,14 +428,15 @@ impl DashPayView<'_, B> { "the provider sealed an entry shorter than its commitment".to_string(), ) })?; - // Everything the record needs is computed before paying. + self.with_managed(sender_identity_id, |managed| { + managed.note_private_reply_commitment(*recipient_identity_id, commitment) + }) + .await?; + // What the record read back must carry, computed before paying. let shared_secret = crypto .ecdh_shared_secret(&sender_enc_path, &recipient_enc_pubkey) .await?; - - let metadata = pay(entry).await?; - - let contact_request = private_request_record( + let expected = private_request_record( *sender_identity_id, *recipient_identity_id, sender_key_index, @@ -327,52 +444,56 @@ impl DashPayView<'_, B> { &request, &commitment, &shared_secret, - metadata.time_ms, + 0, ); - let recorded = { - let mut wm = self.wallet_manager.write().await; - match wm.get_wallet_info_mut(&self.wallet_id).and_then(|info| { - info.identity_manager - .managed_identity_mut(sender_identity_id) - }) { - None => Err("the sender identity is no longer in the wallet".to_string()), - Some(managed) => { - managed.note_sent_request_core_height(*recipient_identity_id, 0); - // Paid for: held in memory even when its store fails, so the - // contact establishes and a retry adopts it rather than paying - // again. The next private sync stores it. - managed.add_paid_sent_contact_request(contact_request.clone(), &self.persister); - Ok(()) - } - } + + pay(entry).await?; + + // Recorded only as the store proves it: read it back. + if let Err(error) = self + .sync_private_contact_requests(crypto, POST_SEND_SYNC_PAGES) + .await + { + tracing::warn!( + sender = %sender_identity_id, + recipient = %recipient_identity_id, + %error, + "private contact request paid for; reading it back failed, a later sync reads it" + ); + } + let recorded = self + .with_managed(sender_identity_id, |managed| { + managed + .dashpay() + .outgoing_request(recipient_identity_id) + .filter(|tracked| { + tracked.account_reference == expected.account_reference + && tracked.encrypted_public_key == expected.encrypted_public_key + }) + .cloned() + }) + .await?; + let Some(contact_request) = recorded else { + return Err(PlatformWalletError::PrivateContactRequestPending( + *recipient_identity_id, + )); }; - match recorded { - Ok(()) => { - if let Err(e) = self - .register_contact_account( - sender_identity_id, - recipient_identity_id, - account_index, - contact_xpub_ext, - ) - .await - { - tracing::warn!( - sender = %sender_identity_id, - recipient = %recipient_identity_id, - error = %e, - "private contact request sent; its receiving account is not registered \ - yet, the next sync registers it" - ); - } - } - Err(e) => tracing::warn!( + if let Err(e) = self + .register_contact_account( + sender_identity_id, + recipient_identity_id, + account_index, + contact_xpub_ext, + ) + .await + { + tracing::warn!( sender = %sender_identity_id, recipient = %recipient_identity_id, error = %e, - "private contact request sent but not recorded; the next private sync \ - recovers it from the store" - ), + "private contact request sent; its receiving account is not registered yet, the \ + next sync registers it" + ); } Ok(contact_request) } @@ -494,6 +615,7 @@ impl DashPayView<'_, B> { identity_index, position: managed.dashpay().private_contact_requests_position(), failed: false, + rewound: false, }); } Ok((identities, paths)) @@ -548,8 +670,9 @@ impl DashPayView<'_, B> { // keys a request is checked against as they were when it was appended. let mut proved_identities: BTreeMap = BTreeMap::new(); // The recipients of the requests the wallet's identities sent, read for - // the key id of the key a request is sealed to. - let mut recipients: BTreeMap> = BTreeMap::new(); + // the key id of the key a request is sealed to, as they were when it + // was appended or later. + let mut recipients: BTreeMap = BTreeMap::new(); let mut position = start; report.next_position = start; @@ -596,11 +719,46 @@ impl DashPayView<'_, B> { &proved_identities, self.sdk.version(), )?; + // Each recipient is fetched on its own, so one that cannot be read + // holds back only the identities whose sent requests it is for. + let mut stale_recipients: BTreeMap = BTreeMap::new(); for sent in &read.sent { - if let Entry::Vacant(entry) = recipients.entry(sent.recipient_id) { - entry.insert(Identity::fetch(&self.sdk, sent.recipient_id).await?); + let height = stale_recipients.entry(sent.recipient_id).or_default(); + *height = (*height).max(sent.block_height); + } + stale_recipients.retain(|recipient_id, height| { + recipients + .get(recipient_id) + .is_none_or(|proved| proved.proved_at_height < *height) + }); + let mut unreadable_recipients = BTreeSet::new(); + for (recipient_id, height) in stale_recipients { + match self + .sdk + .fetch_private_contact_request_identities(&BTreeMap::from([( + recipient_id, + height, + )])) + .await + { + Ok(fetched) => recipients.extend(fetched), + Err(error) => { + tracing::warn!( + recipient = %recipient_id, + %error, + "the recipient of a sent private contact request could not be read; \ + its sender's cursor is held" + ); + unreadable_recipients.insert(recipient_id); + } } } + let held: BTreeSet = read + .sent + .iter() + .filter(|sent| unreadable_recipients.contains(&sent.recipient_id)) + .map(|sent| sent.sender_id) + .collect(); // The wallet's records of the page's requests, under the shared // keys the signer computes. @@ -639,10 +797,13 @@ impl DashPayView<'_, B> { )); } for sent in &read.sent { + if unreadable_recipients.contains(&sent.recipient_id) { + continue; + } let recipient_key = sent.recipient_public_key.serialize(); let recipient_key_id = recipients .get(&sent.recipient_id) - .and_then(Option::as_ref) + .and_then(|proved| proved.identity.as_ref()) .and_then(|recipient| { recipient .public_keys() @@ -703,6 +864,10 @@ impl DashPayView<'_, B> { else { continue; }; + if held.contains(&identity_id) && !identity.failed { + identity.failed = true; + report.unpersisted_identities.push(identity_id); + } let mut received = Vec::new(); let mut addresses = Vec::new(); let mut sent = Vec::new(); @@ -744,12 +909,17 @@ impl DashPayView<'_, B> { for (record, shielded_address) in &addresses { managed.note_private_shielded_address(record, *shielded_address); } - if received_ok && sent_ok && !identity.failed { - managed.advance_private_contact_requests_position( - identity.position, - read.next_position, - ); - identity.position = managed.dashpay().private_contact_requests_position(); + if received_ok && sent_ok { + // A pending private request read back and recorded is + // settled. + for sent in read + .sent + .iter() + .filter(|sent| sent.sender_id == identity_id) + { + managed.settle_private_reply(&sent.recipient_id, &sent.commitment); + } + identity.advance(managed, read.next_position); } else if !identity.failed { identity.failed = true; report.unpersisted_identities.push(identity_id); @@ -1038,8 +1208,73 @@ mod tests { (manager, wallet) } + /// The page of DashPay's private store from `start`, holding `entries`. + fn store_page(start: u64, entries: Vec>) -> PrivateDocumentEntries { + let total_count = start + entries.len() as u64; + PrivateDocumentEntries { + entries: entries + .into_iter() + .zip(start..) + .map(|(data, position)| PrivateDocumentEntry { + position, + block_height: 0, + block_time_ms: BLOCK_TIME_MS, + data, + }) + .collect(), + total_count, + } + } + + /// From now on, `wallet`'s mock SDK answers the page from `start` with + /// `entries`: the store as it is once they are appended. + async fn publish(wallet: &PlatformWallet, start: u64, entries: Vec>) { + let contract = dashpay_contract().expect("bundled DashPay"); + let query = PrivateDocumentEntriesQuery::new( + contract.id(), + PRIVATE_CONTACT_REQUEST_DOCUMENT_TYPE_NAME, + start, + ); + let mut sdk = (*wallet.identity().sdk).clone(); + let mut mock = sdk.mock(); + mock.remove_fetch_expectation::(query.clone()) + .await; + mock.expect_fetch(query, Some(store_page(start, entries))) + .await + .expect("page expectation"); + } + + /// A `pay` that appends the entry to the store `wallet` reads, after + /// `before` from `start`, and keeps it. + fn append_entry( + wallet: &Arc, + start: u64, + before: Vec>, + kept: &Arc>>, + ) -> impl FnOnce( + BinaryData, + ) -> std::pin::Pin< + Box> + Send>, + > + Send { + let wallet = Arc::clone(wallet); + let kept = Arc::clone(kept); + move |entry| { + Box::pin(async move { + let mut entries = before; + entries.push(entry.to_vec()); + publish(&wallet, start, entries).await; + kept.lock().expect("kept entries").push(entry); + Ok(ResponseMetadata { + time_ms: BLOCK_TIME_MS, + ..Default::default() + }) + }) + } + } + /// A `pay` that keeps the entry instead of paying for it, and answers as - /// the proof of a block at `BLOCK_TIME_MS` would. + /// the proof of a block at `BLOCK_TIME_MS` would: the entry never reaches + /// the store. fn keep_entry( kept: &Arc>>, ) -> impl FnOnce(BinaryData) -> std::future::Ready> @@ -1085,7 +1320,8 @@ mod tests { let bob_signer = signer(BOB_PHRASE, &bob); let shielded_address = [0x55; 43]; - let (_alice_manager, alice_wallet) = wallet(ALICE_PHRASE, &alice, &[&bob], vec![]).await; + let (_alice_manager, alice_wallet) = + wallet(ALICE_PHRASE, &alice, &[&alice, &bob], vec![]).await; let kept = Arc::new(Mutex::new(Vec::new())); let sent = alice_wallet .identity() @@ -1098,10 +1334,10 @@ mod tests { Some(shielded_address), &alice_signer, &alice_crypto, - keep_entry(&kept), + append_entry(&alice_wallet, 0, vec![], &kept), ) .await - .expect("Alice sends the request"); + .expect("Alice sends the request, read back from the store"); let entry = kept.lock().expect("kept entries").remove(0); assert_eq!(entry.len(), 589, "the entry takes DashPay's entry size"); assert_eq!(sent.sender_key_index, ENCRYPTION_KEY_ID); @@ -1228,7 +1464,7 @@ mod tests { None, &bob_signer, &bob_crypto, - keep_entry(&reply), + append_entry(&bob_wallet, 1, vec![], &reply), ) .await .expect("Bob accepts privately"); @@ -1277,7 +1513,8 @@ mod tests { .managed_identity_mut(&bob.id()) .expect("Bob"); managed.ignore_sender(&alice.id()); - assert_eq!(managed.dashpay().private_contact_requests_position(), 1); + // Past Alice's request and Bob's reply, which the accept read back. + assert_eq!(managed.dashpay().private_contact_requests_position(), 2); managed.unignore_sender(&alice.id()); assert_eq!(managed.dashpay().private_contact_requests_position(), 0); } @@ -1414,7 +1651,7 @@ mod tests { let alice_crypto = SeedCryptoProvider::from_seed(seed(ALICE_PHRASE), Network::Testnet); let alice_signer = signer(ALICE_PHRASE, &alice); - let (_manager, alice_wallet) = wallet(ALICE_PHRASE, &alice, &[&bob], vec![]).await; + let (_manager, alice_wallet) = wallet(ALICE_PHRASE, &alice, &[&alice, &bob], vec![]).await; let kept = Arc::new(Mutex::new(Vec::new())); let sent = alice_wallet .identity() @@ -1427,7 +1664,7 @@ mod tests { None, &alice_signer, &alice_crypto, - keep_entry(&kept), + append_entry(&alice_wallet, 0, vec![], &kept), ) .await .expect("Alice sends the request"); @@ -1452,6 +1689,145 @@ mod tests { .await; } + /// One private request to a recipient is in flight at a time: one paid + /// for but not read back, or whose payment is unconfirmed, keeps the next + /// from being sent, until a sync reads it back; one whose payment failed + /// does not. + #[tokio::test] + async fn should_send_one_private_request_to_a_recipient_at_a_time() { + let alice = identity(ALICE_PHRASE, ALICE); + let bob = identity(BOB_PHRASE, BOB); + let alice_crypto = SeedCryptoProvider::from_seed(seed(ALICE_PHRASE), Network::Testnet); + let alice_signer = signer(ALICE_PHRASE, &alice); + let (_manager, alice_wallet) = wallet(ALICE_PHRASE, &alice, &[&alice, &bob], vec![]).await; + let view = alice_wallet.identity().dashpay(); + macro_rules! send { + ($pay:expr) => { + view.send_private_contact_request( + &alice.id(), + &bob.id(), + None, + AutoAcceptProofSource::None, + None, + &alice_signer, + &alice_crypto, + $pay, + ) + .await + }; + } + let pending = |result: &Result| matches!(result, Err(PlatformWalletError::PrivateContactRequestPending(id)) if *id == bob.id()); + + // A payment that definitely failed releases the recipient. + let failed = send!( + |_| std::future::ready(Err(PlatformWalletError::InvalidIdentityData( + "no notes".to_string() + ))) + ); + assert!(!pending(&failed), "{failed:?}"); + + // Paid for, but the entry is not in the store yet: pending. + let kept = Arc::new(Mutex::new(Vec::new())); + let first = send!(keep_entry(&kept)); + assert!(pending(&first), "{first:?}"); + let entry = kept.lock().expect("kept").remove(0); + + // A second request is refused before anything is paid. + let paid_again = Arc::new(Mutex::new(Vec::new())); + let second = send!(keep_entry(&paid_again)); + assert!(pending(&second), "{second:?}"); + assert!( + paid_again.lock().expect("paid").is_empty(), + "nothing is paid twice" + ); + + // The store reads the entry back: settled and recorded. + publish(&alice_wallet, 0, vec![entry.to_vec()]).await; + view.sync_private_contact_requests(&alice_crypto, 4) + .await + .expect("Alice reads the store"); + managed(&alice_wallet, ALICE, |managed| { + assert!(!managed.dashpay().private_reply_pending(&bob.id())); + assert!(managed.dashpay().outgoing_request(&bob.id()).is_some()); + }) + .await; + } + + /// A payment whose execution is unconfirmed keeps the request pending. + #[tokio::test] + async fn should_keep_an_unconfirmed_private_request_pending() { + let alice = identity(ALICE_PHRASE, ALICE); + let bob = identity(BOB_PHRASE, BOB); + let alice_crypto = SeedCryptoProvider::from_seed(seed(ALICE_PHRASE), Network::Testnet); + let alice_signer = signer(ALICE_PHRASE, &alice); + let (_manager, alice_wallet) = wallet(ALICE_PHRASE, &alice, &[&alice, &bob], vec![]).await; + let result = alice_wallet + .identity() + .dashpay() + .send_private_contact_request( + &alice.id(), + &bob.id(), + None, + AutoAcceptProofSource::None, + None, + &alice_signer, + &alice_crypto, + |_| { + std::future::ready(Err(PlatformWalletError::ShieldedSpendUnconfirmed { + operation: "private document create", + reason: "the proof fetch timed out".to_string(), + })) + }, + ) + .await; + assert!(matches!( + result, + Err(PlatformWalletError::ShieldedSpendUnconfirmed { .. }) + )); + managed(&alice_wallet, ALICE, |managed| { + assert!(managed.dashpay().private_reply_pending(&bob.id())); + }) + .await; + } + + /// A cursor rewound under a pass is not advanced again that pass, so the + /// next reads from the rewind. + #[tokio::test] + async fn should_keep_a_cursor_rewound_under_a_pass() { + let alice = identity(ALICE_PHRASE, ALICE); + let (_manager, alice_wallet) = wallet(ALICE_PHRASE, &alice, &[], vec![]).await; + let iw = alice_wallet.identity(); + let mut wm = iw.wallet_manager.write().await; + let managed = wm + .get_wallet_info_mut(&alice_wallet.wallet_id()) + .expect("wallet info") + .identity_manager + .managed_identity_mut(&alice.id()) + .expect("Alice"); + let mut scanned = ScannedIdentity { + identity_id: alice.id(), + identity_index: 0, + position: 0, + failed: false, + rewound: false, + }; + scanned.advance(managed, 100); + assert_eq!(managed.dashpay().private_contact_requests_position(), 100); + // Unignoring a sender rewinds the cursor while a page is read. + let carol = Identifier::from([0xC1; 32]); + managed.ignore_sender(&carol); + managed.unignore_sender(&carol); + assert_eq!(managed.dashpay().private_contact_requests_position(), 0); + scanned.advance(managed, 200); + scanned.advance(managed, 300); + assert_eq!( + managed.dashpay().private_contact_requests_position(), + 0, + "the rewind stands for the rest of the pass" + ); + assert!(scanned.rewound); + } + /// The record of a private request rebuilds byte for byte from the same /// entry, and another entry's record encrypts under other IVs. #[test] diff --git a/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/contact_requests.rs b/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/contact_requests.rs index eb21d8ee398..47ac2a87bc9 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/contact_requests.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/contact_requests.rs @@ -76,84 +76,16 @@ impl ManagedIdentity { &mut self, request: ContactRequest, persister: &WalletPersister, - ) -> Result<(), crate::changeset::PersistenceError> { - self.add_sent_contact_request_with(request, persister, false) - } - - /// [`Self::add_sent_contact_request`] for a request already paid for, which must be held - /// whatever the store does: a store failure is logged and the request kept in memory, so - /// the contact establishes and a retry adopts it instead of paying again. The next add of - /// the same request stores it. - pub(crate) fn add_paid_sent_contact_request( - &mut self, - request: ContactRequest, - persister: &WalletPersister, - ) { - if let Err(error) = self.add_sent_contact_request_with(request, persister, true) { - // `keep_unstored` turns every store failure into a kept request. - tracing::error!(%error, "a paid sent contact request was refused"); - } - } - - /// Store `cs` for the request to `recipient_id`. With `keep_unstored`, a failure is kept - /// for the next add of the request instead of returned. - fn store_sent( - &mut self, - persister: &WalletPersister, - cs: ContactChangeSet, - recipient_id: Identifier, - keep_unstored: bool, - ) -> Result<(), crate::changeset::PersistenceError> { - match persister.store(cs.into()) { - Ok(()) => { - self.dashpay.unstored_sent.remove(&recipient_id); - Ok(()) - } - Err(error) if keep_unstored => { - tracing::warn!( - owner = %self.id(), - recipient = %recipient_id, - %error, - "paid sent contact request kept in memory; the next sync stores it" - ); - self.dashpay.unstored_sent.insert(recipient_id); - Ok(()) - } - Err(error) => Err(error), - } - } - - fn add_sent_contact_request_with( - &mut self, - request: ContactRequest, - persister: &WalletPersister, - keep_unstored: bool, ) -> Result<(), crate::changeset::PersistenceError> { let owner_id = self.id(); let recipient_id = request.recipient_id; // Sent-side guard / rotation supersede: already established. - if let Some(existing) = self - .dashpay - .established_contacts - .get(&recipient_id) - .cloned() - { + if let Some(existing) = self.dashpay.established_contacts.get(&recipient_id) { // Same outgoing reference → a re-ingest of a doc we already // track carries no new information; re-establishing would wipe - // user metadata, so it's a no-op, unless it is still to store. + // user metadata, so it's a no-op. if existing.outgoing_request.account_reference == request.account_reference { - if self.dashpay.unstored_sent.contains(&recipient_id) { - let mut cs = ContactChangeSet::default(); - cs.established.insert( - SentContactRequestKey { - owner_id, - recipient_id, - }, - existing, - ); - self.store_sent(persister, cs, recipient_id, keep_unstored)?; - } return Ok(()); } // Different outgoing reference → a rotation re-send. Advance @@ -174,7 +106,7 @@ impl ManagedIdentity { }, updated.clone(), ); - self.store_sent(persister, cs, recipient_id, keep_unstored)?; + persister.store(cs.into())?; self.dashpay .established_contacts .insert(recipient_id, updated); @@ -197,24 +129,8 @@ impl ManagedIdentity { // by the contract's `($ownerId, toUserId, accountReference)` // unique index. Persist BEFORE committing to memory, same as // the established branch and for the same retry reason. - if let Some(existing) = self - .dashpay - .sent_contact_requests - .get(&recipient_id) - .cloned() - { + if let Some(existing) = self.dashpay.sent_contact_requests.get(&recipient_id) { if existing.account_reference == request.account_reference { - if self.dashpay.unstored_sent.contains(&recipient_id) { - let mut cs = ContactChangeSet::default(); - cs.sent_requests.insert( - SentContactRequestKey { - owner_id, - recipient_id, - }, - ContactRequestEntry { request: existing }, - ); - self.store_sent(persister, cs, recipient_id, keep_unstored)?; - } return Ok(()); } let mut cs = ContactChangeSet::default(); @@ -227,7 +143,7 @@ impl ManagedIdentity { request: request.clone(), }, ); - self.store_sent(persister, cs, recipient_id, keep_unstored)?; + persister.store(cs.into())?; self.dashpay .sent_contact_requests .insert(recipient_id, request); @@ -275,7 +191,7 @@ impl ManagedIdentity { }, contact.clone(), ); - self.store_sent(persister, cs, recipient_id, keep_unstored)?; + persister.store(cs.into())?; self.dashpay.incoming_contact_requests.remove(&recipient_id); self.dashpay .established_contacts @@ -292,7 +208,7 @@ impl ManagedIdentity { request: request.clone(), }, ); - self.store_sent(persister, cs, recipient_id, keep_unstored)?; + persister.store(cs.into())?; self.dashpay .sent_contact_requests .insert(recipient_id, request); @@ -925,6 +841,45 @@ impl ManagedIdentity { } } + /// Reserve the one private request to `recipient` that may be in flight; + /// `false` when one already is. See + /// [`DashPayState::private_reply_pending`](super::DashPayState::private_reply_pending). + pub(crate) fn reserve_private_reply(&mut self, recipient: Identifier) -> bool { + match self.dashpay.pending_private_replies.entry(recipient) { + std::collections::btree_map::Entry::Occupied(_) => false, + std::collections::btree_map::Entry::Vacant(entry) => { + entry.insert(None); + true + } + } + } + + /// Note the commitment of the entry the pending request to `recipient` + /// was sealed as, which the private sync settles it by. + pub(crate) fn note_private_reply_commitment( + &mut self, + recipient: Identifier, + commitment: [u8; 32], + ) { + if let Some(pending) = self.dashpay.pending_private_replies.get_mut(&recipient) { + *pending = Some(commitment); + } + } + + /// Drop the pending request to `recipient`: it was never paid for, or its + /// payment definitely failed, or it was read back from the store. + pub(crate) fn release_private_reply(&mut self, recipient: &Identifier) { + self.dashpay.pending_private_replies.remove(recipient); + } + + /// Settle the pending request to `recipient` sealed as `commitment`: the + /// private sync read that entry back and recorded it. + pub(crate) fn settle_private_reply(&mut self, recipient: &Identifier, commitment: &[u8; 32]) { + if self.dashpay.pending_private_replies.get(recipient) == Some(&Some(*commitment)) { + self.dashpay.pending_private_replies.remove(recipient); + } + } + /// Sent-direction counterpart of [`Self::advance_high_water_received`]; /// same compare-and-advance semantics and caller contract. pub fn advance_high_water_sent(&mut self, snapshot: Option, max_fetched: Option) { diff --git a/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/dashpay.rs b/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/dashpay.rs index 01a74222039..0b59b225fd2 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/dashpay.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/dashpay.rs @@ -110,11 +110,13 @@ pub struct DashPayState { /// ([`ContactRequest::came_privately`]), so it holds across restarts. pub(super) private_shielded_addresses: BTreeMap, - /// Recipients whose tracked outgoing request is in memory but did not reach disk: a - /// private reply paid for before its store failed. The next add of the same request (the - /// private sync recovers it from the store) stores it. In memory only: after a restart the - /// scan from the store's start recovers the request anyway. - pub(super) unstored_sent: BTreeSet, + /// The private requests to each recipient that are being paid for, or + /// were paid for and are not yet read back from the store: the + /// commitment of the entry once it is sealed. While one is pending no + /// other private request to the recipient is sent, as the store keeps + /// every entry appended and a second payment could execute too. The + /// private sync settles one when it reads the entry back. In memory only. + pub(super) pending_private_replies: BTreeMap>, /// Lowest Platform-assigned `$createdAtCoreBlockHeight` among OUR sent /// `contactRequest` docs to each recipient seen by a sweep this process. @@ -285,6 +287,12 @@ impl DashPayState { self.private_contact_requests_position } + /// Whether a private request to `contact` is being paid for, or was paid + /// for and is not yet read back from the store; see the field doc. + pub fn private_reply_pending(&self, contact: &Identifier) -> bool { + self.pending_private_replies.contains_key(contact) + } + /// How this identity and `contact` exchanged private requests, from the /// tracked requests either way; `None` when neither went privately. pub fn private_contact(&self, contact: &Identifier) -> Option { From 4a4826a85aca9b7dc1647a2262be82b3ce61d8a4 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 9 Nov 2026 01:08:49 +0700 Subject: [PATCH 015/117] fix(sdk): keep encryption keys inside their contract bounds and check the refreshed DashPay A private contact request's encryption keys, the sender's and the recipient's, are now used only inside their contract bounds: unbound, bound to DashPay, or bound to DashPay's contactRequest or privateContactRequest. contactRequest is allowed because DashPay requires bound keys for it and apps register their DashPay keys bound to it. Keys bound to another contract, another DashPay type or a contract group are refused by the seal and the reader. dpp decides the document-type scope once, in ContractBounds::check_document_type (Allowed, Denied, or needing the group's members, which a client refuses). The signing key check and encrypted_for's key check use it too. After registering a refreshed DashPay, the helper reads it back from the context provider and fails if the provider still lacks the private type, as every page's proof would. The reader's docs no longer claim a key was the identity's when the entry was appended. Tests: encryption key bounds for both roles in the seal and the reader; the DashPay refresh on an unpinned SDK, the already-current shortcut, and a refresh the provider does not take; a send on an unpinned SDK learns the network's version before it builds. Co-Authored-By: Claude Opus 5.5 --- .../contract_bounds/mod.rs | 121 +++++ .../dashpay/private_contact_request.rs | 465 ++++++++++++++++-- packages/rs-sdk/src/platform/encrypted_for.rs | 14 +- ...vate_document_create_from_shielded_pool.rs | 49 ++ 4 files changed, 604 insertions(+), 45 deletions(-) diff --git a/packages/rs-dpp/src/identity/identity_public_key/contract_bounds/mod.rs b/packages/rs-dpp/src/identity/identity_public_key/contract_bounds/mod.rs index 80af5f6ccb5..2ebffe3e290 100644 --- a/packages/rs-dpp/src/identity/identity_public_key/contract_bounds/mod.rs +++ b/packages/rs-dpp/src/identity/identity_public_key/contract_bounds/mod.rs @@ -82,6 +82,21 @@ pub enum BatchedTransitionBoundsCheck { }, } +/// Whether a key carrying contract bounds may be used with the documents of one document type. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DocumentTypeBoundsCheck { + /// The document type lies inside the bounds. + Allowed, + /// The document type lies outside the bounds. + Denied, + /// The bounds name a contract group. The type lies inside them when its contract holds it, + /// or is a whole-contract member, of the group: only state can answer that. + RequiresContractGroupMembership { + /// The group the bounds name. + contract_group_id: Identifier, + }, +} + impl ContractBounds { /// Creates a new contract bounds for the key pub fn new_from_type( @@ -175,6 +190,33 @@ impl ContractBounds { } } + /// Whether a key carrying these bounds may be used with the documents of the type + /// `document_type_name` of the contract `contract_id`. Clients scope their ENCRYPTION and + /// DECRYPTION keys with this, as the bounds ask. A contract group bound is answered by the + /// group's members, which only state holds: a client that does not read them treats the + /// type as outside the bounds. + pub fn check_document_type( + &self, + contract_id: &Identifier, + document_type_name: &str, + ) -> DocumentTypeBoundsCheck { + match self { + SingleContract { id } if id == contract_id => DocumentTypeBoundsCheck::Allowed, + SingleContractDocumentType { + id, + document_type_name: bound_document_type_name, + } if id == contract_id && bound_document_type_name == document_type_name => { + DocumentTypeBoundsCheck::Allowed + } + SingleContract { .. } | SingleContractDocumentType { .. } => { + DocumentTypeBoundsCheck::Denied + } + ContractGroup { id } => DocumentTypeBoundsCheck::RequiresContractGroupMembership { + contract_group_id: *id, + }, + } + } + /// What authorizing `transition` with a key carrying these bounds needs. Consensus uses this /// to authorize a batch signed by a contract-bound AUTHENTICATION key. Token operations are /// contract-wide, so a document-type bound never covers them. A contract group bound is @@ -376,6 +418,85 @@ mod core_tests { } } +#[cfg(test)] +mod document_type_tests { + use super::{ContractBounds, DocumentTypeBoundsCheck}; + use crate::identifier::Identifier; + + #[test] + fn should_allow_only_the_bound_contract_or_document_type() { + let contract_id = Identifier::from([1; 32]); + let other_id = Identifier::from([2; 32]); + let contract = ContractBounds::SingleContract { id: contract_id }; + let document_type = ContractBounds::SingleContractDocumentType { + id: contract_id, + document_type_name: "note".to_string(), + }; + for (case, bounds, checked_contract, checked_type, expected) in [ + ( + "a contract bound, a type of it", + &contract, + contract_id, + "note", + DocumentTypeBoundsCheck::Allowed, + ), + ( + "a contract bound, another type of it", + &contract, + contract_id, + "other", + DocumentTypeBoundsCheck::Allowed, + ), + ( + "a contract bound, another contract", + &contract, + other_id, + "note", + DocumentTypeBoundsCheck::Denied, + ), + ( + "a type bound, that type", + &document_type, + contract_id, + "note", + DocumentTypeBoundsCheck::Allowed, + ), + ( + "a type bound, another type", + &document_type, + contract_id, + "other", + DocumentTypeBoundsCheck::Denied, + ), + ( + "a type bound, that type's name in another contract", + &document_type, + other_id, + "note", + DocumentTypeBoundsCheck::Denied, + ), + ] { + assert_eq!( + bounds.check_document_type(&checked_contract, checked_type), + expected, + "{case}" + ); + } + } + + #[test] + fn should_leave_a_contract_group_bound_to_its_members() { + let contract_group_id = Identifier::from([3; 32]); + let bounds = ContractBounds::ContractGroup { + id: contract_group_id, + }; + assert_eq!( + bounds.check_document_type(&Identifier::from([1; 32]), "note"), + DocumentTypeBoundsCheck::RequiresContractGroupMembership { contract_group_id } + ); + } +} + #[cfg(all(test, feature = "json-conversion"))] mod tests { use super::*; diff --git a/packages/rs-sdk/src/platform/dashpay/private_contact_request.rs b/packages/rs-sdk/src/platform/dashpay/private_contact_request.rs index c695aed08f2..cab9c477e47 100644 --- a/packages/rs-sdk/src/platform/dashpay/private_contact_request.rs +++ b/packages/rs-sdk/src/platform/dashpay/private_contact_request.rs @@ -41,7 +41,7 @@ use dpp::data_contract::DataContract; use dpp::identity::accessors::IdentityGettersV0; use dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; use dpp::identity::identity_public_key::accessors::v1::IdentityPublicKeyGettersV1; -use dpp::identity::identity_public_key::contract_bounds::ContractBounds; +use dpp::identity::identity_public_key::contract_bounds::DocumentTypeBoundsCheck; use dpp::identity::signer::Signer; use dpp::identity::{Identity, IdentityPublicKey, KeyID, KeyType, Purpose, SecurityLevel}; use dpp::platform_value::{BinaryData, Value}; @@ -433,7 +433,8 @@ impl SealInputs { )) })?; let encryption_public_key = - encryption_key(sender, encryption_key_id, None).map_err(Error::Generic)?; + encryption_key(sender, encryption_key_id, dashpay_contract.id(), None) + .map_err(Error::Generic)?; let recipient_key = recipient .get_public_key_by_id(recipient_key_id) @@ -451,7 +452,8 @@ impl SealInputs { ))); } let recipient_public_key = - ecdh_public_key(recipient_key, "recipient", None).map_err(Error::Generic)?; + ecdh_public_key(recipient_key, "recipient", dashpay_contract.id(), None) + .map_err(Error::Generic)?; let properties = request.to_properties(); validate_request_properties(dashpay_contract, &properties, platform_version) @@ -494,6 +496,7 @@ impl SealInputs { fn encryption_key( identity: &Identity, key_id: KeyID, + dashpay_id: Identifier, at_ms: Option, ) -> Result { let key = identity @@ -505,15 +508,17 @@ fn encryption_key( key.purpose() )); } - ecdh_public_key(key, "sender", at_ms) + ecdh_public_key(key, "sender", dashpay_id, at_ms) } -/// The public key of an `ECDSA_SECP256K1` identity key enabled at `at_ms` (now when `None`), -/// which ECDH runs on. A key disabled after `at_ms` was enabled then: keys are disabled, never -/// removed. +/// The public key of an `ECDSA_SECP256K1` identity key not disabled at `at_ms` (now when +/// `None`), and bound, if at all, to the DashPay contract `dashpay_id` or its contact requests, +/// which ECDH runs on. A key disabled after `at_ms` was not disabled then: keys are disabled, +/// never removed. fn ecdh_public_key( key: &IdentityPublicKey, role: &str, + dashpay_id: Identifier, at_ms: Option, ) -> Result { if key.key_type() != KeyType::ECDSA_SECP256K1 { @@ -523,7 +528,14 @@ fn ecdh_public_key( key.key_type() )); } - if let Some(problem) = disabled_problem(key, role, at_ms) { + if let Some(problem) = disabled_problem(key, role, at_ms).or_else(|| { + bounds_problem( + key, + role, + dashpay_id, + &PRIVATE_CONTACT_REQUEST_ENCRYPTION_KEY_DOCUMENT_TYPES, + ) + }) { return Err(problem); } PublicKey::from_slice(key.data().as_slice()).map_err(|error| { @@ -547,6 +559,54 @@ fn disabled_problem(key: &IdentityPublicKey, role: &str, at_ms: Option) -> } } +/// The DashPay document types a private contact request's ENCRYPTION and DECRYPTION keys may be +/// bound to. A private request carries what a public `contactRequest` does, encrypted to the +/// same keys, and DashPay asks for those keys to be bound to `contactRequest`. +const PRIVATE_CONTACT_REQUEST_ENCRYPTION_KEY_DOCUMENT_TYPES: [&str; 2] = + ["contactRequest", PRIVATE_CONTACT_REQUEST_DOCUMENT_TYPE_NAME]; + +/// Why the `role` key `key` may not be used for a private contact request of the DashPay +/// contract `dashpay_id`, if its contract bounds keep it from one: a bound key is used only for +/// one of `document_type_names` of DashPay, as dpp's `ContractBounds::check_document_type` +/// decides. A contract group bound depends on the group's members, which a client does not +/// read, so it is refused. +fn bounds_problem( + key: &IdentityPublicKey, + role: &str, + dashpay_id: Identifier, + document_type_names: &[&str], +) -> Option { + let bounds = key.contract_bounds()?; + let mut contract_group_id = None; + for document_type_name in document_type_names { + match bounds.check_document_type(&dashpay_id, document_type_name) { + DocumentTypeBoundsCheck::Allowed => return None, + DocumentTypeBoundsCheck::Denied => {} + DocumentTypeBoundsCheck::RequiresContractGroupMembership { + contract_group_id: group_id, + } => contract_group_id = Some(group_id), + } + } + let scope = document_type_names.join(" or "); + Some(match contract_group_id { + Some(group_id) => format!( + "the {role} key {} is bound to the contract group {group_id}, whose members a client \ + does not read, not to DashPay's {scope}", + key.id() + ), + None => format!( + "the {role} key {} is bound to the {} {}{}, not to DashPay's {scope}", + key.id(), + bounds.contract_bounds_type_string(), + bounds.identifier(), + bounds + .document_type() + .map(|name| format!(" {name}")) + .unwrap_or_default() + ), + }) +} + /// Why `key` may not sign a private contact request of the DashPay contract `dashpay_id` at /// `at_ms` (now, by this device's clock, when `None`), if it may not: it must be an /// `AUTHENTICATION` key at one of `levels`, of a signing key type, bound to no other contract or @@ -583,23 +643,13 @@ fn signing_key_problem( // A bound key signs only inside its bounds, as a transition's does. A group bound depends // on the group's members when the entry was appended, which a client cannot read, so it // signs no private request. - match key.contract_bounds() { - None => {} - Some(ContractBounds::SingleContract { id }) if *id == dashpay_id => {} - Some(ContractBounds::SingleContractDocumentType { - id, - document_type_name, - }) if *id == dashpay_id - && document_type_name == PRIVATE_CONTACT_REQUEST_DOCUMENT_TYPE_NAME => {} - Some(bounds) => { - return Some(format!( - "the signing key {} is bound to the {} {}, not to DashPay's \ - {PRIVATE_CONTACT_REQUEST_DOCUMENT_TYPE_NAME}", - key.id(), - bounds.contract_bounds_type_string(), - bounds.identifier() - )) - } + if let Some(problem) = bounds_problem( + key, + "signing", + dashpay_id, + &[PRIVATE_CONTACT_REQUEST_DOCUMENT_TYPE_NAME], + ) { + return Some(problem); } let expiry_checked_at = at_ms.unwrap_or_else(now_ms); if key.is_expired_at(expiry_checked_at) { @@ -882,14 +932,21 @@ pub fn private_contact_request_identities(scan: &PrivateDocumentScan) -> BTreeMa /// at or after the height it lists: a missing one, or one proved earlier, fails the read, to be /// fetched again. Each entry, received or sent, is checked before its payload is read: /// - it must be signed, and its sender identity must exist; -/// - the encryption key it names must be the sender's `ECDSA_SECP256K1` `ENCRYPTION` key, -/// enabled when the entry was appended; -/// - its signature must be by a key of the sender that may sign the type's documents, enabled -/// then; -/// - a received entry's recipient key must have been enabled then too; +/// - the encryption key it names must be the sender's `ECDSA_SECP256K1` `ENCRYPTION` key, not +/// disabled when the entry was appended, and bound, if at all, to DashPay or its contact +/// requests, public or private; +/// - its signature must be by a key of the sender that may sign the type's documents, neither +/// disabled nor expired then; +/// - a received entry's recipient key must not have been disabled then either, and be bound +/// the same way; /// - a sent entry must name the identity and key its outgoing viewing key is derived from; /// - its payload must satisfy the type's schema (dpp's document validation). /// +/// The keys are those of the identities given, which may be newer than the entry. That a key +/// was already the identity's when the entry was appended is not established: Platform records +/// when a key was disabled, not when it was added. Adding a key takes the identity's master key, +/// so a key added later still speaks for the identity's owner. +/// /// The first request from a sender to a recipient for an account reference is kept. A wallet /// keeps the requests it already holds from earlier scans, and drops a later one from the same /// sender for the same account reference as this does. @@ -1067,7 +1124,15 @@ impl EntryReader<'_> { "the recipient key {} is not the key the entry is encrypted to", received.recipient_key_id )), - Some(key) => disabled_problem(key, "recipient", Some(received.block_time_ms)), + Some(key) => disabled_problem(key, "recipient", Some(received.block_time_ms)) + .or_else(|| { + bounds_problem( + key, + "recipient", + self.dashpay_id, + &PRIVATE_CONTACT_REQUEST_ENCRYPTION_KEY_DOCUMENT_TYPES, + ) + }), }, }; if let Some(problem) = recipient_key_problem { @@ -1093,8 +1158,10 @@ impl EntryReader<'_> { (self.decode)(sent.entry.payload()) } - /// Checks that the identity an entry names as its sender wrote it, as the identity was - /// when the entry was appended at `block_time_ms`. + /// Checks that the identity an entry names as its sender wrote it: by the keys of the proved + /// identity, which may be newer than the entry, none disabled (nor, for the signing key, + /// expired) at the entry's `block_time_ms`. + /// [`private_contact_requests_from_scan`] says what this does not establish. fn check_sender( &self, entry: &OpenedPrivateDocumentEntry, @@ -1117,8 +1184,13 @@ impl EntryReader<'_> { let sender = self .identity(&sender_id) .ok_or(PrivateContactRequestRejection::SenderNotFound)?; - encryption_key(sender, entry.sender_key_id(), Some(block_time_ms)) - .map_err(PrivateContactRequestRejection::SenderKeyNotUsable)?; + encryption_key( + sender, + entry.sender_key_id(), + self.dashpay_id, + Some(block_time_ms), + ) + .map_err(PrivateContactRequestRejection::SenderKeyNotUsable)?; let signer = entry .recover_signer() .map_err(|_| PrivateContactRequestRejection::SenderNotAuthenticated)?; @@ -1238,8 +1310,22 @@ impl Sdk { .await? .map(Arc::new) .ok_or_else(|| Error::Generic(format!("the DashPay contract {contract_id} is gone")))?; + private_contact_request_type(&contract)?; if let Some(provider) = self.context_provider() { provider.register_data_contract(Arc::clone(&contract)); + // A provider that keeps its older DashPay, or holds none, would fail every page: + // that is refused here, once. + let held = provider.get_data_contract(&contract_id, self.version())?; + if !held + .as_deref() + .is_some_and(|held| private_contact_request_type(held).is_ok()) + { + return Err(Error::Generic(format!( + "the context provider does not hold the DashPay {contract_id} with \ + {PRIVATE_CONTACT_REQUEST_DOCUMENT_TYPE_NAME} once given it, so private \ + pages cannot be verified" + ))); + } } Ok(contract) } @@ -1306,6 +1392,7 @@ mod tests { use dpp::address_funds::AddressWitness; use dpp::data_contract::document_type::accessors::DocumentTypeV0Getters; use dpp::data_contract::document_type::DocumentPropertyType; + use dpp::identity::identity_public_key::contract_bounds::ContractBounds; use dpp::identity::identity_public_key::v0::IdentityPublicKeyV0; use dpp::identity::IdentityV0; use dpp::system_data_contracts::{load_system_data_contract, SystemDataContract}; @@ -1367,6 +1454,19 @@ mod tests { identity } + /// `identity` with its key `key_id` bound by `bounds`. + fn with_bounds(identity: Identity, key_id: KeyID, bounds: Option) -> Identity { + let mut key = identity + .get_public_key_by_id(key_id) + .expect("the identity has the key") + .clone(); + match &mut key { + IdentityPublicKey::V0(key) => key.contract_bounds = bounds, + IdentityPublicKey::V1(key) => key.contract_bounds = bounds, + } + with_key(identity, key) + } + /// The sender, whose signing key 0 is `secret_key(9)` and whose encryption key 1 is /// `secret_key(1)`, disabled at `disabled_at`. fn sender_identity(disabled_at: Option) -> Identity { @@ -2449,6 +2549,122 @@ mod tests { } } + /// An ENCRYPTION or DECRYPTION key bound to a contract is used only inside its bounds: + /// DashPay, or DashPay's contact requests, public or private, as DashPay asks for its + /// contact request keys to be bound to `contactRequest`. A group bound, whose members a + /// client does not read, is used for none. The seal and the reader apply the same rule to + /// the sender's key and the recipient's. + #[test] + fn should_encrypt_only_with_keys_bound_to_dashpay_contact_requests_or_unbound() { + let contract = dashpay(); + let dashpay_id = contract.id(); + let other_id = Identifier::from([0x77; 32]); + let document_type = |id, name: &str| { + Some(ContractBounds::SingleContractDocumentType { + id, + document_type_name: name.to_string(), + }) + }; + let request = smallest_request(1); + let signed = signature(&contract, &sender_identity(None), &request, &secret_key(9)); + let entry = seal(&contract, &sender_identity(None), &request, 1); + let seal_between = |sender: &Identity, recipient: &Identity| { + seal_private_contact_request( + &contract, + &PrivateContactRequestSender { + identity: sender, + encryption_key_id: 1, + encryption_secret_key: &secret_key(1), + signing_key_id: 0, + signature: &signed, + }, + recipient, + 2, + &request, + &[1; 32], + PlatformVersion::latest(), + ) + }; + for (case, bounds, allowed) in [ + ("unbound", None, true), + ( + "bound to DashPay", + Some(ContractBounds::SingleContract { id: dashpay_id }), + true, + ), + ( + "bound to DashPay's contact requests", + document_type(dashpay_id, "contactRequest"), + true, + ), + ( + "bound to DashPay's private contact requests", + document_type(dashpay_id, PRIVATE_CONTACT_REQUEST_DOCUMENT_TYPE_NAME), + true, + ), + ( + "bound to another contract", + Some(ContractBounds::SingleContract { id: other_id }), + false, + ), + ( + "bound to another DashPay type", + document_type(dashpay_id, "profile"), + false, + ), + ( + "bound to another contract's contactRequest", + document_type(other_id, "contactRequest"), + false, + ), + ( + "bound to a contract group", + Some(ContractBounds::ContractGroup { id: other_id }), + false, + ), + ] { + let sender = with_bounds(sender_identity(None), 1, bounds.clone()); + let recipient = with_bounds(recipient_identity(), 2, bounds); + let sealed_from = seal_between(&sender, &recipient_identity()); + let sealed_to = seal_between(&sender_identity(None), &recipient); + let read_from = reasons(read( + &contract, + vec![entry.clone()], + &recipient_keys(), + &[sender, recipient_identity()], + )); + let read_to = reasons(read( + &contract, + vec![entry.clone()], + &recipient_keys(), + &[sender_identity(None), recipient], + )); + if allowed { + assert!(sealed_from.is_ok(), "{case}: sealed from {sealed_from:?}"); + assert!(sealed_to.is_ok(), "{case}: sealed to {sealed_to:?}"); + assert_eq!(read_from, vec![], "{case}: read from"); + assert_eq!(read_to, vec![], "{case}: read to"); + } else { + assert!(sealed_from.is_err(), "{case}: sealed from"); + assert!(sealed_to.is_err(), "{case}: sealed to"); + assert!( + matches!( + read_from.as_slice(), + [PrivateContactRequestRejection::SenderKeyNotUsable(_)] + ), + "{case}: read from {read_from:?}" + ); + assert!( + matches!( + read_to.as_slice(), + [PrivateContactRequestRejection::RecipientKeyNotUsable(_)] + ), + "{case}: read to {read_to:?}" + ); + } + } + } + /// Two recovered sent requests to the same recipient for the same account reference are /// one request: the first is kept. A request the wallet both sent and received is kept on /// both sides. @@ -2904,4 +3120,183 @@ mod tests { assert_eq!(scan.rejected, vec![]); assert_eq!(scan.received[0].request, request); } + + /// A context provider holding `held` whatever version it is asked for, as a provider's + /// known contracts do. A `writable` one puts a registered contract in its place; a + /// read-only one keeps what it holds. Its clones share what it holds. + #[cfg(feature = "mocks")] + #[derive(Clone)] + struct HeldContractProvider { + held: Arc>>, + writable: bool, + } + + #[cfg(feature = "mocks")] + impl HeldContractProvider { + fn new(held: DataContract, writable: bool) -> Self { + Self { + held: Arc::new(std::sync::Mutex::new(Arc::new(held))), + writable, + } + } + + fn held(&self) -> Arc { + Arc::clone(&self.held.lock().expect("the lock")) + } + } + + #[cfg(feature = "mocks")] + impl ContextProvider for HeldContractProvider { + fn get_data_contract( + &self, + _id: &Identifier, + _platform_version: &PlatformVersion, + ) -> Result>, dash_context_provider::ContextProviderError> + { + Ok(Some(self.held())) + } + + fn get_token_configuration( + &self, + _token_id: &Identifier, + ) -> Result< + Option, + dash_context_provider::ContextProviderError, + > { + Ok(None) + } + + fn get_quorum_public_key( + &self, + _quorum_type: u32, + _quorum_hash: [u8; 32], + _core_chain_locked_height: u32, + ) -> Result<[u8; 48], dash_context_provider::ContextProviderError> { + Err(dash_context_provider::ContextProviderError::InvalidQuorum( + "no quorum in this test".to_string(), + )) + } + + fn get_platform_activation_height( + &self, + ) -> Result + { + Ok(1) + } + + fn register_data_contract(&self, contract: Arc) { + if self.writable { + *self.held.lock().expect("the lock") = contract; + } + } + } + + /// DashPay before its private type: as protocol version 14 has it. + #[cfg(feature = "mocks")] + fn dashpay_without_private_requests() -> DataContract { + let contract = load_system_data_contract( + SystemDataContract::Dashpay, + PlatformVersion::get(14).expect("protocol version 14"), + ) + .expect("DashPay loads at protocol version 14"); + assert!(private_contact_request_type(&contract).is_err()); + contract + } + + /// A mock SDK pinned at the latest version, resolving contracts through `provider`. + #[cfg(feature = "mocks")] + fn sdk_with(provider: HeldContractProvider) -> Sdk { + crate::SdkBuilder::new_mock() + .with_version(PlatformVersion::latest()) + .with_context_provider(provider) + .build() + .expect("a mock sdk") + } + + /// An SDK seeded below protocol version 15, whose context provider holds DashPay from + /// before the private type, on a network running the latest version: it learns the + /// network's version, fetches the current DashPay, and gives it to the provider, which the + /// pages are then verified against. + #[cfg(feature = "mocks")] + #[tokio::test] + async fn should_learn_the_version_and_refresh_a_dashpay_without_private_requests() { + let provider = HeldContractProvider::new(dashpay_without_private_requests(), true); + let mut sdk: Sdk = crate::SdkBuilder::new_mock() + .with_initial_version(PlatformVersion::get(14).expect("protocol version 14")) + .with_context_provider(provider.clone()) + .build() + .expect("a mock sdk"); + crate::sdk::test::expect_epoch_refresh(&mut sdk).await; + let current = dashpay(); + sdk.mock() + .expect_fetch(current.id(), Some(current.clone())) + .await + .expect("the DashPay fetch is expected"); + + let contract = sdk + .fetch_private_contact_request_contract() + .await + .expect("the current DashPay"); + + assert_eq!( + sdk.protocol_version_number(), + PlatformVersion::latest().protocol_version + ); + assert!(private_contact_request_type(&contract).is_ok()); + assert!(private_contact_request_type(&provider.held()).is_ok()); + } + + /// A DashPay the provider holds with the private type is used as it is: nothing is fetched, + /// which the mock, expecting no fetch, would refuse. + #[cfg(feature = "mocks")] + #[tokio::test] + async fn should_use_a_dashpay_the_provider_holds_with_private_requests() { + let provider = HeldContractProvider::new(dashpay(), false); + let sdk = sdk_with(provider.clone()); + + let contract = sdk + .fetch_private_contact_request_contract() + .await + .expect("the held DashPay"); + + assert!(Arc::ptr_eq(&contract, &provider.held())); + } + + /// A refresh that leaves the provider without the private type fails, rather than every + /// page failing its proof against the old contract; so does a network without DashPay. + #[cfg(feature = "mocks")] + #[tokio::test] + async fn should_fail_a_refresh_the_provider_does_not_take() { + let current = dashpay(); + + let mut sdk = sdk_with(HeldContractProvider::new( + dashpay_without_private_requests(), + false, + )); + sdk.mock() + .expect_fetch(current.id(), Some(current.clone())) + .await + .expect("the DashPay fetch is expected"); + let error = sdk + .fetch_private_contact_request_contract() + .await + .expect_err("the provider keeps the old DashPay") + .to_string(); + assert!(error.contains("does not hold"), "{error}"); + + let mut sdk = sdk_with(HeldContractProvider::new( + dashpay_without_private_requests(), + true, + )); + sdk.mock() + .expect_fetch(current.id(), None::) + .await + .expect("the DashPay fetch is expected"); + let error = sdk + .fetch_private_contact_request_contract() + .await + .expect_err("the network has no DashPay") + .to_string(); + assert!(error.contains("is gone"), "{error}"); + } } diff --git a/packages/rs-sdk/src/platform/encrypted_for.rs b/packages/rs-sdk/src/platform/encrypted_for.rs index c7c37818512..d95673f51b0 100644 --- a/packages/rs-sdk/src/platform/encrypted_for.rs +++ b/packages/rs-sdk/src/platform/encrypted_for.rs @@ -33,7 +33,7 @@ use dpp::data_contract::document_type::{ }; use dpp::document::{property_names, Document, DocumentV0Getters}; use dpp::identity::accessors::IdentityGettersV0; -use dpp::identity::contract_bounds::ContractBounds; +use dpp::identity::contract_bounds::DocumentTypeBoundsCheck; use dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; use dpp::identity::{Identity, IdentityPublicKey, KeyID, KeyType, Purpose}; use dpp::platform_value::btreemap_extensions::{ @@ -636,15 +636,9 @@ fn why_unfit( .iter() .any(|declaration| declaration.bound_to.is_some()); if let (false, Some(bounds)) = (bound_to_declared, key.contract_bounds()) { - let in_scope = match bounds { - ContractBounds::SingleContract { id } => *id == declaring_contract_id, - ContractBounds::SingleContractDocumentType { - id, - document_type_name: bound_document_type_name, - } => *id == declaring_contract_id && bound_document_type_name == document_type_name, - ContractBounds::ContractGroup { .. } => false, - }; - if !in_scope { + if bounds.check_document_type(&declaring_contract_id, document_type_name) + != DocumentTypeBoundsCheck::Allowed + { return Some(format!( "is bound to {bounds:?}, not to contract {declaring_contract_id} or its document \ type {document_type_name}" diff --git a/packages/rs-sdk/src/platform/transition/private_document_create_from_shielded_pool.rs b/packages/rs-sdk/src/platform/transition/private_document_create_from_shielded_pool.rs index b5e57972902..b8b488d8113 100644 --- a/packages/rs-sdk/src/platform/transition/private_document_create_from_shielded_pool.rs +++ b/packages/rs-sdk/src/platform/transition/private_document_create_from_shielded_pool.rs @@ -117,6 +117,7 @@ impl PrivateDocumentCreateFromShieldedPool for Sdk { #[cfg(all(test, feature = "mocks"))] mod tests { use super::*; + use crate::sdk::test::expect_epoch_refresh; use crate::SdkBuilder; use dpp::shielded::SerializedAction; use dpp::version::PlatformVersion; @@ -187,4 +188,52 @@ mod tests { .to_string(); assert!(error.contains("exists from protocol version 15"), "{error}"); } + + /// An unpinned SDK seeded below protocol version 15 learns the network's version before it + /// builds the send, and builds it against that version. + #[tokio::test] + async fn should_learn_the_network_version_before_building_a_send() { + let mut sdk: Sdk = SdkBuilder::new_mock() + .with_initial_version(PlatformVersion::get(14).expect("protocol version 14")) + .build() + .expect("expected a mock sdk"); + // The mock network runs the latest version + expect_epoch_refresh(&mut sdk).await; + let entries = vec![BinaryData::new(vec![1; 589])]; + + let error = sdk + .private_document_create_from_shielded_pool( + Identifier::new([4; 32]), + "privateContactRequest".to_string(), + entries.clone(), + 114_140_000, + bundle(), + None, + ) + .await + .expect_err("the mock takes no broadcast") + .to_string(); + + assert_eq!( + sdk.protocol_version_number(), + PlatformVersion::latest().protocol_version + ); + assert!(!error.contains("exists from protocol version"), "{error}"); + let built = sdk + .private_document_create_from_shielded_pool_transition( + Identifier::new([4; 32]), + "privateContactRequest".to_string(), + entries, + 114_140_000, + bundle(), + ) + .expect("the send builds at the learned version"); + assert!( + matches!( + built, + StateTransition::PrivateDocumentCreateFromShieldedPool(_) + ), + "{built:?}" + ); + } } From 9a4ca7f266e61c9d784374a493afe4761ab1a54e Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 9 Nov 2026 02:00:09 +0700 Subject: [PATCH 016/117] fix(platform-wallet): refuse a public request while a private one is pending, and report only recorded requests A public `contactRequest` to a recipient a private request is still pending to is refused with `PrivateContactRequestPending`: that private request may already be on chain, and the document would publish the pair. `PrivateContactSyncReport::requests` now lists the requests the pass recorded, received and sent, instead of every candidate the page opened: `ingest_sent_requests` reports what it recorded, as the received ingest already did, so an ignored sender's request, a replay older than the tracked one, one already tracked, or one whose store failed is not reported. Co-Authored-By: Claude Opus 5.5 --- .../identity/network/contact_requests.rs | 128 +++++++++++++++--- .../network/private_contact_requests.rs | 108 ++++++++++++++- 2 files changed, 214 insertions(+), 22 deletions(-) diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs b/packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs index d09a862dafb..8c3d4c8ca8c 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs @@ -566,9 +566,13 @@ impl DashPayView<'_, B> { /// /// A recipient whose requests with this identity came or went privately is /// refused ([`PlatformWalletError::PrivateContactRequestNeedsPrivateReply`]). - /// The refusal reads the requests the wallet tracks, so on a restored or - /// second device it holds once the private sync - /// (`sync_private_contact_requests`) has read the store. + /// So is one a private request is pending to + /// ([`PlatformWalletError::PrivateContactRequestPending`]), until its + /// payment fails for good or a private sync reads it back: it may already + /// be on chain. The refusal reads what the wallet tracks, so on a restored + /// or second device it holds once the private sync + /// (`sync_private_contact_requests`) has read the store. A pending request + /// is tracked in memory only: after a restart, only that read covers it. #[allow(clippy::type_complexity)] pub async fn send_contact_request_with_external_signer( &self, @@ -585,16 +589,27 @@ impl DashPayView<'_, B> { { // A contact whose requests came or went privately is kept off chain: a // `contactRequest` document would publish the relationship. Replies - // and rotations go privately instead. This reads the requests the + // and rotations go privately instead. A private request still pending + // counts too, as it may already be on chain. This reads what the // wallet tracks, so on a restored or second device it holds only once // the private sync has read the store. - let private = { + let (private, private_pending) = { let wm = self.wallet_manager.read().await; wm.get_wallet_info(&self.wallet_id) .and_then(|info| info.identity_manager.managed_identity(sender_identity_id)) - .and_then(|managed| managed.dashpay().private_contact(recipient_identity_id)) - .is_some() + .map_or((false, false), |managed| { + let dashpay = managed.dashpay(); + ( + dashpay.private_contact(recipient_identity_id).is_some(), + dashpay.private_reply_pending(recipient_identity_id), + ) + }) }; + if private_pending { + return Err(PlatformWalletError::PrivateContactRequestPending( + *recipient_identity_id, + )); + } if private { return Err(PlatformWalletError::PrivateContactRequestNeedsPrivateReply( *recipient_identity_id, @@ -1153,7 +1168,13 @@ pub(super) fn ingest_sent_sweep( fetch_ok: bool, ) -> bool { let newest_by_recipient = record_and_collapse_sent_requests(managed, requests); - let persisted = ingest_sent_requests(managed, persister, identity_id, newest_by_recipient); + let persisted = ingest_sent_requests( + managed, + persister, + identity_id, + newest_by_recipient, + &mut Vec::new(), + ); if fetch_ok && persisted { managed.mark_sent_sweep_completed(); } @@ -1304,30 +1325,43 @@ pub(super) fn ingest_received_requests( /// /// `add_sent_contact_request` carries its own duplicate / metadata-loss guard, /// so re-ingesting the same range on the next sweep is safe. +/// +/// `recorded` gets each request this recorded: not one already tracked with +/// the same account reference, a replay, or one whose store failed. pub(super) fn ingest_sent_requests( managed: &mut ManagedIdentity, persister: &WalletPersister, identity_id: Identifier, newest_by_recipient: std::collections::BTreeMap, + recorded: &mut Vec, ) -> bool { for (recipient_id, contact_request) in newest_by_recipient { // An older request than the tracked one is a replay, as on the // received side: superseding with it would re-derive the next rotation // from a retired reference. - if managed - .dashpay() - .outgoing_request(&recipient_id) - .is_some_and(|tracked| predates(&contact_request, tracked)) - { + let (replay, tracked) = + managed + .dashpay() + .outgoing_request(&recipient_id) + .map_or((false, false), |tracked| { + ( + predates(&contact_request, tracked), + tracked.account_reference == contact_request.account_reference, + ) + }); + if replay { continue; } - if let Err(e) = managed.add_sent_contact_request(contact_request, persister) { + if let Err(e) = managed.add_sent_contact_request(contact_request.clone(), persister) { tracing::error!( owner = %identity_id, error = %e, "sent-request ingest persist failed; leaving sent cursor for retry" ); return false; } + if !tracked { + recorded.push(contact_request); + } } true } @@ -4793,12 +4827,14 @@ mod sweep_tests { &noop_persister(), our_id, newest_sent_per_recipient([sent.clone()]), + &mut Vec::new(), )); assert!(ingest_sent_requests( managed, &noop_persister(), our_id, newest_sent_per_recipient([at(our, contact, 0, 4, 200)]), + &mut Vec::new(), )); assert_eq!( managed @@ -4934,7 +4970,8 @@ mod sweep_tests { managed, &failing_persister(), our_id, - newest + newest, + &mut Vec::new() )); // Control on the same fixture: the write succeeds, so the ingest @@ -4944,7 +4981,8 @@ mod sweep_tests { managed, &noop_persister(), our_id, - newest + newest, + &mut Vec::new() )); } @@ -5039,6 +5077,11 @@ mod sweep_tests { ), "precondition: the failed write must report the pass incomplete" ); + assert_eq!( + all_requests, + vec![], + "a request whose store failed is not reported" + ); assert!( managed.dashpay().incoming_contact_requests().is_empty(), "a request that never reached disk must not be tracked in memory, or the \ @@ -5153,6 +5196,49 @@ mod sweep_tests { ); } + /// The sent ingest reports what it recorded: a new request, not one whose + /// store failed nor one already tracked. + #[test] + fn should_report_only_what_a_sent_ingest_recorded() { + let our = 1u8; + let our_id = Identifier::from([our; 32]); + let mut info = info_with_bare_identity(our); + let managed = info + .identity_manager + .managed_identity_mut(&our_id) + .expect("managed identity"); + let request = test_request(our, 2, 0); + + let mut recorded = Vec::new(); + assert!(!ingest_sent_requests( + managed, + &failing_persister(), + our_id, + newest_sent_per_recipient([request.clone()]), + &mut recorded, + )); + assert_eq!(recorded, vec![], "a request whose store failed"); + + assert!(ingest_sent_requests( + managed, + &noop_persister(), + our_id, + newest_sent_per_recipient([request.clone()]), + &mut recorded, + )); + assert_eq!(recorded, vec![request.clone()], "a new request"); + + let mut again = Vec::new(); + assert!(ingest_sent_requests( + managed, + &noop_persister(), + our_id, + newest_sent_per_recipient([request]), + &mut again, + )); + assert_eq!(again, vec![], "a request already tracked"); + } + /// Branch 3, the fresh sent request (`add_sent_contact_request`). Its /// same-reference no-op guard returns `Ok(())`, so a memory-committed /// failed write makes the retry report success without storing anything. @@ -5169,7 +5255,13 @@ mod sweep_tests { // Sweep 1: the write fails. let newest = newest_sent_per_recipient([test_request(our, 2, 0)]); assert!( - !ingest_sent_requests(managed, &failing_persister(), our_id, newest), + !ingest_sent_requests( + managed, + &failing_persister(), + our_id, + newest, + &mut Vec::new() + ), "precondition: the failed write must report the pass incomplete" ); assert!( @@ -5182,7 +5274,7 @@ mod sweep_tests { let (persister, backend) = counting_persister(); let newest = newest_sent_per_recipient([test_request(our, 2, 0)]); assert!( - ingest_sent_requests(managed, &persister, our_id, newest), + ingest_sent_requests(managed, &persister, our_id, newest, &mut Vec::new()), "the retry must complete the pass" ); diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/private_contact_requests.rs b/packages/rs-platform-wallet/src/wallet/identity/network/private_contact_requests.rs index cea0c725648..2ecc2ceb8a9 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/private_contact_requests.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/private_contact_requests.rs @@ -99,7 +99,10 @@ impl PrivateDocumentKeyPaths { pub struct PrivateContactSyncReport { /// The store pages read. pub pages_read: usize, - /// The requests ingested, received and sent, as the wallet records them. + /// The requests this pass recorded, received and sent, as the wallet + /// records them: new ones and rotations. Not one already tracked, an + /// ignored sender's, a replay older than the tracked one, or one whose + /// store failed. pub requests: Vec, /// Entries that opened for one of the wallet's identities but were /// refused. @@ -885,9 +888,6 @@ impl DashPayView<'_, B> { sent.push(record.clone()); } } - report.requests.extend(received.iter().cloned()); - report.requests.extend(sent.iter().cloned()); - let mut rotated = Vec::new(); let mut ingested = Vec::new(); let received_ok = ingest_received_requests( @@ -903,7 +903,9 @@ impl DashPayView<'_, B> { &self.persister, identity_id, newest_sent_per_recipient(sent), + &mut ingested, ); + report.requests.extend(ingested); // The address of the request tracked from each contact, // once ingest decided which that is. for (record, shielded_address) in &addresses { @@ -1689,6 +1691,86 @@ mod tests { .await; } + /// A sync reports the requests it recorded: an ignored sender's request is + /// opened but not recorded, so not reported; once the sender is no longer + /// ignored, the store is read again and the request is. + #[tokio::test] + async fn should_report_only_the_requests_a_sync_recorded() { + let alice = identity(ALICE_PHRASE, ALICE); + let bob = identity(BOB_PHRASE, BOB); + let alice_crypto = SeedCryptoProvider::from_seed(seed(ALICE_PHRASE), Network::Testnet); + let bob_crypto = SeedCryptoProvider::from_seed(seed(BOB_PHRASE), Network::Testnet); + let alice_signer = signer(ALICE_PHRASE, &alice); + let (_manager, alice_wallet) = wallet(ALICE_PHRASE, &alice, &[&alice, &bob], vec![]).await; + let kept = Arc::new(Mutex::new(Vec::new())); + alice_wallet + .identity() + .dashpay() + .send_private_contact_request( + &alice.id(), + &bob.id(), + None, + AutoAcceptProofSource::None, + None, + &alice_signer, + &alice_crypto, + append_entry(&alice_wallet, 0, vec![], &kept), + ) + .await + .expect("Alice sends the request"); + let entry = kept.lock().expect("kept entries").remove(0); + + let (_bob_manager, bob_wallet) = + wallet(BOB_PHRASE, &bob, &[&alice, &bob], vec![entry.to_vec()]).await; + let set_ignored = |ignored: bool| { + let bob_wallet = Arc::clone(&bob_wallet); + let alice_id = alice.id(); + let bob_id = bob.id(); + async move { + let iw = bob_wallet.identity(); + let mut wm = iw.wallet_manager.write().await; + let managed = wm + .get_wallet_info_mut(&bob_wallet.wallet_id()) + .expect("wallet info") + .identity_manager + .managed_identity_mut(&bob_id) + .expect("Bob"); + if ignored { + managed.ignore_sender(&alice_id); + } else { + managed.unignore_sender(&alice_id); + } + } + }; + + set_ignored(true).await; + let report = bob_wallet + .identity() + .dashpay() + .sync_private_contact_requests(&bob_crypto, 4) + .await + .expect("Bob reads the store"); + assert_eq!(report.requests, vec![], "an ignored sender's request"); + assert_eq!(report.rejected, vec![]); + + set_ignored(false).await; + let report = bob_wallet + .identity() + .dashpay() + .sync_private_contact_requests(&bob_crypto, 4) + .await + .expect("Bob reads the store again"); + assert_eq!(report.requests.len(), 1, "{:?}", report.requests); + assert_eq!(report.requests[0].sender_id, alice.id()); + managed(&bob_wallet, BOB, |managed| { + assert_eq!( + managed.dashpay().incoming_request(&alice.id()), + Some(&report.requests[0]) + ); + }) + .await; + } + /// One private request to a recipient is in flight at a time: one paid /// for but not read back, or whose payment is unconfirmed, keeps the next /// from being sent, until a sync reads it back; one whose payment failed @@ -1788,6 +1870,24 @@ mod tests { assert!(managed.dashpay().private_reply_pending(&bob.id())); }) .await; + // A public request now would publish the pair, whose private one may + // already be on chain. + assert!(matches!( + alice_wallet + .identity() + .dashpay() + .send_contact_request_with_external_signer( + &alice.id(), + &bob.id(), + None, + AutoAcceptProofSource::None, + &alice_signer, + &alice_crypto, + ) + .await, + Err(PlatformWalletError::PrivateContactRequestPending(recipient)) + if recipient == bob.id() + )); } /// A cursor rewound under a pass is not advanced again that pass, so the From f83284e197f22107b29fa587a7ea7e6c402d3118 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 9 Nov 2026 02:01:27 +0700 Subject: [PATCH 017/117] fix(platform-wallet): tie each pending private request to its attempt, and restore it after a restart The reservation that keeps a second private request to a recipient from being paid for, and a public one from publishing the pair, had four gaps. - Attempt tokens: a reservation is a `PendingPrivateReply` owned by the attempt that made it. Only that attempt notes its commitment or releases it, so an attempt whose request was settled can no longer release the reservation of the next attempt to the same recipient. - Cancellation: the send holds the reservation in a guard that releases it when the send is dropped before it pays (synchronously when the wallet lock is free, otherwise on a spawned task). It is kept once payment may have started. - Settle only what was reconciled: the private sync settles a pending request only for a sent entry whose record it built and that the tracked request to the recipient now matches (by account reference, or by being newer), after both ingests succeeded. An entry whose recipient could not be read no longer settles it with nothing recorded. - Restart: reservations are in memory, but an unconfirmed payment survives a restart as the shielded store's redrive record. `PlatformWallet::restore_pending_private_contact_requests` reads the type-29 transitions to DashPay's store from those records and reserves each recipient again under its entry's commitment, so a sync settles it. The private payment runs the same check right before it spends, and refuses with `PrivateContactRequestPending` when an unresolved request between the same pair is found, leaving the recipient reserved for that one. A rotation still in flight is not restored. Co-Authored-By: Claude Opus 5.5 --- .../identity/network/contact_requests.rs | 6 +- .../network/private_contact_requests.rs | 724 +++++++++++++++++- .../managed_identity/contact_requests.rs | 170 +++- .../state/managed_identity/dashpay.rs | 28 +- .../src/wallet/platform_wallet.rs | 249 +++++- 5 files changed, 1119 insertions(+), 58 deletions(-) diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs b/packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs index 8c3d4c8ca8c..3663ed6f97f 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs @@ -572,7 +572,9 @@ impl DashPayView<'_, B> { /// be on chain. The refusal reads what the wallet tracks, so on a restored /// or second device it holds once the private sync /// (`sync_private_contact_requests`) has read the store. A pending request - /// is tracked in memory only: after a restart, only that read covers it. + /// is tracked in memory only: after a restart it holds once the host + /// restores it (`PlatformWallet::restore_pending_private_contact_requests`) + /// or that read reads it back. #[allow(clippy::type_complexity)] pub async fn send_contact_request_with_external_signer( &self, @@ -1212,7 +1214,7 @@ fn record_and_collapse_sent_requests( /// pair: by its account reference's version, which a rotation bumps, then by /// its creation time. A replay of an older request appended again later has a /// newer time but an older version. -fn predates(candidate: &ContactRequest, tracked: &ContactRequest) -> bool { +pub(super) fn predates(candidate: &ContactRequest, tracked: &ContactRequest) -> bool { freshness(candidate) < freshness(tracked) } diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/private_contact_requests.rs b/packages/rs-platform-wallet/src/wallet/identity/network/private_contact_requests.rs index 2ecc2ceb8a9..b4ef78ca141 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/private_contact_requests.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/private_contact_requests.rs @@ -28,6 +28,7 @@ use std::collections::{BTreeMap, BTreeSet}; use std::future::Future; +use std::sync::Arc; use dash_sdk::dapi_grpc::platform::v0::ResponseMetadata; use dash_sdk::platform::dashpay::{ @@ -37,7 +38,7 @@ use dash_sdk::platform::dashpay::{ ProvedIdentity, RejectedPrivateContactRequest, PRIVATE_CONTACT_REQUEST_DOCUMENT_TYPE_NAME, }; use dash_sdk::platform::private_document_entries::{ - PrivateDocumentEntries, PrivateDocumentEntriesQuery, + PrivateDocumentEntries, PrivateDocumentEntriesQuery, PrivateDocumentEntry, }; use dash_sdk::platform::private_document_scan::PrivateDocumentScanKeys; use dash_sdk::platform::Fetch; @@ -53,12 +54,14 @@ use dpp::secp256k1::rand::rngs::StdRng; use dpp::secp256k1::rand::{RngCore, SeedableRng}; use dpp::secp256k1::{PublicKey, SecretKey}; use key_wallet::bip32::{DerivationPath, KeyDerivationType}; +use key_wallet_manager::WalletManager; use platform_encryption::{encrypt_account_label, encrypt_extended_public_key, COMPACT_XPUB_LEN}; +use tokio::sync::RwLock; use zeroize::Zeroize; use super::contact_requests::{ ingest_received_requests, ingest_sent_requests, newest_received_per_sender, - newest_sent_per_recipient, tear_down_stale_external_accounts, AutoAcceptProofSource, + newest_sent_per_recipient, predates, tear_down_stale_external_accounts, AutoAcceptProofSource, ContactCryptoProvider, PreparedContactRequest, }; use super::signing_key::first_available; @@ -68,6 +71,7 @@ use crate::error::PlatformWalletError; use crate::wallet::identity::types::dashpay::contact_request::ContactRequest; use crate::wallet::identity::types::dashpay::established_contact::EstablishedContact; use crate::wallet::identity::ManagedIdentity; +use crate::wallet::platform_wallet::{PlatformWalletInfo, WalletId}; /// The paths of the keys a wallet's identities read DashPay's private contact /// request store with, for a [`ContactCryptoProvider`] to derive inside the @@ -154,6 +158,92 @@ impl ScannedIdentity { } } +/// The reservation one send attempt holds on the private request to a +/// recipient that may be in flight. Dropped while armed, it releases the +/// reservation: a send cancelled before it pays leaves nothing pending, which +/// no sync could settle. It is disarmed once payment may have started. +struct PrivateReplyReservation { + wallet_manager: Arc>>, + wallet_id: WalletId, + sender: Identifier, + recipient: Identifier, + attempt: u64, + armed: bool, +} + +impl PrivateReplyReservation { + /// Leave the reservation for the private sync to settle. + fn keep(&mut self) { + self.armed = false; + } + + /// Release the reservation now, kept or not. Dropped while it waits for + /// the wallet lock, the guard releases it. + async fn release(mut self) { + self.armed = true; + let mut wm = self.wallet_manager.write().await; + release_reservation( + &mut wm, + &self.wallet_id, + &self.sender, + &self.recipient, + self.attempt, + ); + self.armed = false; + } +} + +impl Drop for PrivateReplyReservation { + fn drop(&mut self) { + if !self.armed { + return; + } + if let Ok(mut wm) = self.wallet_manager.try_write() { + release_reservation( + &mut wm, + &self.wallet_id, + &self.sender, + &self.recipient, + self.attempt, + ); + return; + } + let Ok(runtime) = tokio::runtime::Handle::try_current() else { + tracing::warn!( + sender = %self.sender, + recipient = %self.recipient, + "a cancelled private contact request could not release its reservation; it stays \ + until restart" + ); + return; + }; + let wallet_manager = Arc::clone(&self.wallet_manager); + let (wallet_id, sender, recipient, attempt) = + (self.wallet_id, self.sender, self.recipient, self.attempt); + runtime.spawn(async move { + let mut wm = wallet_manager.write().await; + release_reservation(&mut wm, &wallet_id, &sender, &recipient, attempt); + }); + } +} + +/// Release the reservation `attempt` holds on `sender`'s private request to +/// `recipient`. +fn release_reservation( + wm: &mut WalletManager, + wallet_id: &WalletId, + sender: &Identifier, + recipient: &Identifier, + attempt: u64, +) { + if let Some(managed) = wm + .get_wallet_info_mut(wallet_id) + .and_then(|info| info.identity_manager.managed_identity_mut(sender)) + { + managed.release_private_reply(recipient, attempt); + } +} + /// The local record of a private request: a [`ContactRequest`] carrying the /// request's compact xpub and label encrypted under `shared_key`, the pair's /// DIP-15 shared key, as a `contactRequest` document carries them. The IVs are @@ -232,7 +322,8 @@ impl DashPayView<'_, B> { /// One private request to a recipient is in flight at a time: it is /// reserved before sealing, and while it is pending another is refused /// with [`PlatformWalletError::PrivateContactRequestPending`], as the store - /// keeps every entry appended and a second payment could execute too. + /// keeps every entry appended and a second payment could execute too. A + /// public request to the recipient is refused the same way. /// /// The proof `pay` waits for shows the notes spent, not the entry /// appended, so a paid request is recorded only once read back: a private @@ -241,7 +332,11 @@ impl DashPayView<'_, B> { /// read back yet, the send returns `PrivateContactRequestPending` and the /// request stays pending until a later sync reads it. An unconfirmed /// payment stays pending the same way; one that definitely failed - /// releases it. The pending request is kept in memory only. + /// releases it, and so does a send dropped before it pays. `pay` itself + /// may return `PrivateContactRequestPending` to keep the request pending + /// unpaid. The pending request is kept in memory only; after a restart, + /// [`Self::restore_pending_private_contact_requests`] restores those whose + /// payment is unconfirmed. #[allow(clippy::too_many_arguments)] pub async fn send_private_contact_request( &self, @@ -260,16 +355,22 @@ impl DashPayView<'_, B> { F: FnOnce(BinaryData) -> Fut + Send, Fut: Future> + Send, { - let reserved = self + let attempt = self .with_managed(sender_identity_id, |managed| { managed.reserve_private_reply(*recipient_identity_id) }) - .await?; - if !reserved { - return Err(PlatformWalletError::PrivateContactRequestPending( + .await? + .ok_or(PlatformWalletError::PrivateContactRequestPending( *recipient_identity_id, - )); - } + ))?; + let mut reservation = PrivateReplyReservation { + wallet_manager: Arc::clone(&self.wallet_manager), + wallet_id: self.wallet_id, + sender: *sender_identity_id, + recipient: *recipient_identity_id, + attempt, + armed: true, + }; let sent = self .send_reserved_private_contact_request( sender_identity_id, @@ -279,6 +380,7 @@ impl DashPayView<'_, B> { shielded_address, signer, crypto, + &mut reservation, pay, ) .await; @@ -288,11 +390,10 @@ impl DashPayView<'_, B> { Err(PlatformWalletError::PrivateContactRequestPending(_) | PlatformWalletError::ShieldedSpendUnconfirmed { .. }) ); - if !keep_pending { - self.with_managed(sender_identity_id, |managed| { - managed.release_private_reply(recipient_identity_id) - }) - .await?; + if keep_pending { + reservation.keep(); + } else { + reservation.release().await; } sent } @@ -314,7 +415,8 @@ impl DashPayView<'_, B> { } /// The body of [`Self::send_private_contact_request`], once the request - /// to the recipient is reserved. + /// to the recipient is reserved by `reservation`, which it keeps right + /// before paying. #[allow(clippy::too_many_arguments)] async fn send_reserved_private_contact_request( &self, @@ -325,6 +427,7 @@ impl DashPayView<'_, B> { shielded_address: Option<[u8; 43]>, signer: &S, crypto: &C, + reservation: &mut PrivateReplyReservation, pay: F, ) -> Result where @@ -432,7 +535,11 @@ impl DashPayView<'_, B> { ) })?; self.with_managed(sender_identity_id, |managed| { - managed.note_private_reply_commitment(*recipient_identity_id, commitment) + managed.note_private_reply_commitment( + *recipient_identity_id, + reservation.attempt, + commitment, + ) }) .await?; // What the record read back must carry, computed before paying. @@ -450,6 +557,9 @@ impl DashPayView<'_, B> { 0, ); + // Once payment may have started, the reservation is the sync's to + // settle, whatever happens to this send. + reservation.keep(); pay(entry).await?; // Recorded only as the store proves it: read it back. @@ -766,6 +876,9 @@ impl DashPayView<'_, B> { // The wallet's records of the page's requests, under the shared // keys the signer computes. let mut records: Vec<(Identifier, ContactRequest, bool, Option<[u8; 43]>)> = Vec::new(); + // The entry commitment of each sent request's record: a pending + // request is settled by it once that record is reconciled. + let mut sent_commitments: Vec<([u8; 32], ContactRequest)> = Vec::new(); for received in &read.received { let Some(sender) = proved_identities .get(&received.sender_id) @@ -840,6 +953,7 @@ impl DashPayView<'_, B> { &shared_secret, sent.block_time_ms, ); + sent_commitments.push((sent.commitment, record.clone())); records.push((sent.sender_id, record, false, None)); } for rejected in &read.rejected { @@ -912,14 +1026,26 @@ impl DashPayView<'_, B> { managed.note_private_shielded_address(record, *shielded_address); } if received_ok && sent_ok { - // A pending private request read back and recorded is - // settled. - for sent in read - .sent + // A pending private request is settled once its + // entry's record is reconciled: the request tracked + // to the recipient is that record, carries its + // account reference (another copy of it), or is + // newer. An entry whose record was not built, its + // recipient unreadable, settles nothing. + for (commitment, record) in sent_commitments .iter() - .filter(|sent| sent.sender_id == identity_id) + .filter(|(_, record)| record.sender_id == identity_id) { - managed.settle_private_reply(&sent.recipient_id, &sent.commitment); + let reconciled = managed + .dashpay() + .outgoing_request(&record.recipient_id) + .is_some_and(|tracked| { + tracked.account_reference == record.account_reference + || predates(record, tracked) + }); + if reconciled { + managed.settle_private_reply(&record.recipient_id, commitment); + } } identity.advance(managed, read.next_position); } else if !identity.failed { @@ -950,6 +1076,134 @@ impl DashPayView<'_, B> { } Ok(report) } + + /// Restore, after a restart, the pending private requests whose payment + /// is unconfirmed: `entries` are those of the signed transitions the + /// shielded store keeps for unconfirmed spends to DashPay's + /// `privateContactRequest` store + /// (`PlatformWallet::restore_pending_private_contact_requests` reads + /// them). Each opens with the keys of the wallet's identities, and every + /// one an identity sent reserves its recipient again under the entry's + /// commitment, so no second request to it is sent, privately or + /// publicly, until a private sync reads the entry back and settles it. + /// A recipient already reserved keeps its reservation, which gets the + /// entry's commitment only when it has none yet. Returns the (sender, + /// recipient) pairs restored. + /// + /// What it does not cover: + /// - An entry to a recipient the sender already tracks a request to is + /// left alone: it was recorded, or it is a rotation still in flight, + /// which is not restored. + /// - A reservation whose payment then fails for good stays until the + /// next restart, as one kept in this process does. + pub async fn restore_pending_private_contact_requests( + &self, + entries: &[BinaryData], + crypto: &C, + ) -> Result, PlatformWalletError> + where + C: ContactCryptoProvider + Sync, + { + self.restore_private_reservations(entries, crypto, None) + .await + } + + /// Whether a request from `sender` to `recipient` among `entries`, paid + /// for before a restart and unconfirmed, may still execute: then the + /// recipient stays reserved for that entry instead of the one about to + /// be paid, which must not be. The other entries are restored as + /// [`Self::restore_pending_private_contact_requests`] restores them. + #[cfg(any(feature = "shielded", test))] + pub(crate) async fn hold_for_restored_private_contact_request( + &self, + entries: &[BinaryData], + crypto: &C, + sender: &Identifier, + recipient: &Identifier, + ) -> Result + where + C: ContactCryptoProvider + Sync, + { + let pair = (*sender, *recipient); + Ok(self + .restore_private_reservations(entries, crypto, Some(pair)) + .await? + .contains(&pair)) + } + + /// Open `entries` as one page of the store and reserve the recipient of + /// each the wallet's identities sent and do not track yet; the pair + /// `paying` is retargeted to its restored entry instead. + async fn restore_private_reservations( + &self, + entries: &[BinaryData], + crypto: &C, + paying: Option<(Identifier, Identifier)>, + ) -> Result, PlatformWalletError> + where + C: ContactCryptoProvider + Sync, + { + if entries.is_empty() { + return Ok(Vec::new()); + } + let (_, paths) = self.private_scan_identities().await?; + if paths.senders.is_empty() { + return Ok(Vec::new()); + } + let contract = dashpay_contract()?; + let page = PrivateDocumentEntries { + entries: entries + .iter() + .zip(0u64..) + .map(|(data, position)| PrivateDocumentEntry { + position, + block_height: 0, + block_time_ms: 0, + data: data.to_vec(), + }) + .collect(), + total_count: entries.len() as u64, + }; + let scan = crypto + .scan_private_document_page( + &paths, + contract.id(), + PRIVATE_CONTACT_REQUEST_DOCUMENT_TYPE_NAME, + 0, + &page, + ) + .await?; + + let mut restored = Vec::new(); + let mut wm = self.wallet_manager.write().await; + let info = wm + .get_wallet_info_mut(&self.wallet_id) + .ok_or_else(|| PlatformWalletError::WalletNotFound(hex::encode(self.wallet_id)))?; + for sent in scan.sent { + let sender = sent.sender_identity_id; + // The wallet's own entries name the identity that sealed them. + if Identifier::from(*sent.entry.sender_identity_id()) != sender { + continue; + } + let recipient = Identifier::from(*sent.entry.recipient_identity_id()); + let Some(managed) = info.identity_manager.managed_identity_mut(&sender) else { + continue; + }; + if managed.dashpay().outgoing_request(&recipient).is_some() { + continue; + } + let commitment = *sent.entry.commitment(); + if paying == Some((sender, recipient)) { + managed.retarget_private_reply(recipient, commitment); + } else { + managed.restore_private_reply(recipient, commitment); + } + if !restored.contains(&(sender, recipient)) { + restored.push((sender, recipient)); + } + } + Ok(restored) + } } #[cfg(test)] @@ -966,17 +1220,21 @@ mod tests { use dash_sdk::error::ContextProviderError; use dash_sdk::platform::private_document_entries::PrivateDocumentEntry; use dash_sdk::platform::ContextProvider; + use dpp::address_funds::AddressWitness; use dpp::data_contract::{DataContract, TokenConfiguration}; use dpp::identity::identity_public_key::v0::IdentityPublicKeyV0; use dpp::identity::v0::IdentityV0; use dpp::identity::{IdentityPublicKey, SecurityLevel}; use dpp::version::PlatformVersion; + use dpp::ProtocolError; use key_wallet::account::account_collection::DashpayAccountKey; use key_wallet::mnemonic::Mnemonic; use key_wallet::wallet::initialization::WalletAccountCreationOptions; use key_wallet::Network; use platform_encryption::{decrypt_account_label, decrypt_extended_public_key}; - use std::sync::{Arc, Mutex}; + use std::sync::atomic::{AtomicBool, Ordering}; + use std::sync::Mutex; + use std::time::Duration; const ALICE_PHRASE: &str = "abandon abandon abandon abandon abandon abandon abandon abandon \ abandon abandon abandon about"; @@ -1890,6 +2148,424 @@ mod tests { )); } + /// A signer whose signatures never come, as a host's prompt the user never + /// answers: it notes that it was asked. + #[derive(Debug)] + struct StalledSigner { + inner: simple_signer::signer::SimpleSigner, + asked: Arc, + } + + #[async_trait::async_trait] + impl Signer for StalledSigner { + async fn sign( + &self, + _key: &IdentityPublicKey, + _data: &[u8], + ) -> Result { + self.asked.store(true, Ordering::SeqCst); + std::future::pending().await + } + + async fn sign_create_witness( + &self, + key: &IdentityPublicKey, + data: &[u8], + ) -> Result { + self.inner.sign_create_witness(key, data).await + } + + fn can_sign_with(&self, key: &IdentityPublicKey) -> bool { + self.inner.can_sign_with(key) + } + } + + /// From now on, `wallet`'s mock SDK answers a fetch of `identity` with it + /// when `known`, and fails it otherwise. + async fn set_identity_known(wallet: &PlatformWallet, identity: &Identity, known: bool) { + let mut sdk = (*wallet.identity().sdk).clone(); + let mut mock = sdk.mock(); + mock.remove_fetch_expectation::(identity.id()) + .await; + if known { + mock.expect_fetch(identity.id(), Some(identity.clone())) + .await + .expect("identity expectation"); + } + } + + /// Whether `wallet`'s identity `identity_id` holds a private request to + /// `recipient` pending. + async fn pending_to( + wallet: &PlatformWallet, + identity_id: [u8; 32], + recipient: &Identity, + ) -> bool { + managed(wallet, identity_id, |managed| { + managed.dashpay().private_reply_pending(&recipient.id()) + }) + .await + } + + /// A send dropped before it pays, here while its signer never answers, + /// leaves no reservation behind: nothing was paid, and no sync could + /// settle one. The recipient is free for the next send. + #[tokio::test] + async fn should_release_the_reservation_of_a_send_dropped_before_it_pays() { + let alice = identity(ALICE_PHRASE, ALICE); + let bob = identity(BOB_PHRASE, BOB); + let alice_crypto = SeedCryptoProvider::from_seed(seed(ALICE_PHRASE), Network::Testnet); + let (_manager, alice_wallet) = wallet(ALICE_PHRASE, &alice, &[&alice, &bob], vec![]).await; + let view = alice_wallet.identity().dashpay(); + let asked = Arc::new(AtomicBool::new(false)); + let stalled = StalledSigner { + inner: signer(ALICE_PHRASE, &alice), + asked: Arc::clone(&asked), + }; + let paid = Arc::new(Mutex::new(Vec::new())); + + let cancelled = tokio::time::timeout( + Duration::from_millis(200), + view.send_private_contact_request( + &alice.id(), + &bob.id(), + None, + AutoAcceptProofSource::None, + None, + &stalled, + &alice_crypto, + keep_entry(&paid), + ), + ) + .await; + assert!(cancelled.is_err(), "the send never completes"); + assert!( + asked.load(Ordering::SeqCst), + "it was dropped while signing, so after reserving" + ); + assert!(paid.lock().expect("paid").is_empty(), "nothing is paid"); + assert!(!pending_to(&alice_wallet, ALICE, &bob).await); + + let kept = Arc::new(Mutex::new(Vec::new())); + let next = view + .send_private_contact_request( + &alice.id(), + &bob.id(), + None, + AutoAcceptProofSource::None, + None, + &signer(ALICE_PHRASE, &alice), + &alice_crypto, + keep_entry(&kept), + ) + .await; + assert!( + matches!( + next, + Err(PlatformWalletError::PrivateContactRequestPending(_)) + ), + "{next:?}" + ); + assert_eq!(kept.lock().expect("kept").len(), 1, "the next send pays"); + } + + /// A sync settles a pending request only once it records the entry: one + /// whose recipient cannot be read leaves no record, so the request stays + /// pending, and it settles on a later sync that reads the recipient. + #[tokio::test] + async fn should_settle_a_pending_request_only_once_its_entry_is_recorded() { + let alice = identity(ALICE_PHRASE, ALICE); + let bob = identity(BOB_PHRASE, BOB); + let alice_crypto = SeedCryptoProvider::from_seed(seed(ALICE_PHRASE), Network::Testnet); + let alice_signer = signer(ALICE_PHRASE, &alice); + let (_manager, alice_wallet) = wallet(ALICE_PHRASE, &alice, &[&alice, &bob], vec![]).await; + let view = alice_wallet.identity().dashpay(); + let kept = Arc::new(Mutex::new(Vec::new())); + let sent = view + .send_private_contact_request( + &alice.id(), + &bob.id(), + None, + AutoAcceptProofSource::None, + None, + &alice_signer, + &alice_crypto, + keep_entry(&kept), + ) + .await; + assert!( + matches!( + sent, + Err(PlatformWalletError::PrivateContactRequestPending(_)) + ), + "{sent:?}" + ); + let entry = kept.lock().expect("kept").remove(0); + publish(&alice_wallet, 0, vec![entry.to_vec()]).await; + + set_identity_known(&alice_wallet, &bob, false).await; + let report = view + .sync_private_contact_requests(&alice_crypto, 4) + .await + .expect("Alice reads the store"); + assert_eq!(report.requests, vec![]); + assert_eq!(report.unpersisted_identities, vec![alice.id()]); + managed(&alice_wallet, ALICE, |managed| { + assert!(managed.dashpay().private_reply_pending(&bob.id())); + assert!(managed.dashpay().outgoing_request(&bob.id()).is_none()); + }) + .await; + + set_identity_known(&alice_wallet, &bob, true).await; + let report = view + .sync_private_contact_requests(&alice_crypto, 4) + .await + .expect("Alice reads the store again"); + assert_eq!(report.requests.len(), 1, "{:?}", report.requests); + managed(&alice_wallet, ALICE, |managed| { + assert!(!managed.dashpay().private_reply_pending(&bob.id())); + assert_eq!( + managed.dashpay().outgoing_request(&bob.id()), + Some(&report.requests[0]) + ); + }) + .await; + } + + /// A pending request whose entry is read back after a newer request to + /// the recipient is tracked, here as another device's rotation would be, + /// is settled: its entry executed, and the newer request stays tracked. + #[tokio::test] + async fn should_settle_a_pending_request_read_back_after_a_newer_one() { + let alice = identity(ALICE_PHRASE, ALICE); + let bob = identity(BOB_PHRASE, BOB); + let alice_crypto = SeedCryptoProvider::from_seed(seed(ALICE_PHRASE), Network::Testnet); + let alice_signer = signer(ALICE_PHRASE, &alice); + let (_manager, alice_wallet) = wallet(ALICE_PHRASE, &alice, &[&alice, &bob], vec![]).await; + let view = alice_wallet.identity().dashpay(); + let kept = Arc::new(Mutex::new(Vec::new())); + let sent = view + .send_private_contact_request( + &alice.id(), + &bob.id(), + None, + AutoAcceptProofSource::None, + None, + &alice_signer, + &alice_crypto, + keep_entry(&kept), + ) + .await; + assert!( + matches!( + sent, + Err(PlatformWalletError::PrivateContactRequestPending(_)) + ), + "{sent:?}" + ); + let entry = kept.lock().expect("kept").remove(0); + + let mut newer = public_request(&alice, &bob, (1 << 28) | 5); + newer.core_height_created_at = 0; + { + let iw = alice_wallet.identity(); + let mut wm = iw.wallet_manager.write().await; + wm.get_wallet_info_mut(&alice_wallet.wallet_id()) + .expect("wallet info") + .identity_manager + .managed_identity_mut(&alice.id()) + .expect("Alice") + .add_sent_contact_request(newer.clone(), &iw.persister) + .expect("the newer request"); + } + publish(&alice_wallet, 0, vec![entry.to_vec()]).await; + view.sync_private_contact_requests(&alice_crypto, 4) + .await + .expect("Alice reads the store"); + managed(&alice_wallet, ALICE, |managed| { + assert!(!managed.dashpay().private_reply_pending(&bob.id())); + assert_eq!(managed.dashpay().outgoing_request(&bob.id()), Some(&newer)); + }) + .await; + } + + /// A request paid for and unconfirmed before a restart is restored from + /// its entry: until a sync reads the entry back, neither a second private + /// request nor a public one goes to the recipient. Once recorded, the + /// entry restores nothing. + #[tokio::test] + async fn should_restore_a_pending_request_paid_for_before_a_restart() { + let alice = identity(ALICE_PHRASE, ALICE); + let bob = identity(BOB_PHRASE, BOB); + let alice_crypto = SeedCryptoProvider::from_seed(seed(ALICE_PHRASE), Network::Testnet); + let alice_signer = signer(ALICE_PHRASE, &alice); + let (_manager, before) = wallet(ALICE_PHRASE, &alice, &[&alice, &bob], vec![]).await; + let kept = Arc::new(Mutex::new(Vec::new())); + before + .identity() + .dashpay() + .send_private_contact_request( + &alice.id(), + &bob.id(), + None, + AutoAcceptProofSource::None, + None, + &alice_signer, + &alice_crypto, + keep_entry(&kept), + ) + .await + .expect_err("not read back"); + let entry = kept.lock().expect("kept").remove(0); + + let (_restarted_manager, restarted) = + wallet(ALICE_PHRASE, &alice, &[&alice, &bob], vec![]).await; + let view = restarted.identity().dashpay(); + assert!(!pending_to(&restarted, ALICE, &bob).await); + let restored = view + .restore_pending_private_contact_requests(std::slice::from_ref(&entry), &alice_crypto) + .await + .expect("restored"); + assert_eq!(restored, vec![(alice.id(), bob.id())]); + assert!(pending_to(&restarted, ALICE, &bob).await); + + let paid = Arc::new(Mutex::new(Vec::new())); + let second = view + .send_private_contact_request( + &alice.id(), + &bob.id(), + None, + AutoAcceptProofSource::None, + None, + &alice_signer, + &alice_crypto, + keep_entry(&paid), + ) + .await; + assert!( + matches!(second, Err(PlatformWalletError::PrivateContactRequestPending(recipient)) + if recipient == bob.id()), + "{second:?}" + ); + assert!( + paid.lock().expect("paid").is_empty(), + "nothing is paid twice" + ); + let public = view + .send_contact_request_with_external_signer( + &alice.id(), + &bob.id(), + None, + AutoAcceptProofSource::None, + &alice_signer, + &alice_crypto, + ) + .await; + assert!( + matches!(public, Err(PlatformWalletError::PrivateContactRequestPending(recipient)) + if recipient == bob.id()), + "{public:?}" + ); + + publish(&restarted, 0, vec![entry.to_vec()]).await; + view.sync_private_contact_requests(&alice_crypto, 4) + .await + .expect("the restarted wallet reads the store"); + managed(&restarted, ALICE, |managed| { + assert!(!managed.dashpay().private_reply_pending(&bob.id())); + assert!(managed.dashpay().outgoing_request(&bob.id()).is_some()); + }) + .await; + assert_eq!( + view.restore_pending_private_contact_requests(&[entry], &alice_crypto) + .await + .expect("restored"), + vec![], + "a recorded entry is not restored" + ); + } + + /// A send whose payment finds a request to the same recipient paid for + /// and unconfirmed before a restart pays nothing: the recipient stays + /// reserved for that request, which a sync settles once it reads it back. + #[tokio::test] + async fn should_hold_a_send_for_a_request_paid_for_before_a_restart() { + let alice = identity(ALICE_PHRASE, ALICE); + let bob = identity(BOB_PHRASE, BOB); + let (alice_id, bob_id) = (alice.id(), bob.id()); + let alice_crypto = SeedCryptoProvider::from_seed(seed(ALICE_PHRASE), Network::Testnet); + let alice_signer = signer(ALICE_PHRASE, &alice); + let (_manager, before) = wallet(ALICE_PHRASE, &alice, &[&alice, &bob], vec![]).await; + let kept = Arc::new(Mutex::new(Vec::new())); + before + .identity() + .dashpay() + .send_private_contact_request( + &alice_id, + &bob_id, + None, + AutoAcceptProofSource::None, + None, + &alice_signer, + &alice_crypto, + keep_entry(&kept), + ) + .await + .expect_err("not read back"); + let unconfirmed = vec![kept.lock().expect("kept").remove(0)]; + + // The host restores nothing at startup; the payment checks. + let (_restarted_manager, restarted) = + wallet(ALICE_PHRASE, &alice, &[&alice, &bob], vec![]).await; + let view = restarted.identity().dashpay(); + let paid = Arc::new(Mutex::new(Vec::new())); + let held = view + .send_private_contact_request( + &alice_id, + &bob_id, + None, + AutoAcceptProofSource::None, + None, + &alice_signer, + &alice_crypto, + |entry: BinaryData| { + let paid = Arc::clone(&paid); + let unconfirmed = &unconfirmed; + let crypto = &alice_crypto; + async move { + if view + .hold_for_restored_private_contact_request( + unconfirmed, + crypto, + &alice_id, + &bob_id, + ) + .await? + { + return Err(PlatformWalletError::PrivateContactRequestPending(bob_id)); + } + paid.lock().expect("paid").push(entry); + Ok(ResponseMetadata::default()) + } + }, + ) + .await; + assert!( + matches!(held, Err(PlatformWalletError::PrivateContactRequestPending(recipient)) + if recipient == bob_id), + "{held:?}" + ); + assert!(paid.lock().expect("paid").is_empty(), "nothing is paid"); + assert!(pending_to(&restarted, ALICE, &bob).await); + + // The restored entry, not the unpaid one, settles it. + publish(&restarted, 0, vec![unconfirmed[0].to_vec()]).await; + view.sync_private_contact_requests(&alice_crypto, 4) + .await + .expect("the restarted wallet reads the store"); + assert!(!pending_to(&restarted, ALICE, &bob).await); + } + /// A cursor rewound under a pass is not advanced again that pass, so the /// next reads from the rewind. #[tokio::test] diff --git a/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/contact_requests.rs b/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/contact_requests.rs index 47ac2a87bc9..589b4d60ec4 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/contact_requests.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/contact_requests.rs @@ -5,6 +5,7 @@ //! - Receiving contact requests (incoming) //! - Automatically establishing contacts when both parties send requests +use super::dashpay::PendingPrivateReply; use super::ManagedIdentity; use crate::changeset::{ ContactChangeSet, ContactRequestEntry, ReceivedContactRequestKey, SentContactRequestKey, @@ -841,45 +842,108 @@ impl ManagedIdentity { } } - /// Reserve the one private request to `recipient` that may be in flight; - /// `false` when one already is. See + /// Reserve the one private request to `recipient` that may be in flight, + /// returning the attempt that holds it; `None` when one already is. See /// [`DashPayState::private_reply_pending`](super::DashPayState::private_reply_pending). - pub(crate) fn reserve_private_reply(&mut self, recipient: Identifier) -> bool { - match self.dashpay.pending_private_replies.entry(recipient) { - std::collections::btree_map::Entry::Occupied(_) => false, - std::collections::btree_map::Entry::Vacant(entry) => { - entry.insert(None); - true - } + pub(crate) fn reserve_private_reply(&mut self, recipient: Identifier) -> Option { + if self + .dashpay + .pending_private_replies + .contains_key(&recipient) + { + return None; } + Some(self.insert_private_reply(recipient, None)) + } + + /// Reserve `recipient` under a new attempt, `commitment` its entry's. + fn insert_private_reply(&mut self, recipient: Identifier, commitment: Option<[u8; 32]>) -> u64 { + let attempt = self.dashpay.next_private_reply_attempt; + self.dashpay.next_private_reply_attempt = attempt.wrapping_add(1); + self.dashpay.pending_private_replies.insert( + recipient, + PendingPrivateReply { + attempt, + commitment, + }, + ); + attempt } - /// Note the commitment of the entry the pending request to `recipient` - /// was sealed as, which the private sync settles it by. + /// Note the commitment of the entry `attempt` sealed its request to + /// `recipient` as, which the private sync settles it by. Nothing when the + /// recipient is not reserved by that attempt. pub(crate) fn note_private_reply_commitment( &mut self, recipient: Identifier, + attempt: u64, commitment: [u8; 32], ) { - if let Some(pending) = self.dashpay.pending_private_replies.get_mut(&recipient) { - *pending = Some(commitment); + if let Some(pending) = self + .dashpay + .pending_private_replies + .get_mut(&recipient) + .filter(|pending| pending.attempt == attempt) + { + pending.commitment = Some(commitment); } } - /// Drop the pending request to `recipient`: it was never paid for, or its - /// payment definitely failed, or it was read back from the store. - pub(crate) fn release_private_reply(&mut self, recipient: &Identifier) { - self.dashpay.pending_private_replies.remove(recipient); + /// Drop the reservation `attempt` holds on `recipient`: its request was + /// never paid for, or its payment definitely failed. Nothing when another + /// attempt holds the recipient by now. + pub(crate) fn release_private_reply(&mut self, recipient: &Identifier, attempt: u64) { + if self + .dashpay + .pending_private_replies + .get(recipient) + .is_some_and(|pending| pending.attempt == attempt) + { + self.dashpay.pending_private_replies.remove(recipient); + } } /// Settle the pending request to `recipient` sealed as `commitment`: the /// private sync read that entry back and recorded it. pub(crate) fn settle_private_reply(&mut self, recipient: &Identifier, commitment: &[u8; 32]) { - if self.dashpay.pending_private_replies.get(recipient) == Some(&Some(*commitment)) { + if self + .dashpay + .pending_private_replies + .get(recipient) + .is_some_and(|pending| pending.commitment == Some(*commitment)) + { self.dashpay.pending_private_replies.remove(recipient); } } + /// Reserve `recipient` again for the entry sealed as `commitment`, whose + /// payment is unconfirmed since before a restart. A recipient already + /// reserved keeps its attempt, and gets the commitment only when it has + /// none yet. + pub(crate) fn restore_private_reply(&mut self, recipient: Identifier, commitment: [u8; 32]) { + match self.dashpay.pending_private_replies.get_mut(&recipient) { + Some(pending) => { + pending.commitment.get_or_insert(commitment); + } + None => { + self.insert_private_reply(recipient, Some(commitment)); + } + } + } + + /// Hold `recipient` for the entry sealed as `commitment`, whose payment is + /// unconfirmed since before a restart, instead of the one the attempt + /// reserving it sealed: that one is not paid for, so only this entry can + /// settle the reservation. + pub(crate) fn retarget_private_reply(&mut self, recipient: Identifier, commitment: [u8; 32]) { + match self.dashpay.pending_private_replies.get_mut(&recipient) { + Some(pending) => pending.commitment = Some(commitment), + None => { + self.insert_private_reply(recipient, Some(commitment)); + } + } + } + /// Sent-direction counterpart of [`Self::advance_high_water_received`]; /// same compare-and-advance semantics and caller contract. pub fn advance_high_water_sent(&mut self, snapshot: Option, max_fetched: Option) { @@ -2229,4 +2293,74 @@ mod tests { "replay un-ignore must not rewind the live cursor" ); } + + /// A reservation belongs to the attempt that made it: once attempt A's + /// request is read back and settled and B reserves the recipient, A's + /// late cleanup neither releases B's reservation nor notes a commitment + /// on it. + #[test] + fn should_keep_a_later_attempts_reservation_from_an_earlier_attempts_cleanup() { + let mut managed = create_test_identity([1u8; 32]); + let bob = Identifier::from([2u8; 32]); + + let a = managed.reserve_private_reply(bob).expect("A reserves"); + assert_eq!(managed.reserve_private_reply(bob), None, "one at a time"); + managed.note_private_reply_commitment(bob, a, [0xA; 32]); + managed.settle_private_reply(&bob, &[0xA; 32]); + assert!(!managed.dashpay().private_reply_pending(&bob)); + + let b = managed.reserve_private_reply(bob).expect("B reserves"); + assert_ne!(a, b); + managed.release_private_reply(&bob, a); + managed.note_private_reply_commitment(bob, a, [0xA; 32]); + assert!( + managed.dashpay().private_reply_pending(&bob), + "A's cleanup leaves B's reservation" + ); + managed.settle_private_reply(&bob, &[0xA; 32]); + assert!( + managed.dashpay().private_reply_pending(&bob), + "A's entry does not settle B's request" + ); + + managed.note_private_reply_commitment(bob, b, [0xB; 32]); + managed.settle_private_reply(&bob, &[0xB; 32]); + assert!(!managed.dashpay().private_reply_pending(&bob)); + } + + /// A reservation restored from an unconfirmed payment takes the entry's + /// commitment; one already held keeps its attempt and gets a commitment + /// only when it has none, unless it is retargeted to the restored entry. + #[test] + fn should_restore_a_reservation_without_taking_over_a_held_one() { + let mut managed = create_test_identity([1u8; 32]); + let bob = Identifier::from([2u8; 32]); + let carol = Identifier::from([3u8; 32]); + + managed.restore_private_reply(bob, [0xB; 32]); + assert!(managed.dashpay().private_reply_pending(&bob)); + assert_eq!(managed.reserve_private_reply(bob), None); + managed.settle_private_reply(&bob, &[0xB; 32]); + assert!(!managed.dashpay().private_reply_pending(&bob)); + + let held = managed.reserve_private_reply(carol).expect("Carol is free"); + managed.restore_private_reply(carol, [0xC; 32]); + managed.restore_private_reply(carol, [0xD; 32]); + managed.release_private_reply(&carol, held + 1); + assert!( + managed.dashpay().private_reply_pending(&carol), + "the attempt holding it is kept" + ); + managed.settle_private_reply(&carol, &[0xD; 32]); + assert!( + managed.dashpay().private_reply_pending(&carol), + "only a missing commitment is filled in" + ); + + managed.retarget_private_reply(carol, [0xD; 32]); + managed.settle_private_reply(&carol, &[0xC; 32]); + assert!(managed.dashpay().private_reply_pending(&carol)); + managed.settle_private_reply(&carol, &[0xD; 32]); + assert!(!managed.dashpay().private_reply_pending(&carol)); + } } diff --git a/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/dashpay.rs b/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/dashpay.rs index 0b59b225fd2..a0524ea25e3 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/dashpay.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/dashpay.rs @@ -28,6 +28,16 @@ pub struct PrivateContact { pub shielded_address: Option<[u8; 43]>, } +/// The private request to one recipient that may be in flight: the attempt +/// that reserved it and, once sealed, the commitment of its entry. Only that +/// attempt notes the commitment or releases it; the private sync settles it +/// by the commitment. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct PendingPrivateReply { + pub(crate) attempt: u64, + pub(crate) commitment: Option<[u8; 32]>, +} + /// DashPay social state carried by one [`ManagedIdentity`](super::ManagedIdentity). #[derive(Debug, Clone, Default)] pub struct DashPayState { @@ -111,12 +121,18 @@ pub struct DashPayState { pub(super) private_shielded_addresses: BTreeMap, /// The private requests to each recipient that are being paid for, or - /// were paid for and are not yet read back from the store: the - /// commitment of the entry once it is sealed. While one is pending no - /// other private request to the recipient is sent, as the store keeps - /// every entry appended and a second payment could execute too. The - /// private sync settles one when it reads the entry back. In memory only. - pub(super) pending_private_replies: BTreeMap>, + /// were paid for and are not yet read back from the store, each owned by + /// the attempt that reserved it. While one is pending no other request to + /// the recipient is sent, private or public, as the store keeps every + /// entry appended and a second payment could execute too. The private + /// sync settles one when it reads the entry back and records it. In + /// memory only: after a restart, those whose payment is unconfirmed are + /// restored from the shielded store's unconfirmed spends. + pub(super) pending_private_replies: BTreeMap, + + /// The attempt id the next reservation in `pending_private_replies` + /// takes. In memory only. + pub(super) next_private_reply_attempt: u64, /// Lowest Platform-assigned `$createdAtCoreBlockHeight` among OUR sent /// `contactRequest` docs to each recipient seen by a sweep this process. diff --git a/packages/rs-platform-wallet/src/wallet/platform_wallet.rs b/packages/rs-platform-wallet/src/wallet/platform_wallet.rs index 6d426f35143..432336aa7e7 100644 --- a/packages/rs-platform-wallet/src/wallet/platform_wallet.rs +++ b/packages/rs-platform-wallet/src/wallet/platform_wallet.rs @@ -46,13 +46,17 @@ use dpp::prelude::Identifier; use { super::identity::network::dashpay_contract, super::shielded::operations::private_document_create_from_pool_with_metadata, - super::shielded::{NetworkShieldedCoordinator, OrchardKeySet}, + super::shielded::store::PendingRedrive, + super::shielded::{NetworkShieldedCoordinator, OrchardKeySet, ShieldedStore}, crate::{AutoAcceptProofSource, ContactCryptoProvider, ContactRequest, EstablishedContact}, dash_sdk::dapi_grpc::platform::v0::ResponseMetadata, dash_sdk::platform::dashpay::PRIVATE_CONTACT_REQUEST_DOCUMENT_TYPE_NAME, dpp::data_contract::accessors::v0::DataContractV0Getters, dpp::platform_value::BinaryData, + dpp::serialization::PlatformDeserializableTrusted, dpp::shielded::builder::OrchardProver, + dpp::state_transition::private_document_create_from_shielded_pool_transition::accessors::PrivateDocumentCreateFromShieldedPoolTransitionAccessorsV0, + dpp::state_transition::StateTransition, }; /// Unique identifier for a wallet (32-byte hash). @@ -1596,7 +1600,16 @@ impl PlatformWallet { signer, crypto, |entry| { - self.pay_private_contact_request(coordinator, keyset, account, entry, prover) + self.pay_private_contact_request( + coordinator, + keyset, + account, + sender_identity_id, + recipient_identity_id, + crypto, + entry, + prover, + ) }, ) .await @@ -1630,7 +1643,16 @@ impl PlatformWallet { self.identity .dashpay() .accept_private_contact_request(request, shielded_address, signer, crypto, |entry| { - self.pay_private_contact_request(coordinator, keyset, account, entry, prover) + self.pay_private_contact_request( + coordinator, + keyset, + account, + &request.recipient_id, + &request.sender_id, + crypto, + entry, + prover, + ) }) .await } @@ -1677,25 +1699,112 @@ impl PlatformWallet { } = payment().await?; let keyset = self.derive_spend_keyset(seed.as_ref(), account).await?; drop(seed); - self.pay_private_contact_request(&coordinator, keyset, account, entry, prover) - .await + self.pay_private_contact_request( + &coordinator, + keyset, + account, + &request.recipient_id, + &request.sender_id, + crypto, + entry, + prover, + ) + .await }, ) .await } - /// Append the sealed private contact request `entry` to DashPay's store, + /// Restore, at startup, the pending DashPay private contact requests + /// whose payment is unconfirmed since before a restart, from the signed + /// transitions `coordinator`'s store keeps for this wallet's unconfirmed + /// spends: until a private sync reads each back, no other request to its + /// recipient is sent, privately or publicly. `crypto` opens the entries. + /// Returns the (sender, recipient) pairs restored. A private send runs + /// the same check right before it pays, so without this call a second + /// private request is still held; this call holds public ones too. See + /// [`DashPayView::restore_pending_private_contact_requests`](crate::DashPayView::restore_pending_private_contact_requests) + /// for what it does not cover. + #[cfg(feature = "shielded")] + pub async fn restore_pending_private_contact_requests( + &self, + coordinator: &Arc, + crypto: &C, + ) -> Result, PlatformWalletError> + where + C: ContactCryptoProvider + Sync, + { + let entries = self + .unconfirmed_private_contact_requests(coordinator) + .await?; + self.identity + .dashpay() + .restore_pending_private_contact_requests(&entries, crypto) + .await + } + + /// The entries of the private contact requests whose payment from this + /// wallet's notes `coordinator`'s store holds as unconfirmed. + #[cfg(feature = "shielded")] + async fn unconfirmed_private_contact_requests( + &self, + coordinator: &Arc, + ) -> Result, PlatformWalletError> { + let dashpay_id = dashpay_contract()?.id(); + let store = coordinator.store().read().await; + let redrives = store + .pending_redrives_for_wallet(self.wallet_id) + .map_err(PlatformWalletError::from)?; + Ok(private_contact_request_redrive_entries( + redrives.iter().map(|(_, redrive)| redrive), + dashpay_id, + )) + } + + /// Append the sealed private contact request `entry`, from + /// `sender_identity_id` to `recipient_identity_id`, to DashPay's store, /// paying from `account`'s notes, and return the metadata of the proof /// that it was appended. + /// + /// A request between the same pair whose payment is unconfirmed since + /// before a restart may still execute: then nothing is paid, and the + /// recipient stays reserved for that request + /// ([`PlatformWalletError::PrivateContactRequestPending`]). #[cfg(feature = "shielded")] - async fn pay_private_contact_request( + #[allow(clippy::too_many_arguments)] + async fn pay_private_contact_request( &self, coordinator: &Arc, keyset: OrchardKeySet, account: u32, + sender_identity_id: &Identifier, + recipient_identity_id: &Identifier, + crypto: &C, entry: BinaryData, prover: P, - ) -> Result { + ) -> Result + where + C: ContactCryptoProvider + Sync, + P: OrchardProver, + { + let unconfirmed = self + .unconfirmed_private_contact_requests(coordinator) + .await?; + if self + .identity + .dashpay() + .hold_for_restored_private_contact_request( + &unconfirmed, + crypto, + sender_identity_id, + recipient_identity_id, + ) + .await? + { + return Err(PlatformWalletError::PrivateContactRequestPending( + *recipient_identity_id, + )); + } let dashpay_id = dashpay_contract()?.id(); private_document_create_from_pool_with_metadata( &self.sdk, @@ -2533,6 +2642,33 @@ impl DerefMut for WalletStateWriteGuard<'_> { } } +/// The entries of every private document create (type 29) among `redrives` +/// that appends to DashPay's `privateContactRequest` store, `dashpay_id` +/// being DashPay's contract id. A record whose transition does not decode +/// is skipped. +#[cfg(feature = "shielded")] +fn private_contact_request_redrive_entries<'a>( + redrives: impl IntoIterator, + dashpay_id: Identifier, +) -> Vec { + redrives + .into_iter() + .filter_map(|redrive| { + match StateTransition::deserialize_from_bytes_trusted(&redrive.st_bytes) { + Ok(StateTransition::PrivateDocumentCreateFromShieldedPool(transition)) + if transition.data_contract_id() == dashpay_id + && transition.document_type_name() + == PRIVATE_CONTACT_REQUEST_DOCUMENT_TYPE_NAME => + { + Some(transition.entries().to_vec()) + } + _ => None, + } + }) + .flatten() + .collect() +} + /// Verify a bech32m recipient's network class matches `network` before decoding. /// /// The address decoder is network-agnostic (`tdash` is shared by @@ -2564,6 +2700,103 @@ fn check_recipient_hrp( Ok(()) } +#[cfg(all(test, feature = "shielded"))] +mod private_contact_request_redrive_tests { + use super::*; + use dpp::serialization::PlatformSerializable; + use dpp::shielded::SerializedAction; + use dpp::state_transition::private_document_create_from_shielded_pool_transition::methods::PrivateDocumentCreateFromShieldedPoolTransitionMethodsV0; + use dpp::state_transition::private_document_create_from_shielded_pool_transition::PrivateDocumentCreateFromShieldedPoolTransition; + use dpp::version::PlatformVersion; + + /// A type 29 transition appending `entries` to the store of + /// `document_type_name` of `data_contract_id`, with placeholder actions. + fn private_document_create( + data_contract_id: Identifier, + document_type_name: &str, + entries: Vec, + ) -> StateTransition { + let action = SerializedAction { + nullifier: [1; 32], + rk: [2; 32], + cmx: [3; 32], + encrypted_note: vec![4; 216], + cv_net: [5; 32], + spend_auth_sig: [6; 64], + }; + PrivateDocumentCreateFromShieldedPoolTransition::try_from_bundle( + data_contract_id, + document_type_name.to_string(), + entries, + vec![action.clone(), action], + 0, + [7; 32], + vec![8; 64], + [9; 64], + PlatformVersion::latest(), + ) + .expect("a type 29 transition") + } + + /// The unconfirmed spend that broadcast `st_bytes`. + fn redrive(st_bytes: Vec) -> PendingRedrive { + PendingRedrive { + activity_id: [1; 32], + anchor: [7; 32], + nullifiers: vec![[1; 32]], + st_bytes, + attempts: 0, + identity_nonce_finalized: false, + identity_user_abandoned: false, + } + } + + /// Only the entries of type 29 transitions to DashPay's private contact + /// request store are read: not another contract's, not another document + /// type's, and not a record that does not decode. + #[test] + fn should_read_dashpays_private_contact_requests_from_unconfirmed_spends() { + let dashpay_id = dashpay_contract().expect("bundled DashPay").id(); + let entry = |byte: u8| BinaryData::new(vec![byte; 589]); + let serialized = |transition: StateTransition| { + transition + .serialize_to_bytes() + .expect("the transition serializes") + }; + let redrives = [ + redrive(serialized(private_document_create( + dashpay_id, + PRIVATE_CONTACT_REQUEST_DOCUMENT_TYPE_NAME, + vec![entry(1), entry(2)], + ))), + redrive(serialized(private_document_create( + Identifier::new([4; 32]), + PRIVATE_CONTACT_REQUEST_DOCUMENT_TYPE_NAME, + vec![entry(3)], + ))), + redrive(serialized(private_document_create( + dashpay_id, + "privateNote", + vec![entry(4)], + ))), + redrive(vec![0xFF; 16]), + redrive(serialized(private_document_create( + dashpay_id, + PRIVATE_CONTACT_REQUEST_DOCUMENT_TYPE_NAME, + vec![entry(5)], + ))), + ]; + assert_eq!( + private_contact_request_redrive_entries(&redrives, dashpay_id), + vec![entry(1), entry(2), entry(5)] + ); + assert_eq!( + private_contact_request_redrive_entries(&[] as &[PendingRedrive], dashpay_id), + Vec::::new() + ); + } +} + #[cfg(all(test, feature = "shielded"))] mod check_recipient_hrp_tests { use super::*; From f8e20bb249bba4fd2d900a2711dbe1d8f5ce70d5 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 9 Nov 2026 02:02:08 +0700 Subject: [PATCH 018/117] fix(platform-wallet): refuse a contact request once every rotation version is used The account reference's rotation version saturates at 15. Past it, with the same receiving xpub and encryption key, the next request would carry the tracked request's reference again. A `contactRequest` document then failed only at the contract's unique index, after signing; a private request was accepted by the store, paid for, and then ignored as already tracked, so the fee bought nothing. `prepare_contact_request` now refuses such a request with `InvalidParameter` before anything is signed, sealed or paid, for both the public and the private send. Co-Authored-By: Claude Opus 5.5 --- .../identity/network/contact_requests.rs | 39 +++++++---- .../network/private_contact_requests.rs | 68 ++++++++++++++++++- 2 files changed, 94 insertions(+), 13 deletions(-) diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs b/packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs index 3663ed6f97f..62f8679298d 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs @@ -781,6 +781,11 @@ impl DashPayView<'_, B> { /// the pair, the DIP-15 masked account reference (its version bumped past /// the previous request to the recipient, public or private) and the /// auto-accept proof over it. + /// + /// Once the version is at its last, 15, a request with the same xpub and + /// key would repeat the previous one: it is refused with + /// [`PlatformWalletError::InvalidParameter`], before anything is signed, + /// sealed or paid. pub(super) async fn prepare_contact_request( &self, sender_identity_id: &Identifier, @@ -920,17 +925,17 @@ impl DashPayView<'_, B> { // identical resend, so the bump is what makes a superseding // (rotation) request broadcastable. The HMAC+mask runs in the // signer (keyed by the raw scalar at `sender_enc_path`). + let prior_reference = { + let wm = self.wallet_manager.read().await; + wm.get_wallet_info(&self.wallet_id) + .and_then(|info| info.identity_manager.managed_identity(sender_identity_id)) + // Checks both the pending sent map AND the established + // contact's outgoing request: see the method doc for + // why consulting only the pending map breaks rotation + // on established contacts. + .and_then(|managed| managed.prior_sent_account_reference(recipient_identity_id)) + }; let account_reference = { - let prior_reference = { - let wm = self.wallet_manager.read().await; - wm.get_wallet_info(&self.wallet_id) - .and_then(|info| info.identity_manager.managed_identity(sender_identity_id)) - // Checks both the pending sent map AND the established - // contact's outgoing request — see the method doc for - // why consulting only the pending map breaks rotation - // on established contacts. - .and_then(|managed| managed.prior_sent_account_reference(recipient_identity_id)) - }; let previous_version = match prior_reference { Some(prior) => Some( crypto @@ -942,8 +947,8 @@ impl DashPayView<'_, B> { }; let version = match previous_version { // 4-bit field; saturate rather than wrap so a 16th - // rotation fails loudly at the unique index instead of - // silently colliding with version 0. + // rotation is refused below instead of silently colliding + // with version 0. Some(v) if v >= 15 => { tracing::warn!( recipient = %recipient_identity_id, @@ -958,6 +963,16 @@ impl DashPayView<'_, B> { .account_reference(&sender_enc_path, &xpub_bytes, account_index, version) .await? }; + // Saturated, with the same xpub and key: the request would repeat the + // tracked one. A document send would fail at the unique index; a + // private one would be paid for and then ignored as already tracked. + if prior_reference == Some(account_reference) { + return Err(PlatformWalletError::InvalidParameter(format!( + "no account reference version is left for another contact request to \ + {recipient_identity_id}: version 15 is used, and this request would repeat the \ + one already sent" + ))); + } // 4c. Resolve the auto-accept proof now that `account_reference` is // known. The QR-scanner variant signs `(sender, recipient, diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/private_contact_requests.rs b/packages/rs-platform-wallet/src/wallet/identity/network/private_contact_requests.rs index b4ef78ca141..0b1378a0a58 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/private_contact_requests.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/private_contact_requests.rs @@ -1231,7 +1231,9 @@ mod tests { use key_wallet::mnemonic::Mnemonic; use key_wallet::wallet::initialization::WalletAccountCreationOptions; use key_wallet::Network; - use platform_encryption::{decrypt_account_label, decrypt_extended_public_key}; + use platform_encryption::{ + account_reference_version, decrypt_account_label, decrypt_extended_public_key, + }; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Mutex; use std::time::Duration; @@ -2566,6 +2568,70 @@ mod tests { assert!(!pending_to(&restarted, ALICE, &bob).await); } + /// Once the account reference's rotation version is at its last, a + /// request with the same xpub and key would repeat the tracked one: it is + /// refused before anything is paid, and nothing stays pending. + #[tokio::test] + async fn should_refuse_a_request_once_every_rotation_version_is_used() { + let alice = identity(ALICE_PHRASE, ALICE); + let bob = identity(BOB_PHRASE, BOB); + let alice_crypto = SeedCryptoProvider::from_seed(seed(ALICE_PHRASE), Network::Testnet); + let alice_signer = signer(ALICE_PHRASE, &alice); + let (_manager, alice_wallet) = wallet(ALICE_PHRASE, &alice, &[&alice, &bob], vec![]).await; + let view = alice_wallet.identity().dashpay(); + let kept = Arc::new(Mutex::new(Vec::new())); + let sent = view + .send_private_contact_request( + &alice.id(), + &bob.id(), + None, + AutoAcceptProofSource::None, + None, + &alice_signer, + &alice_crypto, + append_entry(&alice_wallet, 0, vec![], &kept), + ) + .await + .expect("Alice sends the request, read back"); + assert_eq!(account_reference_version(sent.account_reference), 0); + + let mut exhausted = sent.clone(); + exhausted.account_reference = (sent.account_reference & 0x0FFF_FFFF) | (15 << 28); + assert_eq!(account_reference_version(exhausted.account_reference), 15); + { + let iw = alice_wallet.identity(); + let mut wm = iw.wallet_manager.write().await; + wm.get_wallet_info_mut(&alice_wallet.wallet_id()) + .expect("wallet info") + .identity_manager + .managed_identity_mut(&alice.id()) + .expect("Alice") + .add_sent_contact_request(exhausted, &iw.persister) + .expect("the version 15 request"); + } + + let result = view + .send_private_contact_request( + &alice.id(), + &bob.id(), + None, + AutoAcceptProofSource::None, + None, + &alice_signer, + &alice_crypto, + |_: BinaryData| -> std::future::Ready> { + panic!("a request that repeats the tracked one is not paid for") + }, + ) + .await; + assert!( + matches!(&result, Err(PlatformWalletError::InvalidParameter(message)) + if message.contains("no account reference version is left")), + "{result:?}" + ); + assert!(!pending_to(&alice_wallet, ALICE, &bob).await); + } + /// A cursor rewound under a pass is not advanced again that pass, so the /// next reads from the rewind. #[tokio::test] From 785e4939b1d8cfa5c67b755552a8246646246f1a Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 9 Nov 2026 02:07:13 +0700 Subject: [PATCH 019/117] fix(sdk): give an empty context provider the DashPay it fetches, and validate wasm contracts with their tokens A DashPay fetched from the network because the context provider held none was returned without being registered, so every page's proof, which is verified against the provider's contract, failed. The helper now gives the provider any DashPay it does not hold with the private type, whether it held none or an older one, and fails if the provider still does not hold it. Enabling dpp's validation in rs-sdk made wasm-dpp2's DataContract constructor check document token costs, but it parsed the document types before adding the contract's tokens, so a cost in the contract's own token was refused ("No maximum token position limit is set"). The tokens are now part of the parsed contract. Co-Authored-By: Claude Opus 5.5 --- .../dashpay/private_contact_request.rs | 123 ++++++++++++------ packages/wasm-dpp2/src/data_contract/model.rs | 10 ++ .../tests/unit/TokenConfiguration.spec.ts | 50 +++++++ 3 files changed, 145 insertions(+), 38 deletions(-) diff --git a/packages/rs-sdk/src/platform/dashpay/private_contact_request.rs b/packages/rs-sdk/src/platform/dashpay/private_contact_request.rs index cab9c477e47..f80d6bf7a27 100644 --- a/packages/rs-sdk/src/platform/dashpay/private_contact_request.rs +++ b/packages/rs-sdk/src/platform/dashpay/private_contact_request.rs @@ -706,6 +706,11 @@ fn key_is(key: &IdentityPublicKey, public_key: &PublicKey) -> bool { } } +/// Whether `contract`, the DashPay a context provider holds, declares private contact requests. +fn holds_private_contact_requests(contract: Option<&DataContract>) -> bool { + contract.is_some_and(|contract| private_contact_request_type(contract).is_ok()) +} + fn private_contact_request_type( dashpay_contract: &DataContract, ) -> Result, Error> { @@ -1289,37 +1294,39 @@ impl Sdk { sealed } - /// The DashPay contract, with its `privateContactRequest` type: the network's protocol - /// version is established as 15 or later first, as an unpinned SDK starts below it and would - /// otherwise resolve a DashPay without the type. + /// The DashPay contract, with its `privateContactRequest` type, as the context provider + /// holds it: a page's proof is verified against the contract the provider gives. The + /// network's protocol version is established as 15 or later first, as an unpinned SDK starts + /// below it and would otherwise resolve a DashPay without the type. async fn fetch_private_contact_request_contract(&self) -> Result, Error> { self.require_protocol_version( PRIVATE_DOCUMENT_CREATE_FROM_SHIELDED_POOL_INITIAL_PROTOCOL_VERSION, "DashPay private contact requests", ) .await?; - let contract = self.fetch_dashpay_contract().await?; - if private_contact_request_type(&contract).is_ok() { - return Ok(contract); - } - // The context provider may hold a DashPay from before the type, kept from an older - // version: the network's is current. The provider gets it too, as the page's proof is - // verified against the contract the provider gives. + let mut contract = self.fetch_dashpay_contract().await?; let contract_id = contract.id(); - let contract = DataContract::fetch(self, contract_id) - .await? - .map(Arc::new) - .ok_or_else(|| Error::Generic(format!("the DashPay contract {contract_id} is gone")))?; - private_contact_request_type(&contract)?; + if private_contact_request_type(&contract).is_err() { + // The context provider may hold a DashPay from before the type, kept from an older + // version: the network's is current. + contract = DataContract::fetch(self, contract_id) + .await? + .map(Arc::new) + .ok_or_else(|| { + Error::Generic(format!("the DashPay contract {contract_id} is gone")) + })?; + private_contact_request_type(&contract)?; + } if let Some(provider) = self.context_provider() { - provider.register_data_contract(Arc::clone(&contract)); - // A provider that keeps its older DashPay, or holds none, would fail every page: - // that is refused here, once. + // A DashPay fetched from the network, first or again, is given to the provider. One + // that still gives none with the type would fail every page: that is refused here, + // once. let held = provider.get_data_contract(&contract_id, self.version())?; - if !held - .as_deref() - .is_some_and(|held| private_contact_request_type(held).is_ok()) - { + if !holds_private_contact_requests(held.as_deref()) { + provider.register_data_contract(Arc::clone(&contract)); + } + let held = provider.get_data_contract(&contract_id, self.version())?; + if !holds_private_contact_requests(held.as_deref()) { return Err(Error::Generic(format!( "the context provider does not hold the DashPay {contract_id} with \ {PRIVATE_CONTACT_REQUEST_DOCUMENT_TYPE_NAME} once given it, so private \ @@ -3121,27 +3128,27 @@ mod tests { assert_eq!(scan.received[0].request, request); } - /// A context provider holding `held` whatever version it is asked for, as a provider's - /// known contracts do. A `writable` one puts a registered contract in its place; a - /// read-only one keeps what it holds. Its clones share what it holds. + /// A context provider holding `held`, or nothing, whatever version it is asked for, as a + /// provider's known contracts do. A `writable` one puts a registered contract in its place; + /// a read-only one keeps what it holds. Its clones share what it holds. #[cfg(feature = "mocks")] #[derive(Clone)] struct HeldContractProvider { - held: Arc>>, + held: Arc>>>, writable: bool, } #[cfg(feature = "mocks")] impl HeldContractProvider { - fn new(held: DataContract, writable: bool) -> Self { + fn new(held: Option, writable: bool) -> Self { Self { - held: Arc::new(std::sync::Mutex::new(Arc::new(held))), + held: Arc::new(std::sync::Mutex::new(held.map(Arc::new))), writable, } } - fn held(&self) -> Arc { - Arc::clone(&self.held.lock().expect("the lock")) + fn held(&self) -> Option> { + self.held.lock().expect("the lock").clone() } } @@ -3153,7 +3160,7 @@ mod tests { _platform_version: &PlatformVersion, ) -> Result>, dash_context_provider::ContextProviderError> { - Ok(Some(self.held())) + Ok(self.held()) } fn get_token_configuration( @@ -3186,7 +3193,7 @@ mod tests { fn register_data_contract(&self, contract: Arc) { if self.writable { - *self.held.lock().expect("the lock") = contract; + *self.held.lock().expect("the lock") = Some(contract); } } } @@ -3220,7 +3227,7 @@ mod tests { #[cfg(feature = "mocks")] #[tokio::test] async fn should_learn_the_version_and_refresh_a_dashpay_without_private_requests() { - let provider = HeldContractProvider::new(dashpay_without_private_requests(), true); + let provider = HeldContractProvider::new(Some(dashpay_without_private_requests()), true); let mut sdk: Sdk = crate::SdkBuilder::new_mock() .with_initial_version(PlatformVersion::get(14).expect("protocol version 14")) .with_context_provider(provider.clone()) @@ -3243,7 +3250,32 @@ mod tests { PlatformVersion::latest().protocol_version ); assert!(private_contact_request_type(&contract).is_ok()); - assert!(private_contact_request_type(&provider.held()).is_ok()); + assert!(holds_private_contact_requests(provider.held().as_deref())); + } + + /// A DashPay the provider does not hold yet is fetched from the network and given to it, + /// which the pages are then verified against. + #[cfg(feature = "mocks")] + #[tokio::test] + async fn should_give_an_empty_provider_the_dashpay_it_fetches() { + let provider = HeldContractProvider::new(None, true); + let mut sdk = sdk_with(provider.clone()); + let current = dashpay(); + sdk.mock() + .expect_fetch(current.id(), Some(current.clone())) + .await + .expect("the DashPay fetch is expected"); + + let contract = sdk + .fetch_private_contact_request_contract() + .await + .expect("the current DashPay"); + + assert!(private_contact_request_type(&contract).is_ok()); + let verified_against = provider + .get_data_contract(¤t.id(), PlatformVersion::latest()) + .expect("the provider answers"); + assert!(holds_private_contact_requests(verified_against.as_deref())); } /// A DashPay the provider holds with the private type is used as it is: nothing is fetched, @@ -3251,7 +3283,7 @@ mod tests { #[cfg(feature = "mocks")] #[tokio::test] async fn should_use_a_dashpay_the_provider_holds_with_private_requests() { - let provider = HeldContractProvider::new(dashpay(), false); + let provider = HeldContractProvider::new(Some(dashpay()), false); let sdk = sdk_with(provider.clone()); let contract = sdk @@ -3259,7 +3291,10 @@ mod tests { .await .expect("the held DashPay"); - assert!(Arc::ptr_eq(&contract, &provider.held())); + assert!(Arc::ptr_eq( + &contract, + &provider.held().expect("the provider holds DashPay") + )); } /// A refresh that leaves the provider without the private type fails, rather than every @@ -3270,7 +3305,7 @@ mod tests { let current = dashpay(); let mut sdk = sdk_with(HeldContractProvider::new( - dashpay_without_private_requests(), + Some(dashpay_without_private_requests()), false, )); sdk.mock() @@ -3284,8 +3319,20 @@ mod tests { .to_string(); assert!(error.contains("does not hold"), "{error}"); + let mut sdk = sdk_with(HeldContractProvider::new(None, false)); + sdk.mock() + .expect_fetch(current.id(), Some(current.clone())) + .await + .expect("the DashPay fetch is expected"); + let error = sdk + .fetch_private_contact_request_contract() + .await + .expect_err("the provider takes no contract") + .to_string(); + assert!(error.contains("does not hold"), "{error}"); + let mut sdk = sdk_with(HeldContractProvider::new( - dashpay_without_private_requests(), + Some(dashpay_without_private_requests()), true, )); sdk.mock() diff --git a/packages/wasm-dpp2/src/data_contract/model.rs b/packages/wasm-dpp2/src/data_contract/model.rs index ea810b49a63..57892aba6ed 100644 --- a/packages/wasm-dpp2/src/data_contract/model.rs +++ b/packages/wasm-dpp2/src/data_contract/model.rs @@ -378,6 +378,16 @@ impl DataContractWasm { .set_value("documentSchemas", schema) .map_err(|err| WasmDppError::serialization(err.to_string()))?; + // A document type's token costs are checked against the contract's tokens when the + // contract is validated, so the tokens are part of what is parsed. + if !tokens.is_empty() { + let tokens_value = dpp::platform_value::to_value(&tokens) + .map_err(|e| WasmDppError::serialization(e.to_string()))?; + contract_value + .set_value("tokens", tokens_value) + .map_err(|err| WasmDppError::serialization(err.to_string()))?; + } + let data_contract = DataContract::from_value(contract_value, opts.full_validation, &platform_version)?; diff --git a/packages/wasm-dpp2/tests/unit/TokenConfiguration.spec.ts b/packages/wasm-dpp2/tests/unit/TokenConfiguration.spec.ts index 2006c7cb2a5..568c15af1c7 100644 --- a/packages/wasm-dpp2/tests/unit/TokenConfiguration.spec.ts +++ b/packages/wasm-dpp2/tests/unit/TokenConfiguration.spec.ts @@ -289,6 +289,56 @@ describe('TokenConfiguration', () => { return (contract.tokens as Record)[0]; } + describe('document token costs', () => { + it('should validate a document type paying with the contract\'s own token', () => { + // The document types are validated with the contract's tokens: a cost in token 0 of a + // contract that declares token 0 is valid. + const contract = new wasm.DataContract({ + ownerId, + identityNonce: BigInt(3), + schemas: { + note: { + type: 'object', + tokenCost: { + create: { tokenPosition: 0, amount: 5, gasFeesPaidBy: 0 }, + }, + properties: { + title: { type: 'string', maxLength: 100, position: 0 }, + }, + additionalProperties: false, + }, + }, + definitions: null, + fullValidation: true, + tokens: { 0: createConfiguration() }, + }); + + expect(Object.keys(contract.tokens as Record)).to.deep.equal(['0']); + }); + + it('should refuse a cost in a token the contract does not declare', () => { + expect(() => new wasm.DataContract({ + ownerId, + identityNonce: BigInt(4), + schemas: { + note: { + type: 'object', + tokenCost: { + create: { tokenPosition: 1, amount: 5, gasFeesPaidBy: 0 }, + }, + properties: { + title: { type: 'string', maxLength: 100, position: 0 }, + }, + additionalProperties: false, + }, + }, + definitions: null, + fullValidation: true, + tokens: { 0: createConfiguration() }, + })).to.throw(); + }); + }); + describe('minimumPoolNotesForOutgoing', () => { it('should keep a threshold asked for at creation', () => { const config = createConfiguration({ From 8c5968c8082be532a48c040be706500303c68bd6 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 9 Nov 2026 02:40:12 +0700 Subject: [PATCH 020/117] fix(platform-wallet): fence a private contact request payment against the wallet's removal `PlatformWallet::pay_private_contact_request` entered the shielded spend without the wallet generation's lifecycle gate. A caller could remove the wallet while the proof was being built; the retained spend then recorded pending activity, recreating the removed subwallet's shielded state, and broadcast. The payment now takes the generation's payment guard first, as the transparent contact payment does, and holds it across the liveness check, the restore of unconfirmed requests and the spend. A wallet removed or re-created since the handle was taken is refused with `WalletRemovedBeforeSend`, with nothing restored, recorded or paid, and a removal waits for a payment already past the check. The guard is taken before the wallet manager lock, as the teardown takes them. Reservation attempt ids now come from one process-wide counter, so a handle that outlived its wallet's removal and re-creation under the same id cannot release a reservation the new wallet made with the same id. Co-Authored-By: Claude Opus 5.5 --- .../managed_identity/contact_requests.rs | 9 +- .../state/managed_identity/dashpay.rs | 4 - .../src/wallet/platform_wallet.rs | 182 ++++++++++++++++++ 3 files changed, 189 insertions(+), 6 deletions(-) diff --git a/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/contact_requests.rs b/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/contact_requests.rs index 589b4d60ec4..26bc4068019 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/contact_requests.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/contact_requests.rs @@ -15,6 +15,12 @@ use crate::wallet::persister::WalletPersister; use crate::{ContactRequest, EstablishedContact}; use dpp::prelude::Identifier; use platform_encryption::account_reference_version; +use std::sync::atomic::{AtomicU64, Ordering}; + +/// The attempt id the next private request reservation takes. Process-wide, +/// so a handle that outlived its wallet's removal and re-creation under the +/// same id cannot release a reservation the new wallet made. +static NEXT_PRIVATE_REPLY_ATTEMPT: AtomicU64 = AtomicU64::new(0); impl ManagedIdentity { /// The masked `accountReference` of the most recent request WE sent @@ -858,8 +864,7 @@ impl ManagedIdentity { /// Reserve `recipient` under a new attempt, `commitment` its entry's. fn insert_private_reply(&mut self, recipient: Identifier, commitment: Option<[u8; 32]>) -> u64 { - let attempt = self.dashpay.next_private_reply_attempt; - self.dashpay.next_private_reply_attempt = attempt.wrapping_add(1); + let attempt = NEXT_PRIVATE_REPLY_ATTEMPT.fetch_add(1, Ordering::Relaxed); self.dashpay.pending_private_replies.insert( recipient, PendingPrivateReply { diff --git a/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/dashpay.rs b/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/dashpay.rs index a0524ea25e3..e6369dcbfa8 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/dashpay.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/dashpay.rs @@ -130,10 +130,6 @@ pub struct DashPayState { /// restored from the shielded store's unconfirmed spends. pub(super) pending_private_replies: BTreeMap, - /// The attempt id the next reservation in `pending_private_replies` - /// takes. In memory only. - pub(super) next_private_reply_attempt: u64, - /// Lowest Platform-assigned `$createdAtCoreBlockHeight` among OUR sent /// `contactRequest` docs to each recipient seen by a sweep this process. /// diff --git a/packages/rs-platform-wallet/src/wallet/platform_wallet.rs b/packages/rs-platform-wallet/src/wallet/platform_wallet.rs index 432336aa7e7..eb35842797c 100644 --- a/packages/rs-platform-wallet/src/wallet/platform_wallet.rs +++ b/packages/rs-platform-wallet/src/wallet/platform_wallet.rs @@ -1770,6 +1770,12 @@ impl PlatformWallet { /// before a restart may still execute: then nothing is paid, and the /// recipient stays reserved for that request /// ([`PlatformWalletError::PrivateContactRequestPending`]). + /// + /// Holds the wallet generation's lifecycle gate from the liveness check + /// until the spend returns, so a removal waits for it. A wallet removed or + /// re-created since this handle was taken is refused with + /// [`PlatformWalletError::WalletRemovedBeforeSend`], with nothing + /// restored, recorded or paid. A caller must not hold the gate. #[cfg(feature = "shielded")] #[allow(clippy::too_many_arguments)] async fn pay_private_contact_request( @@ -1787,6 +1793,17 @@ impl PlatformWallet { C: ContactCryptoProvider + Sync, P: OrchardProver, { + // The generation's lifecycle gate, held across the liveness check, + // the restore and the spend, before the wallet manager lock as the + // teardown takes them: a removal cannot complete in between, so a + // removed wallet's spend never records activity, recreating its + // shielded state, or reaches the network. + let _lifecycle = self.core().generation_payment_guard().await; + if !self.core().is_current_generation().await { + return Err(PlatformWalletError::WalletRemovedBeforeSend(hex::encode( + self.wallet_id, + ))); + } let unconfirmed = self .unconfirmed_private_contact_requests(coordinator) .await?; @@ -2797,6 +2814,171 @@ mod private_contact_request_redrive_tests { } } +#[cfg(all(test, feature = "shielded"))] +mod private_contact_request_payment_tests { + use super::*; + use crate::events::{EventHandler, PlatformEventHandler}; + use crate::wallet::identity::network::SeedCryptoProvider; + use crate::wallet::persister::NoPlatformPersistence; + use crate::wallet::shielded::FileBackedShieldedStore; + use crate::PlatformWalletManager; + use grovedb_commitment_tree::ProvingKey; + use key_wallet::wallet::initialization::WalletAccountCreationOptions; + use std::time::Duration; + + const SEED: [u8; 64] = [0x42; 64]; + + struct NoopEventHandler; + impl EventHandler for NoopEventHandler {} + impl PlatformEventHandler for NoopEventHandler {} + + /// A prover the refused or unfunded payments here never reach. + struct UnreachableProver; + impl OrchardProver for UnreachableProver { + fn proving_key(&self) -> &ProvingKey { + panic!("nothing is proved") + } + } + + fn coordinator_at(dir: &std::path::Path) -> Arc { + let db_path = dir.join("tree.sqlite"); + let store = FileBackedShieldedStore::open_path(&db_path, 100).expect("open file store"); + Arc::new(NetworkShieldedCoordinator::new( + Arc::new(dash_sdk::Sdk::new_mock()), + dashcore::Network::Testnet, + db_path, + store, + )) + } + + async fn manager_with_wallet() -> ( + Arc>, + Arc, + ) { + let manager = Arc::new(PlatformWalletManager::new( + Arc::new(dash_sdk::Sdk::new_mock()), + Arc::new(NoPlatformPersistence), + Arc::new(NoopEventHandler), + )); + let wallet = add_wallet(&manager).await; + (manager, wallet) + } + + async fn add_wallet( + manager: &PlatformWalletManager, + ) -> Arc { + manager + .create_wallet_from_seed_bytes( + dashcore::Network::Testnet, + &SEED, + WalletAccountCreationOptions::Default, + Some(0), + ) + .await + .expect("wallet") + } + + /// Pay for a private contact request from `wallet`'s account 0. + async fn pay( + wallet: &PlatformWallet, + coordinator: &Arc, + ) -> Result { + let keyset = + OrchardKeySet::from_seed(&SEED, dashcore::Network::Testnet, 0).expect("keyset"); + let crypto = SeedCryptoProvider::from_seed(SEED, dashcore::Network::Testnet); + wallet + .pay_private_contact_request( + coordinator, + keyset, + 0, + &Identifier::from([0xA1; 32]), + &Identifier::from([0xB1; 32]), + &crypto, + BinaryData::new(vec![1; 589]), + UnreachableProver, + ) + .await + } + + /// A handle to a wallet removed, or re-created under the same id, since + /// it was taken pays nothing for a private contact request: it is + /// refused at the liveness check, before the spend. + #[tokio::test] + async fn should_refuse_a_private_payment_from_a_wallet_no_longer_current() { + let dir = tempfile::tempdir().expect("temporary shielded store"); + let coordinator = coordinator_at(dir.path()); + let (manager, removed) = manager_with_wallet().await; + let wallet_id = removed.wallet_id(); + manager.remove_wallet(&wallet_id).await.expect("removed"); + let refused = pay(&removed, &coordinator).await; + assert!( + matches!(&refused, Err(PlatformWalletError::WalletRemovedBeforeSend(id)) + if *id == hex::encode(wallet_id)), + "{refused:?}" + ); + + let recreated = add_wallet(&manager).await; + assert_eq!(recreated.wallet_id(), wallet_id); + let refused = pay(&removed, &coordinator).await; + assert!( + matches!( + refused, + Err(PlatformWalletError::WalletRemovedBeforeSend(_)) + ), + "{refused:?}" + ); + let current = pay(&recreated, &coordinator).await; + assert!( + !matches!( + current, + Err(PlatformWalletError::WalletRemovedBeforeSend(_)) + ), + "the current wallet's payment goes on to the spend: {current:?}" + ); + } + + /// A removal waits for a private payment that entered its fenced section, + /// here held at the shielded store, and completes once it returns. + #[tokio::test] + async fn should_hold_a_wallets_removal_until_its_private_payment_returns() { + let dir = tempfile::tempdir().expect("temporary shielded store"); + let coordinator = coordinator_at(dir.path()); + let (manager, wallet) = manager_with_wallet().await; + let wallet_id = wallet.wallet_id(); + + let store = coordinator.store().write().await; + let payment = { + let wallet = Arc::clone(&wallet); + let coordinator = Arc::clone(&coordinator); + tokio::spawn(async move { pay(&wallet, &coordinator).await }) + }; + tokio::time::sleep(Duration::from_millis(100)).await; + let removal = { + let manager = Arc::clone(&manager); + tokio::spawn(async move { manager.remove_wallet(&wallet_id).await.map(|_| ()) }) + }; + tokio::time::sleep(Duration::from_millis(200)).await; + assert!(!payment.is_finished(), "the payment waits at the store"); + assert!( + !removal.is_finished(), + "the removal waits for the payment in flight" + ); + assert!(wallet.core().is_current_generation().await); + + drop(store); + let paid = payment.await.expect("payment task"); + assert!( + !matches!(paid, Err(PlatformWalletError::WalletRemovedBeforeSend(_))), + "the wallet was current when it was checked: {paid:?}" + ); + removal + .await + .expect("removal task") + .expect("the removal completes"); + assert!(!wallet.core().is_current_generation().await); + } +} + #[cfg(all(test, feature = "shielded"))] mod check_recipient_hrp_tests { use super::*; From 9f5db04c2c68e841399d0ce1c0b3d5208a4984c5 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 9 Nov 2026 02:40:45 +0700 Subject: [PATCH 021/117] fix(platform-wallet): read the private store before an identity's first private send since load A private request or reply can be paid for and confirmed but not read back by the post-pay sync: the send returns `PrivateContactRequestPending` with nothing recorded and no unconfirmed spend kept, as the payment succeeded. After a restart the in-memory reservation is gone and the store cursor starts again at 0, so a new private send or accept sealed and paid a second entry before any sync had read the first. Each identity now carries an in-memory flag, set when a private sync reads DashPay's store to its end for it with nothing held back. Until it is set, a private send, a private accept, and an in-kind accept of a request that came privately (or would get a private reply) first run bounded private syncs. They stop when the flag is set, and refuse with `PrivateContactRequestPending`, nothing sealed or paid, when the syncs stop advancing or run out; a sync error comes back as it is. The in-kind accept reads before it chooses the reply, so a reply read back is adopted with no payment. A public request with no private one tracked reads nothing. Co-Authored-By: Claude Opus 5.5 --- packages/rs-platform-wallet/src/error.rs | 9 +- .../network/private_contact_requests.rs | 422 ++++++++++++++++++ .../managed_identity/contact_requests.rs | 6 + .../state/managed_identity/dashpay.rs | 14 + 4 files changed, 449 insertions(+), 2 deletions(-) diff --git a/packages/rs-platform-wallet/src/error.rs b/packages/rs-platform-wallet/src/error.rs index 1130358d50b..f3110add6c4 100644 --- a/packages/rs-platform-wallet/src/error.rs +++ b/packages/rs-platform-wallet/src/error.rs @@ -665,8 +665,13 @@ pub enum PlatformWalletError { /// A private contact request to this identity is being paid for, or was /// paid for and is not yet read back from DashPay's store: no other one is - /// sent until the private sync reads it back or its payment definitely - /// fails, as the store keeps every entry appended. + /// sent, privately or publicly, until the private sync reads it back or + /// its payment definitely fails, as the store keeps every entry appended. + /// Also returned, with nothing sealed or paid, by the first private send + /// or accept from an identity since load while DashPay's store cannot yet + /// be read to its end for it: a request paid for in an earlier process + /// may be there unread. Syncing private contact requests and trying again + /// goes on from where that read stopped. #[error( "a private contact request to {0} is in flight or not yet read back from the store; sync \ private contact requests to settle it" diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/private_contact_requests.rs b/packages/rs-platform-wallet/src/wallet/identity/network/private_contact_requests.rs index 0b1378a0a58..2dadeab9cb8 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/private_contact_requests.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/private_contact_requests.rs @@ -126,6 +126,16 @@ pub struct PrivateContactSyncReport { /// A request not read back within them stays pending for a later sync. const POST_SEND_SYNC_PAGES: usize = 4; +/// How many pages of the store each private sync reads that a private send +/// runs, before it seals anything, when the store was not yet read to its end +/// since load. +const PRIVATE_STORE_READ_PAGES: usize = 16; + +/// How many such syncs a private send runs before it refuses with +/// [`PlatformWalletError::PrivateContactRequestPending`]: a later send goes on +/// from where they stopped. +const PRIVATE_STORE_READ_PASSES: usize = 4; + /// One of the wallet's identities, as a pass reads the store for it. struct ScannedIdentity { identity_id: Identifier, @@ -337,6 +347,12 @@ impl DashPayView<'_, B> { /// unpaid. The pending request is kept in memory only; after a restart, /// [`Self::restore_pending_private_contact_requests`] restores those whose /// payment is unconfirmed. + /// + /// A request paid for and confirmed but not read back leaves nothing + /// that survives a restart. So the first private send or accept from an + /// identity since load reads the store to its end first, which records + /// such a request; while that read cannot finish, the send is refused + /// with `PrivateContactRequestPending`, nothing sealed or paid. #[allow(clippy::too_many_arguments)] pub async fn send_private_contact_request( &self, @@ -355,6 +371,8 @@ impl DashPayView<'_, B> { F: FnOnce(BinaryData) -> Fut + Send, Fut: Future> + Send, { + self.read_private_store_before_sending(sender_identity_id, recipient_identity_id, crypto) + .await?; let attempt = self .with_managed(sender_identity_id, |managed| { managed.reserve_private_reply(*recipient_identity_id) @@ -398,6 +416,75 @@ impl DashPayView<'_, B> { sent } + /// Make sure DashPay's private contact request store was read to its end + /// for `identity_id` since load, before a private request from it to + /// `recipient` is sealed or paid for: a request paid for in an earlier + /// process may be in the store unread, and the reservation that kept a + /// second one from being paid did not survive the restart. Until it was, + /// this runs private syncs of [`PRIVATE_STORE_READ_PAGES`] pages, at most + /// [`PRIVATE_STORE_READ_PASSES`] of them, and refuses with + /// [`PlatformWalletError::PrivateContactRequestPending`] when they stop + /// advancing or run out first. A sync error comes back as it is. Once + /// read, it reads nothing. An identity the private sync does not read, + /// with no HD index or no key an entry can be sealed to or from, sends no + /// private request, so it is not held. + async fn read_private_store_before_sending( + &self, + identity_id: &Identifier, + recipient: &Identifier, + crypto: &C, + ) -> Result<(), PlatformWalletError> + where + C: ContactCryptoProvider + Sync, + { + if self.private_store_read(identity_id).await? { + return Ok(()); + } + let (identities, _) = self.private_scan_identities().await?; + if !identities + .iter() + .any(|identity| identity.identity_id == *identity_id) + { + return Ok(()); + } + let mut position = identities + .iter() + .map(|identity| identity.position) + .min() + .unwrap_or_default(); + for _ in 0..PRIVATE_STORE_READ_PASSES { + let report = self + .sync_private_contact_requests(crypto, PRIVATE_STORE_READ_PAGES) + .await?; + if self.private_store_read(identity_id).await? { + return Ok(()); + } + if report.next_position <= position { + break; + } + position = report.next_position; + } + Err(PlatformWalletError::PrivateContactRequestPending( + *recipient, + )) + } + + /// Whether a private sync read DashPay's private contact request store + /// to its end for `identity_id` since load. + async fn private_store_read( + &self, + identity_id: &Identifier, + ) -> Result { + let wm = self.wallet_manager.read().await; + let managed = wm + .get_wallet_info(&self.wallet_id) + .ok_or_else(|| PlatformWalletError::WalletNotFound(hex::encode(self.wallet_id)))? + .identity_manager + .managed_identity(identity_id) + .ok_or(PlatformWalletError::IdentityNotFound(*identity_id))?; + Ok(managed.dashpay().private_contact_requests_read()) + } + /// Run `f` on the managed identity `identity_id` under the wallet lock. async fn with_managed( &self, @@ -616,6 +703,11 @@ impl DashPayView<'_, B> { /// sent by [`Self::send_private_contact_request`], signed by `signer`, /// `pay` paying for it and `shielded_address` the address the contact may /// pay us at privately. + /// + /// As for the send, the store is read to its end first when it was not + /// since load, so a reply paid for in an earlier process is adopted + /// rather than paid for again; while that read cannot finish, the accept + /// is refused with [`PlatformWalletError::PrivateContactRequestPending`]. pub async fn accept_private_contact_request( &self, request: &ContactRequest, @@ -630,6 +722,8 @@ impl DashPayView<'_, B> { F: FnOnce(BinaryData) -> Fut + Send, Fut: Future> + Send, { + self.read_private_store_before_sending(&request.recipient_id, &request.sender_id, crypto) + .await?; self.accept_contact_request_replying_with(request, crypto, || async { self.send_private_contact_request( &request.recipient_id, @@ -654,6 +748,13 @@ impl DashPayView<'_, B> { /// ([`Self::accept_contact_request_with_external_signer`]). The one accept /// a host calls: which reply a request takes is the wallet's decision. /// `pay` runs only for a private reply. + /// + /// When the request came privately, or a private reply would be chosen, + /// the store is read to its end first if it was not since load, before + /// the reply is chosen: a reply paid for in an earlier process is then + /// adopted, with no payment. While that read cannot finish, the accept is + /// refused with [`PlatformWalletError::PrivateContactRequestPending`]. A + /// public request with no private one tracked reads nothing. pub async fn accept_contact_request_replying_in_kind( &self, request: &ContactRequest, @@ -668,6 +769,14 @@ impl DashPayView<'_, B> { F: FnOnce(BinaryData) -> Fut + Send, Fut: Future> + Send, { + if request.came_privately() || self.needs_private_reply(request).await { + self.read_private_store_before_sending( + &request.recipient_id, + &request.sender_id, + crypto, + ) + .await?; + } if self.needs_private_reply(request).await { self.accept_private_contact_request(request, shielded_address, signer, crypto, pay) .await @@ -1052,6 +1161,11 @@ impl DashPayView<'_, B> { identity.failed = true; report.unpersisted_identities.push(identity_id); } + // Read to the store's end with nothing held back this + // pass: every entry was ingested for this identity. + if read.is_complete() && !identity.failed { + managed.note_private_contact_requests_read(); + } tear_down_stale_external_accounts(wallet, info, &identity_id, rotated); candidates.push(( identity_id, @@ -2568,6 +2682,314 @@ mod tests { assert!(!pending_to(&restarted, ALICE, &bob).await); } + /// A `pay` that must not run. + fn no_payment( + _: BinaryData, + ) -> std::future::Ready> { + panic!("nothing is paid") + } + + /// Whether `wallet`'s identity `identity_id` read the private store to its + /// end since load. + async fn store_read(wallet: &PlatformWallet, identity_id: [u8; 32]) -> bool { + managed(wallet, identity_id, |managed| { + managed.dashpay().private_contact_requests_read() + }) + .await + } + + /// A private reply paid for and confirmed but not read back leaves + /// nothing that survives a restart. After one, with Alice's request + /// already known (as a sync that stopped between the two leaves it), the + /// accept a host calls reads the store first, finds the reply, and adopts + /// it: nothing is paid twice. + #[tokio::test] + async fn should_adopt_a_private_reply_paid_for_before_a_restart() { + let alice = identity(ALICE_PHRASE, ALICE); + let bob = identity(BOB_PHRASE, BOB); + let alice_crypto = SeedCryptoProvider::from_seed(seed(ALICE_PHRASE), Network::Testnet); + let bob_crypto = SeedCryptoProvider::from_seed(seed(BOB_PHRASE), Network::Testnet); + let alice_signer = signer(ALICE_PHRASE, &alice); + let bob_signer = signer(BOB_PHRASE, &bob); + let (_alice_manager, alice_wallet) = + wallet(ALICE_PHRASE, &alice, &[&alice, &bob], vec![]).await; + let kept = Arc::new(Mutex::new(Vec::new())); + alice_wallet + .identity() + .dashpay() + .send_private_contact_request( + &alice.id(), + &bob.id(), + None, + AutoAcceptProofSource::None, + None, + &alice_signer, + &alice_crypto, + append_entry(&alice_wallet, 0, vec![], &kept), + ) + .await + .expect("Alice sends the request"); + let request_entry = kept.lock().expect("kept").remove(0); + + let (_bob_manager, before) = wallet( + BOB_PHRASE, + &bob, + &[&alice, &bob], + vec![request_entry.to_vec()], + ) + .await; + before + .identity() + .dashpay() + .sync_private_contact_requests(&bob_crypto, 4) + .await + .expect("Bob reads the store"); + let received = managed(&before, BOB, |managed| { + managed.dashpay().incoming_request(&alice.id()).cloned() + }) + .await + .expect("Bob holds Alice's request"); + let reply = Arc::new(Mutex::new(Vec::new())); + let paid = before + .identity() + .dashpay() + .accept_contact_request_replying_in_kind( + &received, + None, + &bob_signer, + &bob_crypto, + keep_entry(&reply), + ) + .await; + assert!( + matches!( + paid, + Err(PlatformWalletError::PrivateContactRequestPending(_)) + ), + "paid and confirmed, not read back: {paid:?}" + ); + let reply_entry = reply.lock().expect("reply").remove(0); + + // The restart: the reply is in the store, the reservation is gone. + let (_restarted_manager, restarted) = wallet( + BOB_PHRASE, + &bob, + &[&alice, &bob], + vec![request_entry.to_vec(), reply_entry.to_vec()], + ) + .await; + { + let iw = restarted.identity(); + let mut wm = iw.wallet_manager.write().await; + wm.get_wallet_info_mut(&restarted.wallet_id()) + .expect("wallet info") + .identity_manager + .managed_identity_mut(&bob.id()) + .expect("Bob") + .add_incoming_contact_request(received.clone(), &iw.persister) + .expect("Alice's request"); + } + assert!(!store_read(&restarted, BOB).await); + let established = restarted + .identity() + .dashpay() + .accept_contact_request_replying_in_kind( + &received, + None, + &bob_signer, + &bob_crypto, + no_payment, + ) + .await + .expect("the reply read back is adopted"); + assert_eq!(established.contact_identity_id, alice.id()); + assert_eq!(established.incoming_request, received); + assert!(established.outgoing_request.came_privately()); + assert!(store_read(&restarted, BOB).await); + assert!(!pending_to(&restarted, BOB, &alice).await); + } + + /// The first private send since load reads the store first. While that + /// read cannot finish, here because the recipient of a request the store + /// holds from Alice cannot be read, it is refused with nothing sealed or + /// paid; a sync error comes back as it is; once the store reads, the send + /// goes on. + #[tokio::test] + async fn should_refuse_a_private_send_until_the_store_reads_to_its_end() { + let alice = identity(ALICE_PHRASE, ALICE); + let bob = identity(BOB_PHRASE, BOB); + let alice_crypto = SeedCryptoProvider::from_seed(seed(ALICE_PHRASE), Network::Testnet); + let alice_signer = signer(ALICE_PHRASE, &alice); + let (_manager, before) = wallet(ALICE_PHRASE, &alice, &[&alice, &bob], vec![]).await; + let kept = Arc::new(Mutex::new(Vec::new())); + before + .identity() + .dashpay() + .send_private_contact_request( + &alice.id(), + &bob.id(), + None, + AutoAcceptProofSource::None, + None, + &alice_signer, + &alice_crypto, + keep_entry(&kept), + ) + .await + .expect_err("not read back"); + let entry = kept.lock().expect("kept").remove(0); + + let (_restarted_manager, restarted) = + wallet(ALICE_PHRASE, &alice, &[&alice, &bob], vec![entry.to_vec()]).await; + let view = restarted.identity().dashpay(); + macro_rules! send { + ($pay:expr) => { + view.send_private_contact_request( + &alice.id(), + &bob.id(), + None, + AutoAcceptProofSource::None, + None, + &alice_signer, + &alice_crypto, + $pay, + ) + .await + }; + } + + set_identity_known(&restarted, &bob, false).await; + let held = send!(no_payment); + assert!( + matches!(held, Err(PlatformWalletError::PrivateContactRequestPending(recipient)) + if recipient == bob.id()), + "{held:?}" + ); + assert!(!store_read(&restarted, ALICE).await); + assert!( + !pending_to(&restarted, ALICE, &bob).await, + "nothing reserved" + ); + + let contract = dashpay_contract().expect("bundled DashPay"); + let page = PrivateDocumentEntriesQuery::new( + contract.id(), + PRIVATE_CONTACT_REQUEST_DOCUMENT_TYPE_NAME, + 0, + ); + { + let mut sdk = (*restarted.identity().sdk).clone(); + sdk.mock() + .remove_fetch_expectation::(page) + .await; + } + let failed = send!(no_payment); + assert!( + matches!(failed, Err(PlatformWalletError::Sdk(_))), + "{failed:?}" + ); + + publish(&restarted, 0, vec![entry.to_vec()]).await; + set_identity_known(&restarted, &bob, true).await; + let paid = Arc::new(Mutex::new(Vec::new())); + let sent = send!(keep_entry(&paid)); + assert!( + matches!( + sent, + Err(PlatformWalletError::PrivateContactRequestPending(_)) + ), + "paid, not read back: {sent:?}" + ); + assert_eq!(paid.lock().expect("paid").len(), 1, "the send goes on"); + assert!(store_read(&restarted, ALICE).await); + managed(&restarted, ALICE, |managed| { + assert!( + managed.dashpay().outgoing_request(&bob.id()).is_some(), + "the request paid for before the restart is recorded first" + ); + }) + .await; + } + + /// Once the store was read to its end, a private send reads nothing more + /// before it pays: here a page read would fail, and the send still pays. + #[tokio::test] + async fn should_read_the_private_store_once_since_load() { + let alice = identity(ALICE_PHRASE, ALICE); + let bob = identity(BOB_PHRASE, BOB); + let carol = identity(BOB_PHRASE, [0xC1; 32]); + let alice_crypto = SeedCryptoProvider::from_seed(seed(ALICE_PHRASE), Network::Testnet); + let alice_signer = signer(ALICE_PHRASE, &alice); + let (_manager, alice_wallet) = + wallet(ALICE_PHRASE, &alice, &[&alice, &bob, &carol], vec![]).await; + let view = alice_wallet.identity().dashpay(); + macro_rules! send { + ($recipient:expr, $kept:expr) => { + view.send_private_contact_request( + &alice.id(), + &$recipient, + None, + AutoAcceptProofSource::None, + None, + &alice_signer, + &alice_crypto, + keep_entry($kept), + ) + .await + }; + } + assert!(!store_read(&alice_wallet, ALICE).await); + let read_when_paid = Arc::new(AtomicBool::new(false)); + view.send_private_contact_request( + &alice.id(), + &bob.id(), + None, + AutoAcceptProofSource::None, + None, + &alice_signer, + &alice_crypto, + |_: BinaryData| { + let read_when_paid = Arc::clone(&read_when_paid); + let wallet = &alice_wallet; + async move { + read_when_paid.store(store_read(wallet, ALICE).await, Ordering::SeqCst); + Ok(ResponseMetadata { + time_ms: BLOCK_TIME_MS, + ..Default::default() + }) + } + }, + ) + .await + .expect_err("not read back"); + assert!( + read_when_paid.load(Ordering::SeqCst), + "the store was read before the first payment" + ); + + let contract = dashpay_contract().expect("bundled DashPay"); + { + let mut sdk = (*alice_wallet.identity().sdk).clone(); + sdk.mock() + .remove_fetch_expectation::( + PrivateDocumentEntriesQuery::new( + contract.id(), + PRIVATE_CONTACT_REQUEST_DOCUMENT_TYPE_NAME, + 0, + ), + ) + .await; + } + let second = Arc::new(Mutex::new(Vec::new())); + let sent = send!(carol.id(), &second); + assert!( + matches!(sent, Err(PlatformWalletError::PrivateContactRequestPending(recipient)) + if recipient == carol.id()), + "paid, its read back failing: {sent:?}" + ); + assert_eq!(second.lock().expect("second").len(), 1, "no read first"); + } + /// Once the account reference's rotation version is at its last, a /// request with the same xpub and key would repeat the tracked one: it is /// refused before anything is paid, and nothing stays pending. diff --git a/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/contact_requests.rs b/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/contact_requests.rs index 26bc4068019..7caefda41ce 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/contact_requests.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/contact_requests.rs @@ -799,6 +799,12 @@ impl ManagedIdentity { advance_if_unchanged(self.dashpay.high_water_received_ms, snapshot, max_fetched); } + /// Note that a private sync read DashPay's private contact request store + /// to its end for this identity, with nothing held back. + pub(crate) fn note_private_contact_requests_read(&mut self) { + self.dashpay.private_contact_requests_read = true; + } + /// Advance the private contact request cursor to `next_position` once /// every entry before it was read and what it held for this identity /// ingested. Never moves it back, and leaves it where a concurrent diff --git a/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/dashpay.rs b/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/dashpay.rs index e6369dcbfa8..f052125f022 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/dashpay.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/dashpay.rs @@ -114,6 +114,14 @@ pub struct DashPayState { /// [`ManagedIdentity::unignore_sender`]: super::ManagedIdentity::unignore_sender pub(super) private_contact_requests_position: u64, + /// Whether a private sync read DashPay's private contact request store + /// to its end for this identity since the wallet was loaded, with nothing + /// held back. Until then a private send or accept reads it first: a + /// request paid for in an earlier process may be in the store unread, and + /// the reservation that kept it from being paid twice did not survive. + /// In memory only; a cursor rewind leaves it set. + pub(super) private_contact_requests_read: bool, + /// The shielded address each contact's tracked private request to this /// identity carries. In memory only, rebuilt by the scan a cold start /// runs. Whether a request came privately is the request's own @@ -305,6 +313,12 @@ impl DashPayState { self.pending_private_replies.contains_key(contact) } + /// Whether a private sync read DashPay's private contact request store + /// to its end for this identity since load; see the field doc. + pub fn private_contact_requests_read(&self) -> bool { + self.private_contact_requests_read + } + /// How this identity and `contact` exchanged private requests, from the /// tracked requests either way; `None` when neither went privately. pub fn private_contact(&self, contact: &Identifier) -> Option { From b3a0ad36b23ee2268f80b744ff4066e2a4607420 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 9 Nov 2026 02:45:25 +0700 Subject: [PATCH 022/117] fix(wasm-dpp2): key the constructor's tokens by position as text, and test a multibyte label The contract format reads token positions as text keys, but the constructor handed its tokens to the parser keyed by integer, so every DataContract built with tokens failed to deserialize. Positions now go in as strings. The bounds test also checks that the account label's bound is in bytes: 32 two-byte characters (64 bytes) are refused when sealing and when reading. Co-Authored-By: Claude Opus 5.5 --- .../src/platform/dashpay/private_contact_request.rs | 7 +++++++ packages/wasm-dpp2/src/data_contract/model.rs | 9 +++++++-- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/packages/rs-sdk/src/platform/dashpay/private_contact_request.rs b/packages/rs-sdk/src/platform/dashpay/private_contact_request.rs index f80d6bf7a27..6dcb1cc2cba 100644 --- a/packages/rs-sdk/src/platform/dashpay/private_contact_request.rs +++ b/packages/rs-sdk/src/platform/dashpay/private_contact_request.rs @@ -1796,6 +1796,13 @@ mod tests { ), ..smallest_request(1) }, + // The bound is in bytes: 32 two-byte characters are 64 bytes. + PrivateContactRequest { + account_label: Some( + "é".repeat(PRIVATE_CONTACT_REQUEST_ACCOUNT_LABEL_MAX_BYTES.div_ceil(2)), + ), + ..smallest_request(1) + }, PrivateContactRequest { auto_accept_proof: Some(vec![ 0; diff --git a/packages/wasm-dpp2/src/data_contract/model.rs b/packages/wasm-dpp2/src/data_contract/model.rs index 57892aba6ed..dfadad8c7fa 100644 --- a/packages/wasm-dpp2/src/data_contract/model.rs +++ b/packages/wasm-dpp2/src/data_contract/model.rs @@ -379,9 +379,14 @@ impl DataContractWasm { .map_err(|err| WasmDppError::serialization(err.to_string()))?; // A document type's token costs are checked against the contract's tokens when the - // contract is validated, so the tokens are part of what is parsed. + // contract is validated, so the tokens are part of what is parsed. The contract format + // reads token positions as text keys. if !tokens.is_empty() { - let tokens_value = dpp::platform_value::to_value(&tokens) + let tokens_by_position: BTreeMap = tokens + .iter() + .map(|(position, token)| (position.to_string(), token)) + .collect(); + let tokens_value = dpp::platform_value::to_value(&tokens_by_position) .map_err(|e| WasmDppError::serialization(e.to_string()))?; contract_value .set_value("tokens", tokens_value) From f5dde309c4bfcf51c501a08a656bbb6b75552d0b Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 9 Nov 2026 03:09:45 +0700 Subject: [PATCH 023/117] fix(wasm-dpp2): refuse tokens for a contract that cannot hold them On a platform version that builds data contracts of structure version 0 (protocol version 8 and earlier), the constructor dropped the tokens it was given without saying so. It now refuses them. Co-Authored-By: Claude Opus 5.5 --- packages/wasm-dpp2/src/data_contract/model.rs | 14 ++++++++++++++ .../tests/unit/TokenConfiguration.spec.ts | 13 +++++++++++++ 2 files changed, 27 insertions(+) diff --git a/packages/wasm-dpp2/src/data_contract/model.rs b/packages/wasm-dpp2/src/data_contract/model.rs index dfadad8c7fa..d713e0946c6 100644 --- a/packages/wasm-dpp2/src/data_contract/model.rs +++ b/packages/wasm-dpp2/src/data_contract/model.rs @@ -323,6 +323,20 @@ impl DataContractWasm { tokens_configuration_from_js_value(v) })? .unwrap_or_default(); + // A contract of structure version 0 holds no tokens: refuse them rather than drop them. + if !tokens.is_empty() + && platform_version + .dpp + .contract_versions + .contract_structure_version + == 0 + { + return Err(WasmDppError::invalid_argument(format!( + "protocol version {} builds data contracts of structure version 0, which hold no \ + tokens", + platform_version.protocol_version + ))); + } // Extract primitive fields via serde last (consumes options) let opts: DataContractOptions = serde_wasm_bindgen::from_value(options.into()) diff --git a/packages/wasm-dpp2/tests/unit/TokenConfiguration.spec.ts b/packages/wasm-dpp2/tests/unit/TokenConfiguration.spec.ts index 568c15af1c7..3a54f6397ab 100644 --- a/packages/wasm-dpp2/tests/unit/TokenConfiguration.spec.ts +++ b/packages/wasm-dpp2/tests/unit/TokenConfiguration.spec.ts @@ -316,6 +316,19 @@ describe('TokenConfiguration', () => { expect(Object.keys(contract.tokens as Record)).to.deep.equal(['0']); }); + it('should refuse tokens on a platform version whose contracts hold none', () => { + // Protocol version 8 builds data contracts of structure version 0, which have no tokens. + expect(() => new wasm.DataContract({ + ownerId, + identityNonce: BigInt(5), + schemas: object.documentSchemas, + definitions: null, + fullValidation: false, + tokens: { 0: createConfiguration() }, + platformVersion: new wasm.PlatformVersion(8), + })).to.throw(/structure version 0/); + }); + it('should refuse a cost in a token the contract does not declare', () => { expect(() => new wasm.DataContract({ ownerId, From 410b38c1e9015295a22a5c79af46ab57d2c27080 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 9 Nov 2026 03:31:12 +0700 Subject: [PATCH 024/117] fix(sdk): refuse a scan of another store as DashPay private contact requests An entry opens only for the store it was sealed for, as its commitment binds the contract and type, but `private_contact_requests_from_scan` took any scan: a caller handing it a scan of another store's page would have that store's entries read as DashPay requests. An opened entry now keeps the store it was opened for (`contract_id`, `document_type_name`), and the reader refuses a scan holding an entry of any store but DashPay's privateContactRequest. Co-Authored-By: Claude Opus 5.5 --- .../src/private_document_envelope.rs | 15 ++++ .../dashpay/private_contact_request.rs | 68 +++++++++++++++++++ 2 files changed, 83 insertions(+) diff --git a/packages/rs-platform-encryption/src/private_document_envelope.rs b/packages/rs-platform-encryption/src/private_document_envelope.rs index 3ad0def302a..2d4ed1cc155 100644 --- a/packages/rs-platform-encryption/src/private_document_envelope.rs +++ b/packages/rs-platform-encryption/src/private_document_envelope.rs @@ -214,6 +214,8 @@ impl PrivateDocumentAuthMode { /// so what a caller reads is what was authenticated. #[derive(Clone, Debug, PartialEq, Eq)] pub struct OpenedPrivateDocumentEntry { + contract_id: [u8; 32], + document_type_name: String, commitment: [u8; 32], recipient_key_index: Option, recipient_public_key: PublicKey, @@ -227,6 +229,17 @@ pub struct OpenedPrivateDocumentEntry { } impl OpenedPrivateDocumentEntry { + /// The contract of the store the entry was opened for: its commitment binds that store, so + /// it opens for no other. + pub fn contract_id(&self) -> &[u8; 32] { + &self.contract_id + } + + /// The private document type of the store the entry was opened for. + pub fn document_type_name(&self) -> &str { + &self.document_type_name + } + /// The entry's commitment: two entries with the same one are copies. pub fn commitment(&self) -> &[u8; 32] { &self.commitment @@ -721,6 +734,8 @@ fn parse_body( payload, ); Ok(OpenedPrivateDocumentEntry { + contract_id: target.contract_id, + document_type_name: target.document_type_name.to_string(), commitment, recipient_key_index, recipient_public_key, diff --git a/packages/rs-sdk/src/platform/dashpay/private_contact_request.rs b/packages/rs-sdk/src/platform/dashpay/private_contact_request.rs index 6dcb1cc2cba..db21039a742 100644 --- a/packages/rs-sdk/src/platform/dashpay/private_contact_request.rs +++ b/packages/rs-sdk/src/platform/dashpay/private_contact_request.rs @@ -961,6 +961,26 @@ pub fn private_contact_requests_from_scan( identities: &BTreeMap, platform_version: &PlatformVersion, ) -> Result { + // An entry opens only for the store it was sealed for, but a scan of another store's page + // would hold entries of that store: refused, not read as DashPay requests. + let dashpay_id = dashpay_contract.id().to_buffer(); + if let Some(entry) = scan + .received + .iter() + .map(|received| &received.entry) + .chain(scan.sent.iter().map(|sent| &sent.entry)) + .find(|entry| { + entry.contract_id() != &dashpay_id + || entry.document_type_name() != PRIVATE_CONTACT_REQUEST_DOCUMENT_TYPE_NAME + }) + { + return Err(Error::Generic(format!( + "the scan holds an entry of the store {} {}, not DashPay's \ + {PRIVATE_CONTACT_REQUEST_DOCUMENT_TYPE_NAME}", + Identifier::from(*entry.contract_id()), + entry.document_type_name() + ))); + } for (identity_id, height) in private_contact_request_identities(&scan) { match identities.get(&identity_id) { None => { @@ -1397,6 +1417,7 @@ mod tests { use super::*; use crate::platform::private_document_entries::{PrivateDocumentEntries, PrivateDocumentEntry}; use dpp::address_funds::AddressWitness; + use dpp::data_contract::accessors::v0::DataContractV0Setters; use dpp::data_contract::document_type::accessors::DocumentTypeV0Getters; use dpp::data_contract::document_type::DocumentPropertyType; use dpp::identity::identity_public_key::contract_bounds::ContractBounds; @@ -2679,6 +2700,53 @@ mod tests { } } + /// A scan of another store's page is refused, received and sent entries alike, rather + /// than read as DashPay requests: here a store of another contract with DashPay's types. + #[test] + fn should_refuse_a_scan_of_another_store() { + let dashpay_contract = dashpay(); + let mut other = dashpay(); + other.set_id(Identifier::from([0x55; 32])); + let entry = seal(&other, &sender_identity(None), &smallest_request(1), 1); + let target = PrivateDocumentTarget { + contract_id: other.id().to_buffer(), + document_type_name: PRIVATE_CONTACT_REQUEST_DOCUMENT_TYPE_NAME, + }; + let sender_keys = PrivateDocumentScanKeys::new().with_sender( + Identifier::from(SENDER_ID), + 1, + private_contact_request_outgoing_viewing_key(&secret_key(1)), + ); + for (case, keys) in [("received", recipient_keys()), ("sent", sender_keys)] { + let scan = keys.scan_page(&target, 0, &page(vec![entry.clone()])); + assert_eq!(scan.received.len() + scan.sent.len(), 1, "{case}: it opens"); + let identities = private_contact_request_identities(&scan) + .into_keys() + .map(|identity_id| { + let identity = [sender_identity(None), recipient_identity()] + .into_iter() + .find(|identity| identity.id() == identity_id); + ( + identity_id, + ProvedIdentity { + identity, + proved_at_height: PROVED_AT_HEIGHT, + }, + ) + }) + .collect(); + let error = private_contact_requests_from_scan( + &dashpay_contract, + scan, + &identities, + PlatformVersion::latest(), + ) + .expect_err("another store") + .to_string(); + assert!(error.contains("not DashPay's"), "{case}: {error}"); + } + } + /// Two recovered sent requests to the same recipient for the same account reference are /// one request: the first is kept. A request the wallet both sent and received is kept on /// both sides. From 9ea232553773e869d59abfd6f2bde296f20432ef Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 9 Nov 2026 04:26:49 +0700 Subject: [PATCH 025/117] fix(platform-wallet): restore an unconfirmed rotation over the request it rotates The restart restore, at startup and in the hold a private payment runs, skipped a saved unconfirmed private contact request whenever its sender tracked any request to the recipient. With v0 recorded and a v1 rotation whose payment was unconfirmed at a restart, the store read recovered only v0, so a retried rotation derived v1 again, sealed another entry and could pay from other notes while the first v1 could still execute. Each saved entry's request is now decoded (dpp's private document payload decode, then `PrivateContactRequest::from_properties`), and the entry is discharged only once the request tracked to its recipient has the entry's account reference or a later version: its own request or a superseding one is recorded. A recorded v0 no longer hides an unconfirmed v1; a recorded v1 or newer, or an entry read back while its spend still waits, is skipped as before. An entry whose request does not decode is skipped, as no sync could record it. Co-Authored-By: Claude Opus 5.5 --- .../identity/network/contact_requests.rs | 11 + .../network/private_contact_requests.rs | 259 +++++++++++++++++- 2 files changed, 258 insertions(+), 12 deletions(-) diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs b/packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs index 62f8679298d..271270d8690 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs @@ -1233,6 +1233,17 @@ pub(super) fn predates(candidate: &ContactRequest, tracked: &ContactRequest) -> freshness(candidate) < freshness(tracked) } +/// Whether `tracked`, a request between one pair, is an unconfirmed request +/// carrying `account_reference` or supersedes it in the order [`predates`] +/// reads: the same reference, or a later version. The unconfirmed request is +/// not in the store yet, so it has no creation time; appended, it would be +/// the newest of its version. +pub(super) fn records_or_supersedes(tracked: &ContactRequest, account_reference: u32) -> bool { + tracked.account_reference == account_reference + || platform_encryption::account_reference_version(account_reference) + < platform_encryption::account_reference_version(tracked.account_reference) +} + /// How fresh a request between one pair is, for collapsing a sweep's requests /// and for passing over older ones alike: by its account reference's version, /// which a rotation bumps, then by its creation time, then by the reference diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/private_contact_requests.rs b/packages/rs-platform-wallet/src/wallet/identity/network/private_contact_requests.rs index 2dadeab9cb8..bceb724b8e6 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/private_contact_requests.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/private_contact_requests.rs @@ -43,6 +43,7 @@ use dash_sdk::platform::private_document_entries::{ use dash_sdk::platform::private_document_scan::PrivateDocumentScanKeys; use dash_sdk::platform::Fetch; use dpp::data_contract::accessors::v0::DataContractV0Getters; +use dpp::data_contract::document_type::decode_private_document_payload; use dpp::identity::accessors::IdentityGettersV0; use dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; use dpp::identity::identity_public_key::Purpose; @@ -61,8 +62,8 @@ use zeroize::Zeroize; use super::contact_requests::{ ingest_received_requests, ingest_sent_requests, newest_received_per_sender, - newest_sent_per_recipient, predates, tear_down_stale_external_accounts, AutoAcceptProofSource, - ContactCryptoProvider, PreparedContactRequest, + newest_sent_per_recipient, predates, records_or_supersedes, tear_down_stale_external_accounts, + AutoAcceptProofSource, ContactCryptoProvider, PreparedContactRequest, }; use super::signing_key::first_available; use super::{dashpay_contract, DashPayView, IdentityWallet}; @@ -1204,12 +1205,17 @@ impl DashPayView<'_, B> { /// entry's commitment only when it has none yet. Returns the (sender, /// recipient) pairs restored. /// + /// An entry is discharged once its own request or a superseding one is + /// recorded: it is left alone when the request the sender tracks to its + /// recipient has the entry's account reference, or a later version. So an + /// unconfirmed rotation is restored over the request it rotates, and an + /// entry read back while its spend still waits for confirmation is not. + /// /// What it does not cover: - /// - An entry to a recipient the sender already tracks a request to is - /// left alone: it was recorded, or it is a rotation still in flight, - /// which is not restored. /// - A reservation whose payment then fails for good stays until the /// next restart, as one kept in this process does. + /// - An entry whose request does not decode is left alone: no sync could + /// record it, so its reservation would never settle. pub async fn restore_pending_private_contact_requests( &self, entries: &[BinaryData], @@ -1223,10 +1229,12 @@ impl DashPayView<'_, B> { } /// Whether a request from `sender` to `recipient` among `entries`, paid - /// for before a restart and unconfirmed, may still execute: then the - /// recipient stays reserved for that entry instead of the one about to - /// be paid, which must not be. The other entries are restored as - /// [`Self::restore_pending_private_contact_requests`] restores them. + /// for before a restart and unconfirmed, may still execute, neither it nor + /// a later request being recorded (a rotation over the tracked request + /// counts): then the recipient stays reserved for that entry instead of + /// the one about to be paid, which must not be. The other entries are + /// restored as [`Self::restore_pending_private_contact_requests`] restores + /// them. #[cfg(any(feature = "shielded", test))] pub(crate) async fn hold_for_restored_private_contact_request( &self, @@ -1246,8 +1254,9 @@ impl DashPayView<'_, B> { } /// Open `entries` as one page of the store and reserve the recipient of - /// each the wallet's identities sent and do not track yet; the pair - /// `paying` is retargeted to its restored entry instead. + /// each the wallet's identities sent whose request is neither recorded + /// nor superseded yet; the pair `paying` is retargeted to its restored + /// entry instead. async fn restore_private_reservations( &self, entries: &[BinaryData], @@ -1265,6 +1274,14 @@ impl DashPayView<'_, B> { return Ok(Vec::new()); } let contract = dashpay_contract()?; + let document_type = contract + .document_type_for_name(PRIVATE_CONTACT_REQUEST_DOCUMENT_TYPE_NAME) + .map_err(|error| { + PlatformWalletError::InvalidIdentityData(format!( + "the bundled DashPay has no {PRIVATE_CONTACT_REQUEST_DOCUMENT_TYPE_NAME} type: \ + {error}" + )) + })?; let page = PrivateDocumentEntries { entries: entries .iter() @@ -1300,10 +1317,37 @@ impl DashPayView<'_, B> { continue; } let recipient = Identifier::from(*sent.entry.recipient_identity_id()); + let request = decode_private_document_payload( + document_type, + sent.entry.payload(), + self.sdk.version(), + ) + .map_err(|error| error.to_string()) + .and_then(|properties| { + PrivateContactRequest::from_properties(properties) + .map_err(|error| error.to_string()) + }); + let request = match request { + Ok(request) => request, + Err(error) => { + tracing::warn!( + sender = %sender, + recipient = %recipient, + %error, + "an unconfirmed private contact request does not decode; not restored" + ); + continue; + } + }; let Some(managed) = info.identity_manager.managed_identity_mut(&sender) else { continue; }; - if managed.dashpay().outgoing_request(&recipient).is_some() { + // Recorded, or superseded by a later request: discharged. + if managed + .dashpay() + .outgoing_request(&recipient) + .is_some_and(|tracked| records_or_supersedes(tracked, request.account_reference)) + { continue; } let commitment = *sent.entry.commitment(); @@ -2682,6 +2726,197 @@ mod tests { assert!(!pending_to(&restarted, ALICE, &bob).await); } + /// A rotation whose payment is unconfirmed at a restart, over a request + /// that was recorded, is restored: the recorded request is older, so it + /// does not discharge the rotation. Until the rotation is read back, a + /// retried rotation is refused unpaid, whether the host restored at + /// startup or the payment finds it; once it is recorded, its entry, and + /// the older one, restore nothing. + #[tokio::test] + async fn should_restore_a_rotation_paid_for_before_a_restart() { + let alice = identity(ALICE_PHRASE, ALICE); + let bob = identity(BOB_PHRASE, BOB); + let (alice_id, bob_id) = (alice.id(), bob.id()); + let alice_crypto = SeedCryptoProvider::from_seed(seed(ALICE_PHRASE), Network::Testnet); + let alice_signer = signer(ALICE_PHRASE, &alice); + let (_manager, before) = wallet(ALICE_PHRASE, &alice, &[&alice, &bob], vec![]).await; + let kept = Arc::new(Mutex::new(Vec::new())); + let v0 = before + .identity() + .dashpay() + .send_private_contact_request( + &alice_id, + &bob_id, + None, + AutoAcceptProofSource::None, + None, + &alice_signer, + &alice_crypto, + append_entry(&before, 0, vec![], &kept), + ) + .await + .expect("v0 is recorded"); + assert_eq!(account_reference_version(v0.account_reference), 0); + let v0_entry = kept.lock().expect("kept").remove(0); + let unconfirmed = { + let kept = Arc::clone(&kept); + move |entry: BinaryData| { + kept.lock().expect("kept").push(entry); + std::future::ready(Err::( + PlatformWalletError::ShieldedSpendUnconfirmed { + operation: "private document create", + reason: "the proof fetch timed out".to_string(), + }, + )) + } + }; + let rotation = before + .identity() + .dashpay() + .send_private_contact_request( + &alice_id, + &bob_id, + None, + AutoAcceptProofSource::None, + None, + &alice_signer, + &alice_crypto, + unconfirmed, + ) + .await; + assert!( + matches!( + rotation, + Err(PlatformWalletError::ShieldedSpendUnconfirmed { .. }) + ), + "{rotation:?}" + ); + let v1_entry = kept.lock().expect("kept").remove(0); + + // A restart: the store holds v0 only, which the restarted wallet + // records. The host restores the unconfirmed rotation. + let (_restarted_manager, restarted) = wallet( + ALICE_PHRASE, + &alice, + &[&alice, &bob], + vec![v0_entry.to_vec()], + ) + .await; + let view = restarted.identity().dashpay(); + view.sync_private_contact_requests(&alice_crypto, 4) + .await + .expect("the restarted wallet reads the store"); + managed(&restarted, ALICE, |managed| { + assert_eq!(managed.dashpay().outgoing_request(&bob_id), Some(&v0)); + }) + .await; + assert_eq!( + view.restore_pending_private_contact_requests( + std::slice::from_ref(&v1_entry), + &alice_crypto + ) + .await + .expect("restored"), + vec![(alice_id, bob_id)], + "a recorded v0 does not discharge the v1 rotation" + ); + let retried = view + .send_private_contact_request( + &alice_id, + &bob_id, + None, + AutoAcceptProofSource::None, + None, + &alice_signer, + &alice_crypto, + no_payment, + ) + .await; + assert!( + matches!(retried, Err(PlatformWalletError::PrivateContactRequestPending(recipient)) + if recipient == bob_id), + "{retried:?}" + ); + + // Another restart, the host restoring nothing: the payment of the + // retried rotation finds the unconfirmed one and pays nothing. + let (_held_manager, held_wallet) = wallet( + ALICE_PHRASE, + &alice, + &[&alice, &bob], + vec![v0_entry.to_vec()], + ) + .await; + let held_view = held_wallet.identity().dashpay(); + let paid = Arc::new(Mutex::new(Vec::new())); + let unconfirmed_entries = vec![v1_entry.clone()]; + let held = held_view + .send_private_contact_request( + &alice_id, + &bob_id, + None, + AutoAcceptProofSource::None, + None, + &alice_signer, + &alice_crypto, + |entry: BinaryData| { + let paid = Arc::clone(&paid); + let unconfirmed_entries = &unconfirmed_entries; + let crypto = &alice_crypto; + async move { + if held_view + .hold_for_restored_private_contact_request( + unconfirmed_entries, + crypto, + &alice_id, + &bob_id, + ) + .await? + { + return Err(PlatformWalletError::PrivateContactRequestPending(bob_id)); + } + paid.lock().expect("paid").push(entry); + Ok(ResponseMetadata::default()) + } + }, + ) + .await; + assert!( + matches!(held, Err(PlatformWalletError::PrivateContactRequestPending(recipient)) + if recipient == bob_id), + "{held:?}" + ); + assert!(paid.lock().expect("paid").is_empty(), "nothing is paid"); + + // v1 lands and is read back, after v0 where each wallet's cursor is: + // settled, and discharged. + for wallet in [&restarted, &held_wallet] { + publish(wallet, 1, vec![v1_entry.to_vec()]).await; + wallet + .identity() + .dashpay() + .sync_private_contact_requests(&alice_crypto, 4) + .await + .expect("the store is read"); + managed(wallet, ALICE, |managed| { + assert!(!managed.dashpay().private_reply_pending(&bob_id)); + let tracked = managed + .dashpay() + .outgoing_request(&bob_id) + .expect("tracked"); + assert_eq!(account_reference_version(tracked.account_reference), 1); + }) + .await; + } + assert_eq!( + view.restore_pending_private_contact_requests(&[v1_entry, v0_entry], &alice_crypto) + .await + .expect("restored"), + vec![], + "a recorded rotation, and the request it rotated, restore nothing" + ); + } + /// A `pay` that must not run. fn no_payment( _: BinaryData, From ab68754a1677436ea4b2cb2f72edec1a1a5382f9 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 9 Nov 2026 04:37:34 +0700 Subject: [PATCH 026/117] refactor(dpp): give the document signing levels of a security requirement one home The levels a key may sign a document at, given its type's `security_level_requirement` (MASTER alone for a MASTER requirement, otherwise CRITICAL to the requirement), were computed in `DocumentsBatchTransitionMethodsV0::combined_security_level_requirement` and again in the SDK's `private_contact_request_signing_security_levels`. `SecurityLevel::document_signing_levels` now computes them. The batch method returns the levels of its strongest requirement through it, with the same outputs as before (pinned for each requirement, a mixed batch, a token batch and an empty one), and without the `unwrap` its range conversion used. The SDK function delegates to it. Co-Authored-By: Claude Opus 5.5 --- .../identity_public_key/security_level.rs | 43 +++++++++++++ .../batch_transition/methods/v0/mod.rs | 10 +-- .../document/batch_transition/tests.rs | 63 +++++++++++++++++++ .../dashpay/private_contact_request.rs | 17 ++--- 4 files changed, 113 insertions(+), 20 deletions(-) diff --git a/packages/rs-dpp/src/identity/identity_public_key/security_level.rs b/packages/rs-dpp/src/identity/identity_public_key/security_level.rs index eacf4c4f2e8..bb299f0d26a 100644 --- a/packages/rs-dpp/src/identity/identity_public_key/security_level.rs +++ b/packages/rs-dpp/src/identity/identity_public_key/security_level.rs @@ -105,6 +105,21 @@ impl SecurityLevel { pub fn stronger_or_equal_security_than(self: SecurityLevel, rhs: SecurityLevel) -> bool { (self as u8) <= (rhs as u8) } + + /// The security levels a key may sign a document at when the document's type requires + /// `self` (its `security_level_requirement`): `MASTER` alone when the requirement is + /// `MASTER`, otherwise every level from `CRITICAL` to the requirement, strongest first. A + /// batch takes the levels of the strongest requirement among its documents + /// (`DocumentsBatchTransitionMethodsV0::combined_security_level_requirement`). + pub fn document_signing_levels(self) -> Vec { + if self == Self::MASTER { + return vec![Self::MASTER]; + } + Self::full_range() + .into_iter() + .filter(|level| *level != Self::MASTER && (*level as u8) <= (self as u8)) + .collect() + } } impl std::fmt::Display for SecurityLevel { @@ -117,6 +132,34 @@ impl std::fmt::Display for SecurityLevel { mod tests { use super::*; + /// Each requirement admits `MASTER` alone, or `CRITICAL` to itself, as a document batch + /// has always computed it. + #[test] + fn should_give_the_levels_each_document_requirement_admits() { + use SecurityLevel::*; + assert_eq!(MASTER.document_signing_levels(), vec![MASTER]); + assert_eq!(CRITICAL.document_signing_levels(), vec![CRITICAL]); + assert_eq!(HIGH.document_signing_levels(), vec![CRITICAL, HIGH]); + assert_eq!( + MEDIUM.document_signing_levels(), + vec![CRITICAL, HIGH, MEDIUM] + ); + for requirement in SecurityLevel::full_range() { + let range: Vec = if requirement == MASTER { + vec![MASTER] + } else { + (CRITICAL as u8..=requirement as u8) + .map(|level| SecurityLevel::try_from(level).expect("a security level")) + .collect() + }; + assert_eq!( + requirement.document_signing_levels(), + range, + "{requirement}" + ); + } + } + // -- TryFrom valid -- #[test] fn test_security_level_try_from_u8_all_valid() { diff --git a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/methods/v0/mod.rs b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/methods/v0/mod.rs index 01d042c7a8a..a1175d47da0 100644 --- a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/methods/v0/mod.rs +++ b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/methods/v0/mod.rs @@ -19,7 +19,6 @@ use crate::ProtocolError; use platform_value::Identifier; #[cfg(feature = "state-transition-signing")] use platform_version::version::PlatformVersion; -use std::convert::TryFrom; use crate::state_transition::batch_transition::batched_transition::{BatchedTransition, BatchedTransitionRef}; #[cfg(feature = "state-transition-signing")] use crate::state_transition::batch_transition::methods::StateTransitionCreationOptions; @@ -165,14 +164,7 @@ pub trait DocumentsBatchTransitionMethodsV0: DocumentsBatchTransitionAccessorsV0 } }; - Ok(if highest_security_level == SecurityLevel::MASTER { - vec![SecurityLevel::MASTER] - } else { - // this might seem wrong until you realize that master is 0, critical 1, etc - (SecurityLevel::CRITICAL as u8..=highest_security_level as u8) - .map(|security_level| SecurityLevel::try_from(security_level).unwrap()) - .collect() - }) + Ok(highest_security_level.document_signing_levels()) } fn set_transitions(&mut self, transitions: Vec); diff --git a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/tests.rs b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/tests.rs index d55399d2319..7c344386196 100644 --- a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/tests.rs +++ b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/tests.rs @@ -858,6 +858,69 @@ mod batch_transition_tests { assert_eq!(purposes, vec![Purpose::AUTHENTICATION]); } + // ----------------------------------------------------------------------- + // combined_security_level_requirement + // ----------------------------------------------------------------------- + + /// The levels a batch may be signed at, pinned for each document requirement, for the + /// strongest of two requirements, for a token transition and for an empty batch: they + /// come from `SecurityLevel::document_signing_levels` and must not change. + #[test] + fn should_keep_the_levels_a_batch_may_be_signed_at() { + use SecurityLevel::*; + let documents = make_batch_v1(vec![ + BatchedTransition::Document(make_delete_transition(1)), + BatchedTransition::Document(make_delete_transition(2)), + ]); + for (requirement, expected) in [ + (MASTER, vec![MASTER]), + (CRITICAL, vec![CRITICAL]), + (HIGH, vec![CRITICAL, HIGH]), + (MEDIUM, vec![CRITICAL, HIGH, MEDIUM]), + ] { + let levels = documents + .combined_security_level_requirement(Some( + move |_: Identifier, _: String| -> Result { + Ok(requirement) + }, + )) + .expect("the levels"); + assert_eq!(levels, expected, "{requirement}"); + } + + // The strongest requirement among the batch's documents wins. + let calls = std::cell::Cell::new(0); + let mixed = documents + .combined_security_level_requirement(Some( + |_: Identifier, _: String| -> Result { + calls.set(calls.get() + 1); + Ok(if calls.get() == 1 { MEDIUM } else { HIGH }) + }, + )) + .expect("the levels"); + assert_eq!(mixed, vec![CRITICAL, HIGH]); + + let token = make_batch_v1(vec![BatchedTransition::Token(make_token_burn_transition( + 1, 10, + ))]); + assert_eq!( + token + .combined_security_level_requirement( + None:: Result> + ) + .expect("the levels"), + vec![CRITICAL] + ); + assert_eq!( + make_batch_v1(vec![]) + .combined_security_level_requirement( + None:: Result> + ) + .expect("the levels"), + vec![CRITICAL, HIGH, MEDIUM] + ); + } + // ----------------------------------------------------------------------- // get_security_level_requirement function // ----------------------------------------------------------------------- diff --git a/packages/rs-sdk/src/platform/dashpay/private_contact_request.rs b/packages/rs-sdk/src/platform/dashpay/private_contact_request.rs index db21039a742..920554af14e 100644 --- a/packages/rs-sdk/src/platform/dashpay/private_contact_request.rs +++ b/packages/rs-sdk/src/platform/dashpay/private_contact_request.rs @@ -232,20 +232,15 @@ pub const PRIVATE_CONTACT_REQUEST_SIGNING_KEY_TYPES: [KeyType; 2] = [KeyType::ECDSA_SECP256K1, KeyType::ECDSA_HASH160]; /// The security levels an `AUTHENTICATION` key may sign a private contact request at: the ones -/// Platform lets a document of the type be signed at, from `CRITICAL` to the type's requirement -/// (`HIGH` unless the contract sets another). `MASTER` keys sign no documents. +/// Platform lets a document of the type be signed at +/// ([`SecurityLevel::document_signing_levels`] of the type's requirement), from `CRITICAL` to +/// the requirement (`HIGH` unless the contract sets another). `MASTER` keys sign no documents. pub fn private_contact_request_signing_security_levels( dashpay_contract: &DataContract, ) -> Result, Error> { - let requirement = private_contact_request_type(dashpay_contract)?.security_level_requirement(); - Ok(if requirement == SecurityLevel::MASTER { - vec![SecurityLevel::MASTER] - } else { - SecurityLevel::full_range() - .into_iter() - .filter(|level| *level != SecurityLevel::MASTER && *level as u8 <= requirement as u8) - .collect() - }) + Ok(private_contact_request_type(dashpay_contract)? + .security_level_requirement() + .document_signing_levels()) } /// What a private contact request's sender signs, before sealing it: the entry's signing data From 1bd378852450c8f1de6004262225988dd9fc4a66 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 9 Nov 2026 04:40:16 +0700 Subject: [PATCH 027/117] fix(sdk): read a private contact request only through a key it could be sealed to The receive side checked the recipient key's ownership, disablement and bounds, but not the purpose and type sealing requires: an entry that opened with the point of an AUTHENTICATION or TRANSFER key, or matched an ECDSA_HASH160 record of it, was read. Sealing and reading now share one check, `recipient_key`: an ECDSA_SECP256K1 DECRYPTION or ENCRYPTION key, enabled at the given time and bound, if at all, to DashPay's contact requests. Reading runs it at the entry's block time and then compares the point; the private format has no older keys to tolerate. Such entries are refused with `RecipientKeyNotUsable`. Co-Authored-By: Claude Opus 5.5 --- .../dashpay/private_contact_request.rs | 138 +++++++++++++----- 1 file changed, 104 insertions(+), 34 deletions(-) diff --git a/packages/rs-sdk/src/platform/dashpay/private_contact_request.rs b/packages/rs-sdk/src/platform/dashpay/private_contact_request.rs index 920554af14e..fd454292c21 100644 --- a/packages/rs-sdk/src/platform/dashpay/private_contact_request.rs +++ b/packages/rs-sdk/src/platform/dashpay/private_contact_request.rs @@ -431,23 +431,8 @@ impl SealInputs { encryption_key(sender, encryption_key_id, dashpay_contract.id(), None) .map_err(Error::Generic)?; - let recipient_key = recipient - .get_public_key_by_id(recipient_key_id) - .ok_or_else(|| { - Error::Generic(format!( - "the recipient identity {} has no key {recipient_key_id}", - recipient.id() - )) - })?; - if !recipient_key_purpose_is_valid(recipient_key.purpose()) { - return Err(Error::Generic(format!( - "the recipient key {recipient_key_id} is a {} key, not a DECRYPTION or \ - ENCRYPTION key", - recipient_key.purpose() - ))); - } let recipient_public_key = - ecdh_public_key(recipient_key, "recipient", dashpay_contract.id(), None) + recipient_key(recipient, recipient_key_id, dashpay_contract.id(), None) .map_err(Error::Generic)?; let properties = request.to_properties(); @@ -506,6 +491,31 @@ fn encryption_key( ecdh_public_key(key, "sender", dashpay_id, at_ms) } +/// The public key of `recipient`'s key `key_id` that a private contact request may be +/// encrypted to: an `ECDSA_SECP256K1` `DECRYPTION` or `ENCRYPTION` key, enabled at `at_ms` (now +/// when `None`) and bound, if at all, to DashPay's contact requests. Sealing checks it now and +/// reading at the entry's block time; the private format has no older keys to tolerate. +fn recipient_key( + recipient: &Identity, + key_id: KeyID, + dashpay_id: Identifier, + at_ms: Option, +) -> Result { + let key = recipient.get_public_key_by_id(key_id).ok_or_else(|| { + format!( + "the recipient identity {} has no key {key_id}", + recipient.id() + ) + })?; + if !recipient_key_purpose_is_valid(key.purpose()) { + return Err(format!( + "the recipient key {key_id} is a {} key, not a DECRYPTION or ENCRYPTION key", + key.purpose() + )); + } + ecdh_public_key(key, "recipient", dashpay_id, at_ms) +} + /// The public key of an `ECDSA_SECP256K1` identity key not disabled at `at_ms` (now when /// `None`), and bound, if at all, to the DashPay contract `dashpay_id` or its contact requests, /// which ECDH runs on. A key disabled after `at_ms` was not disabled then: keys are disabled, @@ -1133,26 +1143,23 @@ impl EntryReader<'_> { received: &ReceivedPrivateDocumentEntry, ) -> Result { self.check_sender(&received.entry, received.block_time_ms)?; + // The key the entry opened with, checked as sealing checks it, at the entry's time. let recipient_key_problem = match self.identity(&received.recipient_identity_id) { None => Some("the recipient identity does not exist".to_string()), - Some(recipient) => match recipient.get_public_key_by_id(received.recipient_key_id) { - None => Some(format!( - "the recipient has no key {}", - received.recipient_key_id - )), - Some(key) if !key_is(key, received.entry.recipient_public_key()) => Some(format!( - "the recipient key {} is not the key the entry is encrypted to", - received.recipient_key_id - )), - Some(key) => disabled_problem(key, "recipient", Some(received.block_time_ms)) - .or_else(|| { - bounds_problem( - key, - "recipient", - self.dashpay_id, - &PRIVATE_CONTACT_REQUEST_ENCRYPTION_KEY_DOCUMENT_TYPES, - ) - }), + Some(recipient) => match recipient_key( + recipient, + received.recipient_key_id, + self.dashpay_id, + Some(received.block_time_ms), + ) { + Err(problem) => Some(problem), + Ok(public_key) if public_key != *received.entry.recipient_public_key() => { + Some(format!( + "the recipient key {} is not the key the entry is encrypted to", + received.recipient_key_id + )) + } + Ok(_) => None, }, }; if let Some(problem) = recipient_key_problem { @@ -2208,6 +2215,69 @@ mod tests { )); } + /// A request is read only through a recipient key sealing would have encrypted to: one + /// whose record is an AUTHENTICATION or TRANSFER key holding the point it opened with, or + /// an ECDSA_HASH160 record of that point, is refused. + #[test] + fn should_refuse_a_request_through_a_key_it_could_not_be_sealed_to() { + let contract = dashpay(); + let entry = seal(&contract, &sender_identity(None), &smallest_request(1), 1); + let read_with = |recipient_key: IdentityPublicKey| { + reasons(read( + &contract, + vec![entry.clone()], + &recipient_keys(), + &[ + sender_identity(None), + with_key(recipient_identity(), recipient_key), + ], + )) + }; + assert_eq!( + read_with(key( + 2, + Purpose::DECRYPTION, + KeyType::ECDSA_SECP256K1, + &secret_key(2), + None + )), + vec![], + "the key it was sealed to" + ); + for purpose in [Purpose::AUTHENTICATION, Purpose::TRANSFER] { + assert!( + matches!( + read_with(key( + 2, + purpose, + KeyType::ECDSA_SECP256K1, + &secret_key(2), + None + )) + .as_slice(), + [PrivateContactRequestRejection::RecipientKeyNotUsable(_)] + ), + "{purpose}" + ); + } + let hash160 = IdentityPublicKey::V0(IdentityPublicKeyV0 { + id: 2, + purpose: Purpose::DECRYPTION, + security_level: SecurityLevel::MEDIUM, + contract_bounds: None, + key_type: KeyType::ECDSA_HASH160, + read_only: false, + data: BinaryData::new( + ripemd160_sha256(&PublicKey::from_secret_key(&secret_key(2)).serialize()).to_vec(), + ), + disabled_at: None, + }); + assert!(matches!( + read_with(hash160).as_slice(), + [PrivateContactRequestRejection::RecipientKeyNotUsable(_)] + )); + } + /// An entry the sender's outgoing viewing key recovers is the sender's only when it names /// the identity and key that key is derived from and one of the identity's signing keys /// signed it: someone holding the encryption key could build the others. From 609f52944b078529be495dcb3ba2e277ad86f98e Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 9 Nov 2026 04:40:57 +0700 Subject: [PATCH 028/117] fix(sdk): wipe the outgoing viewing key a private contact request is sealed with `seal_private_contact_request` held the outgoing viewing key derived from the sender's encryption key in a plain array that was never wiped. It is now held in `Zeroizing`, so it is erased on every return path. Co-Authored-By: Claude Opus 5.5 --- .../src/platform/dashpay/private_contact_request.rs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/rs-sdk/src/platform/dashpay/private_contact_request.rs b/packages/rs-sdk/src/platform/dashpay/private_contact_request.rs index fd454292c21..6d092597991 100644 --- a/packages/rs-sdk/src/platform/dashpay/private_contact_request.rs +++ b/packages/rs-sdk/src/platform/dashpay/private_contact_request.rs @@ -60,7 +60,7 @@ use platform_encryption::{ }; use std::collections::{BTreeMap, BTreeSet}; use std::sync::Arc; -use zeroize::Zeroize; +use zeroize::{Zeroize, Zeroizing}; /// The DashPay document type of private contact requests. pub const PRIVATE_CONTACT_REQUEST_DOCUMENT_TYPE_NAME: &str = "privateContactRequest"; @@ -376,8 +376,10 @@ pub fn seal_private_contact_request( ))); } - let outgoing_viewing_key = - private_contact_request_outgoing_viewing_key(sender.encryption_secret_key); + // Wiped on every return. + let outgoing_viewing_key = Zeroizing::new(private_contact_request_outgoing_viewing_key( + sender.encryption_secret_key, + )); let entry = seal_private_document_entry( &inputs.target(), inputs.entry_size, From 5839100bcd69f2ecd6f9dcf8c9aecfd65d788169 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 9 Nov 2026 04:45:30 +0700 Subject: [PATCH 029/117] fix(sdk): give a private page reader's context provider the contract with the private type A client whose context provider holds a contract from before its private type was declared, as a wallet hydrated across the protocol-15 upgrade holds DashPay v2, could not verify a page of the type's store: `PrivateDocumentEntries::FromProof` resolves the contract from the provider and refuses one without the type. Only the DashPay reader refreshed the contract first. `Sdk::fetch_private_document_contract(contract_id, document_type_name)` now does that for any private type: it uses a held contract that declares the type and never replaces it, otherwise fetches the contract, refuses one without the type, gives it to the provider, reads it back and fails when the provider still holds none with the type. `fetch_private_contact_request_contract` calls it after its protocol gate, and `Sdk::scan_private_document_page` resolves the contract through it before fetching the page. The test context provider moves next to it to be shared. Co-Authored-By: Claude Opus 5.5 --- .../dashpay/private_contact_request.rs | 165 +++++++----------- .../src/platform/private_document_entries.rs | 138 ++++++++++++++- .../src/platform/private_document_scan.rs | 27 ++- 3 files changed, 226 insertions(+), 104 deletions(-) diff --git a/packages/rs-sdk/src/platform/dashpay/private_contact_request.rs b/packages/rs-sdk/src/platform/dashpay/private_contact_request.rs index 6d092597991..1ab12688101 100644 --- a/packages/rs-sdk/src/platform/dashpay/private_contact_request.rs +++ b/packages/rs-sdk/src/platform/dashpay/private_contact_request.rs @@ -29,7 +29,6 @@ use crate::platform::private_document_scan::{ }; use crate::platform::{Fetch, Identifier}; use crate::{Error, Sdk}; -use dash_context_provider::ContextProvider; use dpp::data_contract::accessors::v0::DataContractV0Getters; use dpp::data_contract::document_type::accessors::{DocumentTypeV0Getters, DocumentTypeV2Getters}; use dpp::data_contract::document_type::property_constraints::DocumentSystemValues; @@ -714,6 +713,7 @@ fn key_is(key: &IdentityPublicKey, public_key: &PublicKey) -> bool { } /// Whether `contract`, the DashPay a context provider holds, declares private contact requests. +#[cfg(test)] fn holds_private_contact_requests(contract: Option<&DataContract>) -> bool { contract.is_some_and(|contract| private_contact_request_type(contract).is_ok()) } @@ -1319,46 +1319,19 @@ impl Sdk { } /// The DashPay contract, with its `privateContactRequest` type, as the context provider - /// holds it: a page's proof is verified against the contract the provider gives. The - /// network's protocol version is established as 15 or later first, as an unpinned SDK starts - /// below it and would otherwise resolve a DashPay without the type. + /// holds it ([`Sdk::fetch_private_document_contract`]): a page's proof is verified against + /// the contract the provider gives. The network's protocol version is established as 15 or + /// later first, as an unpinned SDK starts below it and would otherwise resolve a DashPay + /// without the type. async fn fetch_private_contact_request_contract(&self) -> Result, Error> { self.require_protocol_version( PRIVATE_DOCUMENT_CREATE_FROM_SHIELDED_POOL_INITIAL_PROTOCOL_VERSION, "DashPay private contact requests", ) .await?; - let mut contract = self.fetch_dashpay_contract().await?; - let contract_id = contract.id(); - if private_contact_request_type(&contract).is_err() { - // The context provider may hold a DashPay from before the type, kept from an older - // version: the network's is current. - contract = DataContract::fetch(self, contract_id) - .await? - .map(Arc::new) - .ok_or_else(|| { - Error::Generic(format!("the DashPay contract {contract_id} is gone")) - })?; - private_contact_request_type(&contract)?; - } - if let Some(provider) = self.context_provider() { - // A DashPay fetched from the network, first or again, is given to the provider. One - // that still gives none with the type would fail every page: that is refused here, - // once. - let held = provider.get_data_contract(&contract_id, self.version())?; - if !holds_private_contact_requests(held.as_deref()) { - provider.register_data_contract(Arc::clone(&contract)); - } - let held = provider.get_data_contract(&contract_id, self.version())?; - if !holds_private_contact_requests(held.as_deref()) { - return Err(Error::Generic(format!( - "the context provider does not hold the DashPay {contract_id} with \ - {PRIVATE_CONTACT_REQUEST_DOCUMENT_TYPE_NAME} once given it, so private \ - pages cannot be verified" - ))); - } - } - Ok(contract) + let dashpay_id = self.get_dashpay_contract_id()?; + self.fetch_private_document_contract(dashpay_id, PRIVATE_CONTACT_REQUEST_DOCUMENT_TYPE_NAME) + .await } /// Fetches the identities `needed` lists ([`private_contact_request_identities`]), a few at @@ -3270,75 +3243,12 @@ mod tests { assert_eq!(scan.received[0].request, request); } - /// A context provider holding `held`, or nothing, whatever version it is asked for, as a - /// provider's known contracts do. A `writable` one puts a registered contract in its place; - /// a read-only one keeps what it holds. Its clones share what it holds. #[cfg(feature = "mocks")] - #[derive(Clone)] - struct HeldContractProvider { - held: Arc>>>, - writable: bool, - } - + use crate::platform::private_document_entries::held_contract_provider::HeldContractProvider; #[cfg(feature = "mocks")] - impl HeldContractProvider { - fn new(held: Option, writable: bool) -> Self { - Self { - held: Arc::new(std::sync::Mutex::new(held.map(Arc::new))), - writable, - } - } - - fn held(&self) -> Option> { - self.held.lock().expect("the lock").clone() - } - } - + use crate::platform::private_document_entries::PrivateDocumentEntriesQuery; #[cfg(feature = "mocks")] - impl ContextProvider for HeldContractProvider { - fn get_data_contract( - &self, - _id: &Identifier, - _platform_version: &PlatformVersion, - ) -> Result>, dash_context_provider::ContextProviderError> - { - Ok(self.held()) - } - - fn get_token_configuration( - &self, - _token_id: &Identifier, - ) -> Result< - Option, - dash_context_provider::ContextProviderError, - > { - Ok(None) - } - - fn get_quorum_public_key( - &self, - _quorum_type: u32, - _quorum_hash: [u8; 32], - _core_chain_locked_height: u32, - ) -> Result<[u8; 48], dash_context_provider::ContextProviderError> { - Err(dash_context_provider::ContextProviderError::InvalidQuorum( - "no quorum in this test".to_string(), - )) - } - - fn get_platform_activation_height( - &self, - ) -> Result - { - Ok(1) - } - - fn register_data_contract(&self, contract: Arc) { - if self.writable { - *self.held.lock().expect("the lock") = Some(contract); - } - } - } + use dash_context_provider::ContextProvider; /// DashPay before its private type: as protocol version 14 has it. #[cfg(feature = "mocks")] @@ -3395,6 +3305,59 @@ mod tests { assert!(holds_private_contact_requests(provider.held().as_deref())); } + /// A direct page read, as a generic reader makes one, by a client whose provider holds + /// DashPay from before the private type: the current DashPay is fetched and given to the + /// provider before the page, which is then verified against it. + #[cfg(feature = "mocks")] + #[tokio::test] + async fn should_give_a_page_reader_s_provider_the_contract_with_the_private_type() { + let provider = HeldContractProvider::new(Some(dashpay_without_private_requests()), true); + let mut sdk = sdk_with(provider.clone()); + let current = dashpay(); + sdk.mock() + .expect_fetch(current.id(), Some(current.clone())) + .await + .expect("the DashPay fetch is expected"); + sdk.mock() + .expect_fetch( + PrivateDocumentEntriesQuery::new( + current.id(), + PRIVATE_CONTACT_REQUEST_DOCUMENT_TYPE_NAME, + 0, + ), + Some(PrivateDocumentEntries { + entries: vec![], + total_count: 0, + }), + ) + .await + .expect("the page fetch is expected"); + + let scan = sdk + .scan_private_document_page( + current.id(), + PRIVATE_CONTACT_REQUEST_DOCUMENT_TYPE_NAME, + 0, + &PrivateDocumentScanKeys::new(), + ) + .await + .expect("the page is read"); + assert!(scan.is_complete()); + assert!(holds_private_contact_requests(provider.held().as_deref())); + + // A provider holding a contract with the type keeps it: the very contract it holds + // comes back, where a refetch would have given it another. + let held = provider.held().expect("the provider holds DashPay"); + let resolved = sdk + .fetch_private_document_contract( + current.id(), + PRIVATE_CONTACT_REQUEST_DOCUMENT_TYPE_NAME, + ) + .await + .expect("the held contract"); + assert!(Arc::ptr_eq(&resolved, &held)); + } + /// A DashPay the provider does not hold yet is fetched from the network and given to it, /// which the pages are then verified against. #[cfg(feature = "mocks")] diff --git a/packages/rs-sdk/src/platform/private_document_entries.rs b/packages/rs-sdk/src/platform/private_document_entries.rs index 0d68e5d80bf..4abc8dadf12 100644 --- a/packages/rs-sdk/src/platform/private_document_entries.rs +++ b/packages/rs-sdk/src/platform/private_document_entries.rs @@ -10,14 +10,22 @@ //! //! [`PrivateDocumentEntries::fetch`] verifies the page against the platform's root hash, with //! the contract the context provider supplies, so a node can neither leave entries out of it -//! nor change their header times. [`FetchUnproved`] reads it without a proof. +//! nor change their header times. [`FetchUnproved`] reads it without a proof. A provider that +//! holds the contract from before its private type was declared fails that verification; +//! [`Sdk::fetch_private_document_contract`] gives it the contract with the type first. + +use std::sync::Arc; use crate::platform::{Fetch, FetchUnproved, Identifier, Query, QuerySettings}; -use crate::Error; +use crate::{Error, Sdk}; use dapi_grpc::platform::v0::get_private_document_entries_request::GetPrivateDocumentEntriesRequestV0; use dapi_grpc::platform::v0::{ get_private_document_entries_request, GetPrivateDocumentEntriesRequest, }; +use dash_context_provider::ContextProvider; +use dpp::data_contract::accessors::v0::DataContractV0Getters; +use dpp::data_contract::document_type::accessors::DocumentTypeV2Getters; +use dpp::data_contract::DataContract; pub use drive_proof_verifier::types::private_document_entries::{ PrivateDocumentEntries, PrivateDocumentEntry, }; @@ -76,6 +84,132 @@ impl FetchUnproved for PrivateDocumentEntries { type Request = GetPrivateDocumentEntriesRequest; } +/// Whether `contract` declares `document_type_name` as a private type. +fn declares_private_type(contract: &DataContract, document_type_name: &str) -> bool { + contract + .document_type_for_name(document_type_name) + .is_ok_and(|document_type| document_type.is_private()) +} + +impl Sdk { + /// The contract `data_contract_id`, with its private type `document_type_name`, as the + /// context provider holds it: a page of the type's store is verified against the contract + /// the provider gives, and one kept from before the type was declared, as a client + /// hydrated across the protocol version that declared it may hold, fails every page. + /// + /// A held contract that declares the type is used as it is and never replaced, so a newer + /// one the provider proved stays. Otherwise the contract is fetched from the network, + /// refused when it does not declare the type, given to the provider and read back; the + /// call fails when the provider then still holds none that declares it. Without a context + /// provider the fetched contract is returned. + pub async fn fetch_private_document_contract( + &self, + data_contract_id: Identifier, + document_type_name: &str, + ) -> Result, Error> { + let provider = self.context_provider(); + if let Some(provider) = &provider { + if let Some(held) = provider.get_data_contract(&data_contract_id, self.version())? { + if declares_private_type(&held, document_type_name) { + return Ok(held); + } + } + } + let contract = DataContract::fetch(self, data_contract_id) + .await? + .map(Arc::new) + .ok_or_else(|| Error::Generic(format!("the contract {data_contract_id} is gone")))?; + if !declares_private_type(&contract, document_type_name) { + return Err(Error::Generic(format!( + "the contract {data_contract_id} declares no private type \"{document_type_name}\"" + ))); + } + let Some(provider) = provider else { + return Ok(contract); + }; + provider.register_data_contract(contract); + match provider.get_data_contract(&data_contract_id, self.version())? { + Some(held) if declares_private_type(&held, document_type_name) => Ok(held), + _ => Err(Error::Generic(format!( + "the context provider does not hold the contract {data_contract_id} with its \ + private type \"{document_type_name}\" once given it, so the type's pages cannot \ + be verified" + ))), + } + } +} + +/// A context provider for tests reading private document pages. +#[cfg(all(test, feature = "mocks"))] +pub(crate) mod held_contract_provider { + use std::sync::{Arc, Mutex}; + + use dash_context_provider::{ContextProvider, ContextProviderError}; + use dpp::data_contract::{DataContract, TokenConfiguration}; + use dpp::prelude::{CoreBlockHeight, Identifier}; + use dpp::version::PlatformVersion; + + /// A context provider holding `held`, or nothing, whatever version it is asked for, as a + /// provider's known contracts do. A `writable` one puts a registered contract in its + /// place; a read-only one keeps what it holds. Its clones share what it holds. + #[derive(Clone)] + pub(crate) struct HeldContractProvider { + held: Arc>>>, + writable: bool, + } + + impl HeldContractProvider { + pub(crate) fn new(held: Option, writable: bool) -> Self { + Self { + held: Arc::new(Mutex::new(held.map(Arc::new))), + writable, + } + } + + pub(crate) fn held(&self) -> Option> { + self.held.lock().expect("the lock").clone() + } + } + + impl ContextProvider for HeldContractProvider { + fn get_data_contract( + &self, + _id: &Identifier, + _platform_version: &PlatformVersion, + ) -> Result>, ContextProviderError> { + Ok(self.held()) + } + + fn get_token_configuration( + &self, + _token_id: &Identifier, + ) -> Result, ContextProviderError> { + Ok(None) + } + + fn get_quorum_public_key( + &self, + _quorum_type: u32, + _quorum_hash: [u8; 32], + _core_chain_locked_height: u32, + ) -> Result<[u8; 48], ContextProviderError> { + Err(ContextProviderError::InvalidQuorum( + "no quorum in this test".to_string(), + )) + } + + fn get_platform_activation_height(&self) -> Result { + Ok(1) + } + + fn register_data_contract(&self, contract: Arc) { + if self.writable { + *self.held.lock().expect("the lock") = Some(contract); + } + } + } +} + #[cfg(all(test, feature = "mocks"))] mod tests { use super::*; diff --git a/packages/rs-sdk/src/platform/private_document_scan.rs b/packages/rs-sdk/src/platform/private_document_scan.rs index 8e61dcac639..6caa72a75c1 100644 --- a/packages/rs-sdk/src/platform/private_document_scan.rs +++ b/packages/rs-sdk/src/platform/private_document_scan.rs @@ -369,7 +369,9 @@ impl PrivateDocumentScanKeys { impl Sdk { /// Fetches the proved page of the store of the private type `document_type_name` of - /// `data_contract_id` from `start_position`, and sorts its entries with `keys`. + /// `data_contract_id` from `start_position`, and sorts its entries with `keys`. The context + /// provider is given the contract with the type first when it lacks it + /// ([`Sdk::fetch_private_document_contract`]). /// /// A wallet keeps [`PrivateDocumentScan::next_position`] as its cursor once it has stored /// what the page held, and scans from it again until [`PrivateDocumentScan::is_complete`]; @@ -381,6 +383,9 @@ impl Sdk { start_position: u64, keys: &PrivateDocumentScanKeys, ) -> Result { + // The page is verified against the contract the context provider holds. + self.fetch_private_document_contract(data_contract_id, document_type_name) + .await?; let query = PrivateDocumentEntriesQuery::new(data_contract_id, document_type_name, start_position); let page = PrivateDocumentEntries::fetch(self, query) @@ -678,14 +683,34 @@ mod tests { assert!(scan.is_complete()); } + /// DashPay as the latest version has it, under the test contract's id: a contract that + /// declares the private type the test entries are sealed for. + #[cfg(feature = "mocks")] + fn contract_with_the_private_type() -> dpp::data_contract::DataContract { + use dpp::data_contract::accessors::v0::DataContractV0Setters; + use dpp::system_data_contracts::{load_system_data_contract, SystemDataContract}; + use dpp::version::PlatformVersion; + + let mut contract = + load_system_data_contract(SystemDataContract::Dashpay, PlatformVersion::latest()) + .expect("DashPay loads at the latest version"); + contract.set_id(Identifier::from(CONTRACT_ID)); + contract + } + #[cfg(feature = "mocks")] #[tokio::test] async fn should_scan_a_fetched_page_of_the_store() { + use crate::platform::private_document_entries::held_contract_provider::HeldContractProvider; use crate::SdkBuilder; use dpp::version::PlatformVersion; let mut sdk: Sdk = SdkBuilder::new_mock() .with_version(PlatformVersion::latest()) + .with_context_provider(HeldContractProvider::new( + Some(contract_with_the_private_type()), + false, + )) .build() .expect("expected a mock sdk"); let alice = Identifier::from([0xA0; 32]); From be7fba865be7e8b2cac10874b5f4e27abdbd99c1 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 9 Nov 2026 04:49:33 +0700 Subject: [PATCH 030/117] feat(sdk): stream a private document store's pages, many fetched at once A reader of a private document type's store fetched one page, one chunk, at a time. `Sdk::private_document_pages(contract_id, document_type_name, start_position, max_concurrent)` now yields the proved pages from a cursor to the store's end, as the first page reports it, in store order. A page reads to the end of the chunk holding its start, and the client is not told the chunk size. So the first page and the next one, from the chunk boundary the first ends at, are read one at a time. That second page, short of the store's end, is a whole chunk, a power of two, which sets every later page's start. Those are then fetched with up to `max_concurrent` in flight (`MAX_CONCURRENT_PRIVATE_DOCUMENT_PAGE_FETCHES` is 16, as the shielded note sync uses) and yielded in order. A later page that does not hold its whole chunk is an error, not a gap. A store with two chunks or fewer past the cursor is read one page at a time. Drive is unchanged. The stream resolves the contract through `Sdk::fetch_private_document_contract` first, and ends with a failed fetch's error after the pages before it. Co-Authored-By: Claude Opus 5.5 --- .../src/platform/private_document_scan.rs | 454 ++++++++++++++++++ 1 file changed, 454 insertions(+) diff --git a/packages/rs-sdk/src/platform/private_document_scan.rs b/packages/rs-sdk/src/platform/private_document_scan.rs index 6caa72a75c1..acefcde1d4f 100644 --- a/packages/rs-sdk/src/platform/private_document_scan.rs +++ b/packages/rs-sdk/src/platform/private_document_scan.rs @@ -5,6 +5,8 @@ //! send with, which recover what the wallet sent after a restore from seed. //! [`PrivateDocumentScanKeys`] prepares both once, and [`Sdk::scan_private_document_page`] //! fetches one proved page of the store from a cursor and sorts its entries. +//! [`Sdk::private_document_pages`] reads the store from a cursor to its end as a stream of +//! proved pages in store order, many fetched at once. //! //! A scan only opens entries. What an entry claims about its sender is checked against the //! sender identity's keys: a deniable MAC with [`PrivateDocumentScanKeys::verify_sender`], a @@ -12,6 +14,9 @@ //! decode and validate. `crate::platform::dashpay` does both for DashPay's private contact //! requests. +use std::future::Future; +use std::pin::Pin; + use crate::platform::private_document_entries::{ PrivateDocumentEntries, PrivateDocumentEntriesQuery, PrivateDocumentEntry, }; @@ -19,6 +24,7 @@ use crate::platform::{Fetch, Identifier}; use crate::{Error, Sdk}; use dpp::identity::KeyID; use dpp::secp256k1::{PublicKey, SecretKey}; +use futures::stream::{self, Stream, StreamExt}; use platform_encryption::{ private_document_outgoing_viewing_key, recover_sent_private_document_entry, OpenedPrivateDocumentEntry, PrivateDocumentEnvelopeError, PrivateDocumentRecipientKeys, @@ -367,7 +373,228 @@ impl PrivateDocumentScanKeys { } } +/// How many pages of a private document type's store [`Sdk::private_document_pages`] keeps in +/// flight, as the shielded note sync keeps as many chunk fetches. A page costs the node a +/// proof, not the transfer, so pages in flight are what speeds a read up; and the stream holds +/// no more pages than this unread, so its memory stays bounded. +pub const MAX_CONCURRENT_PRIVATE_DOCUMENT_PAGE_FETCHES: usize = 16; + +/// One proved page of a private document type's store, as [`Sdk::private_document_pages`] +/// yields it. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PrivateDocumentPage { + /// The position the page was read from. + pub start_position: u64, + /// The page. + pub entries: PrivateDocumentEntries, +} + +type PageFuture = Pin> + Send>>; +type PageStream = Pin> + Send>>; + +/// Where a walk over a store's pages is. +enum PageWalk { + /// Reading one page at a time from `start`, until the chunk size is known. `total` is + /// the store's size as the first page reported it, `None` before it; `aligned` says that + /// `start` is a chunk boundary, as the end of a page short of the store's end is. + Sequential { + start: u64, + total: Option, + aligned: bool, + }, + /// The chunk size is known: every page left is fetched, several at once. + Concurrent(PageStream), + Done, +} + +/// The pages of a store, read with `fetch` from `start_position` to the store's end as the +/// first page reports it, in store order: one at a time until the chunk size is known, then +/// up to `max_concurrent` at once ([`Sdk::private_document_pages`]). +fn private_document_page_walk( + fetch: F, + start_position: u64, + max_concurrent: usize, +) -> impl Stream> + Send +where + F: Fn(u64) -> PageFuture + Clone + Send + Sync + 'static, +{ + let max_concurrent = max_concurrent.max(1); + stream::unfold( + PageWalk::Sequential { + start: start_position, + total: None, + aligned: false, + }, + move |walk| { + let fetch = fetch.clone(); + async move { + match walk { + PageWalk::Done => None, + PageWalk::Concurrent(mut pages) => match pages.next().await? { + Ok(page) => Some((Ok(page), PageWalk::Concurrent(pages))), + Err(error) => Some((Err(error), PageWalk::Done)), + }, + PageWalk::Sequential { + start, + total, + aligned, + } => { + let entries = match fetch(start).await { + Ok(entries) => entries, + Err(error) => return Some((Err(error), PageWalk::Done)), + }; + let total = total.unwrap_or(entries.total_count); + let len = entries.entries.len() as u64; + let end = start.saturating_add(len); + let page = PrivateDocumentPage { + start_position: start, + entries, + }; + if end >= total { + return Some((Ok(page), PageWalk::Done)); + } + if len == 0 { + return Some(( + Err(Error::Generic(format!( + "the page from position {start} of a store of {total} \ + entries is empty" + ))), + PageWalk::Done, + )); + } + // A page from a chunk boundary that stops short of the store's end is + // one whole chunk, a power of two, and so is every page after it. + let next = if aligned && len.is_power_of_two() && start % len == 0 { + PageWalk::Concurrent(Box::pin(whole_chunk_pages( + fetch, + end, + len, + total, + max_concurrent, + ))) + } else { + PageWalk::Sequential { + start: end, + total: Some(total), + aligned: true, + } + }; + Some((Ok(page), next)) + } + } + } + }, + ) +} + +/// The pages of `chunk_size` entries from `first`, a chunk boundary, to `total`, fetched up to +/// `max_concurrent` at once and yielded in store order. A page that does not hold its whole +/// chunk, short of the store's end, is an error: the walk would otherwise skip entries. +fn whole_chunk_pages( + fetch: F, + first: u64, + chunk_size: u64, + total: u64, + max_concurrent: usize, +) -> impl Stream> + Send +where + F: Fn(u64) -> PageFuture + Clone + Send + Sync + 'static, +{ + let starts = std::iter::successors(Some(first), move |start| { + start.checked_add(chunk_size).filter(|next| *next < total) + }) + .filter(move |start| *start < total); + stream::iter(starts) + .map(move |start| { + let page = fetch(start); + async move { + let entries = page.await?; + let len = entries.entries.len() as u64; + let first_position = entries.entries.first().map(|entry| entry.position); + let whole = len == chunk_size || start.saturating_add(len) >= total; + if len > chunk_size || !whole || first_position.is_some_and(|p| p != start) { + return Err(Error::Generic(format!( + "the page from position {start} holds {len} entries, not the chunk of \ + {chunk_size} the store's earlier pages set" + ))); + } + Ok(PrivateDocumentPage { + start_position: start, + entries, + }) + } + }) + .buffered(max_concurrent) +} + impl Sdk { + /// The proved pages of the store of the private type `document_type_name` of + /// `data_contract_id`, from `start_position` to the store's end as the first page reports + /// it, in store order, with up to `max_concurrent` fetched at once + /// ([`MAX_CONCURRENT_PRIVATE_DOCUMENT_PAGE_FETCHES`] is the wallet's choice). Entries + /// appended while the pages are read are left for the next read, unless the last page's + /// chunk holds them. + /// + /// A page reads to the end of the chunk holding its start, which the client does not + /// learn from the node: so the first page, and the next one from the chunk boundary it + /// ends at, are read one at a time. That second page, short of the store's end, is a whole + /// chunk, which sets the size of every page after it; those are then fetched at once. A + /// store with two chunks or fewer past `start_position` is read one page at a time. + /// + /// The context provider is given the contract with the type first when it lacks it + /// ([`Sdk::fetch_private_document_contract`]). A failed fetch ends the stream with its + /// error, after the pages before it. + pub fn private_document_pages( + &self, + data_contract_id: Identifier, + document_type_name: &str, + start_position: u64, + max_concurrent: usize, + ) -> impl Stream> + Send { + let sdk = self.clone(); + let document_type_name = document_type_name.to_string(); + let resolve = { + let sdk = sdk.clone(); + let document_type_name = document_type_name.clone(); + async move { + sdk.fetch_private_document_contract(data_contract_id, &document_type_name) + .await + } + }; + stream::once(resolve).flat_map(move |resolved| -> PageStream { + match resolved { + Err(error) => Box::pin(stream::once(async move { Err(error) })), + Ok(_) => { + let sdk = sdk.clone(); + let document_type_name = document_type_name.clone(); + let fetch = move |start: u64| -> PageFuture { + let sdk = sdk.clone(); + let query = PrivateDocumentEntriesQuery::new( + data_contract_id, + document_type_name.clone(), + start, + ); + Box::pin(async move { + PrivateDocumentEntries::fetch(&sdk, query) + .await? + .ok_or_else(|| { + Error::Generic(format!( + "no page of the private document type of contract \ + {data_contract_id} from position {start}" + )) + }) + }) + }; + Box::pin(private_document_page_walk( + fetch, + start_position, + max_concurrent, + )) + } + } + }) + } + /// Fetches the proved page of the store of the private type `document_type_name` of /// `data_contract_id` from `start_position`, and sorts its entries with `keys`. The context /// provider is given the contract with the type first when it lacks it @@ -683,6 +910,233 @@ mod tests { assert!(scan.is_complete()); } + /// The page Drive serves from `start` of a store of `total` entries in chunks of + /// `chunk_size`: the rest of the chunk holding `start`, or to the store's end. + fn drive_page(chunk_size: u64, total: u64, start: u64) -> PrivateDocumentEntries { + let end = if start >= total { + start + } else { + (start - start % chunk_size + chunk_size).min(total) + }; + PrivateDocumentEntries { + entries: (start..end) + .map(|position| PrivateDocumentEntry { + position, + block_height: position, + block_time_ms: position, + data: vec![position as u8; 4], + }) + .collect(), + total_count: total, + } + } + + /// Fetches with how many are in flight at once noted. + #[derive(Clone, Default)] + struct InFlight { + now: std::sync::Arc, + most: std::sync::Arc, + starts: std::sync::Arc>>, + } + + impl InFlight { + /// A fetch of Drive's pages of a store of `total` entries in chunks of `chunk_size`, + /// each taking `delay`, failing from `failing` when given. + fn fetch( + &self, + chunk_size: u64, + total: u64, + delay: std::time::Duration, + failing: Option, + ) -> impl Fn(u64) -> PageFuture + Clone + Send + Sync + 'static { + let in_flight = self.clone(); + move |start| { + let in_flight = in_flight.clone(); + Box::pin(async move { + use std::sync::atomic::Ordering::SeqCst; + in_flight.starts.lock().expect("starts").push(start); + let now = in_flight.now.fetch_add(1, SeqCst) + 1; + in_flight.most.fetch_max(now, SeqCst); + tokio::time::sleep(delay).await; + in_flight.now.fetch_sub(1, SeqCst); + if failing == Some(start) { + return Err(Error::Generic(format!("no page from {start}"))); + } + Ok(drive_page(chunk_size, total, start)) + }) + } + } + + fn most(&self) -> usize { + self.most.load(std::sync::atomic::Ordering::SeqCst) + } + } + + /// Every page a walk yields, and the positions of their entries. + async fn walk( + fetch: impl Fn(u64) -> PageFuture + Clone + Send + Sync + 'static, + start: u64, + max_concurrent: usize, + ) -> Vec> { + private_document_page_walk(fetch, start, max_concurrent) + .collect() + .await + } + + /// From a chunk boundary, from inside a chunk and from the store's last partial chunk, + /// the pages come in store order, each from where the last ended, and hold every entry + /// from the cursor to the store's end once. + #[tokio::test] + async fn should_read_a_store_s_pages_in_order_from_any_cursor() { + let (chunk_size, total) = (4, 30); + for cursor in [0, 6, 16, 26, 29, 30] { + let pages = walk( + InFlight::default().fetch(chunk_size, total, std::time::Duration::ZERO, None), + cursor, + MAX_CONCURRENT_PRIVATE_DOCUMENT_PAGE_FETCHES, + ) + .await; + let mut expected_start = cursor; + let mut positions = Vec::new(); + for page in pages { + let page = page.expect("every page reads"); + assert_eq!(page.start_position, expected_start, "cursor {cursor}"); + expected_start += page.entries.entries.len() as u64; + positions.extend(page.entries.entries.iter().map(|entry| entry.position)); + } + assert_eq!( + positions, + (cursor..total).collect::>(), + "cursor {cursor}" + ); + } + } + + /// A store of two chunks or fewer past the cursor is read one page at a time. + #[tokio::test] + async fn should_read_a_short_store_one_page_at_a_time() { + for (cursor, total) in [(1, 7), (0, 8), (3, 4)] { + let in_flight = InFlight::default(); + let pages = walk( + in_flight.fetch(4, total, std::time::Duration::from_millis(5), None), + cursor, + MAX_CONCURRENT_PRIVATE_DOCUMENT_PAGE_FETCHES, + ) + .await; + assert!(pages.iter().all(Result::is_ok)); + assert_eq!( + pages + .iter() + .flatten() + .map(|page| page.start_position) + .collect::>(), + *in_flight.starts.lock().expect("starts") + ); + assert_eq!(in_flight.most(), 1, "cursor {cursor} of {total}"); + } + } + + /// Past the first two pages, several fetches are in flight at once, as many as allowed. + #[tokio::test] + async fn should_keep_several_page_fetches_in_flight() { + let in_flight = InFlight::default(); + let pages = walk( + in_flight.fetch(4, 64, std::time::Duration::from_millis(20), None), + 0, + 5, + ) + .await; + assert_eq!(pages.len(), 16); + assert!(pages.iter().all(Result::is_ok)); + assert_eq!(in_flight.most(), 5); + } + + /// A failed fetch ends the stream with its error, after every page before it. + #[tokio::test] + async fn should_end_with_a_later_page_s_error_after_the_pages_before_it() { + let pages = walk( + InFlight::default().fetch(4, 40, std::time::Duration::from_millis(1), Some(20)), + 0, + MAX_CONCURRENT_PRIVATE_DOCUMENT_PAGE_FETCHES, + ) + .await; + let starts: Vec = pages + .iter() + .take_while(|page| page.is_ok()) + .flatten() + .map(|page| page.start_position) + .collect(); + assert_eq!(starts, vec![0, 4, 8, 12, 16]); + assert_eq!(pages.len(), 6, "the error, then nothing"); + assert!(pages[5].is_err()); + } + + /// A page short of its chunk, before the store's end, is an error rather than a gap. + #[tokio::test] + async fn should_refuse_a_page_short_of_its_chunk() { + let fetch = |start: u64| -> PageFuture { + Box::pin(async move { + let mut page = drive_page(4, 40, start); + if start == 12 { + page.entries.truncate(2); + } + Ok(page) + }) + }; + let pages = walk(fetch, 0, 4).await; + assert!(pages[..3].iter().all(Result::is_ok)); + let error = pages[3].as_ref().expect_err("a short page").to_string(); + assert!(error.contains("not the chunk of 4"), "{error}"); + assert_eq!(pages.len(), 4); + } + + /// The pages read through the SDK, after the context provider is given the contract. + #[cfg(feature = "mocks")] + #[tokio::test] + async fn should_stream_the_pages_of_a_store_through_the_sdk() { + use crate::platform::private_document_entries::held_contract_provider::HeldContractProvider; + use crate::SdkBuilder; + use dpp::version::PlatformVersion; + + let mut sdk: Sdk = SdkBuilder::new_mock() + .with_version(PlatformVersion::latest()) + .with_context_provider(HeldContractProvider::new( + Some(contract_with_the_private_type()), + false, + )) + .build() + .expect("expected a mock sdk"); + let contract_id = Identifier::from(CONTRACT_ID); + for start in [2, 4, 8, 12] { + sdk.mock() + .expect_fetch( + PrivateDocumentEntriesQuery::new(contract_id, TYPE_NAME, start), + Some(drive_page(4, 14, start)), + ) + .await + .expect("expected to register the page"); + } + let pages: Vec = sdk + .private_document_pages(contract_id, TYPE_NAME, 2, 3) + .map(|page| page.expect("every page reads")) + .collect() + .await; + assert_eq!( + pages + .iter() + .map(|page| page.start_position) + .collect::>(), + vec![2, 4, 8, 12] + ); + assert_eq!( + pages + .iter() + .flat_map(|page| page.entries.entries.iter().map(|entry| entry.position)) + .collect::>(), + (2..14).collect::>() + ); + } + /// DashPay as the latest version has it, under the test contract's id: a contract that /// declares the private type the test entries are sealed for. #[cfg(feature = "mocks")] From f479c947b402823f707043daa085efab250b44ce Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 9 Nov 2026 05:02:11 +0700 Subject: [PATCH 031/117] perf(platform-wallet): read the private contact request store many pages at once The private sync fetched one page of DashPay's store, scanned it, ingested it and only then fetched the next; and every scan derived the scan keys again, a mnemonic resolve each in the FFI signer. `sync_private_contact_requests` now reads `Sdk::private_document_pages`, with up to `MAX_CONCURRENT_PRIVATE_DOCUMENT_PAGE_FETCHES` (16) fetches in flight while earlier pages are processed, and scans the pages ready at once in batches of up to 16. Each page is still processed in order under the existing rules: identity fetches, ingest, and a cursor advance only once the ingest reached disk. Pages before a failed fetch are processed, then the error ends the pass; `max_pages` is still the pages one pass reads. `ContactCryptoProvider::scan_private_document_pages` scans a batch. Its default calls `scan_private_document_page` once a page, so other providers keep working; the test provider derives the keys once a batch. With pages cheap, a send's read-back after paying reads one window of 16 pages, and the read before an identity's first private send reads up to 256 pages a sync, at most 4 syncs. `futures` becomes an unconditional dependency, as the sync is not behind the `shielded` feature. Co-Authored-By: Claude Opus 5.5 --- packages/rs-platform-wallet/Cargo.toml | 11 +- .../identity/network/contact_requests.rs | 70 +- .../network/private_contact_requests.rs | 752 +++++++++++------- 3 files changed, 545 insertions(+), 288 deletions(-) diff --git a/packages/rs-platform-wallet/Cargo.toml b/packages/rs-platform-wallet/Cargo.toml index 7da2bb5ce6c..ff178bd57f1 100644 --- a/packages/rs-platform-wallet/Cargo.toml +++ b/packages/rs-platform-wallet/Cargo.toml @@ -61,10 +61,11 @@ grovedb-commitment-tree = { workspace = true, optional = true } # is built against (its `Cargo.lock` resolves rusqlite at this major). rusqlite = { version = "0.38", optional = true } zip32 = { version = "0.2.0", default-features = false, optional = true } -# `StreamExt`/`pin_mut!` to consume the SDK's `sync_shielded_notes_stream` -# in the interleaved fetch/tree-append consumer (shielded sync only). -# Same version as `dash-sdk` so the lockfile resolves a single copy. -futures = { version = "0.3.30", optional = true } +# `StreamExt` to consume the SDK's page streams: the shielded note sync's +# `sync_shielded_notes_stream` and the private contact request sync's +# `private_document_pages`. Same version as `dash-sdk` so the lockfile +# resolves a single copy. +futures = { version = "0.3.30" } # Networked, opt-in example binaries. Each one performs real network I/O # against a live devnet, so they are examples (compiled, never run by @@ -129,7 +130,7 @@ default = ["bls", "eddsa"] test-utils = ["key-wallet/test-utils"] bls = ["key-wallet/bls", "key-wallet-manager/bls"] eddsa = ["key-wallet/eddsa", "key-wallet-manager/eddsa"] -shielded = ["dep:grovedb-commitment-tree", "dep:rusqlite", "dep:zip32", "dep:futures", "dash-sdk/shielded", "dpp/shielded-client"] +shielded = ["dep:grovedb-commitment-tree", "dep:rusqlite", "dep:zip32", "dash-sdk/shielded", "dpp/shielded-client"] # Opt-in serde derives on the changeset types in `src/changeset/` plus # the per-identity / DashPay scalar types those changesets carry. # Activates `key-wallet/serde` (which transitively activates diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs b/packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs index 271270d8690..a00b67e2668 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs @@ -170,6 +170,35 @@ pub trait ContactCryptoProvider { Err(private_documents_unsupported()) } + /// Sorts each of `pages`, each read from its start position of the same store, as + /// [`Self::scan_private_document_page`] sorts one: one scan per page, in their order. A + /// provider derives the keys at `keys` once for all of them, rather than once a page. + /// + /// Defaulted to one [`Self::scan_private_document_page`] call per page, so a provider that + /// only implements that one keeps working. + async fn scan_private_document_pages( + &self, + keys: &PrivateDocumentKeyPaths, + data_contract_id: Identifier, + document_type_name: &str, + pages: &[(u64, &PrivateDocumentEntries)], + ) -> Result, PlatformWalletError> { + let mut scans = Vec::with_capacity(pages.len()); + for (start_position, page) in pages { + scans.push( + self.scan_private_document_page( + keys, + data_contract_id, + document_type_name, + *start_position, + page, + ) + .await?, + ); + } + Ok(scans) + } + /// Seals `request` from `sender`, whose encryption key is the one at /// `sender_encryption_key_path`, to the key `recipient_key_id` of `recipient`, with /// `signature`, the signature of the request by the sender's key `signing_key_id` @@ -233,6 +262,9 @@ pub struct ContactInfoOpened { #[cfg(test)] pub(crate) struct SeedCryptoProvider { wallet: key_wallet::wallet::Wallet, + /// How many times the private document scan keys were derived: once per scan call, a + /// page's or a batch's. + pub(crate) scan_key_derivations: std::sync::atomic::AtomicUsize, } #[cfg(test)] @@ -243,6 +275,7 @@ impl SeedCryptoProvider { Self { wallet: Wallet::from_seed_bytes(seed, network, WalletAccountCreationOptions::None) .expect("test seed wallet"), + scan_key_derivations: std::sync::atomic::AtomicUsize::new(0), } } } @@ -379,15 +412,36 @@ impl ContactCryptoProvider for SeedCryptoProvider { start_position: u64, page: &PrivateDocumentEntries, ) -> Result { + self.scan_private_document_pages( + keys, + data_contract_id, + document_type_name, + &[(start_position, page)], + ) + .await? + .pop() + .ok_or_else(|| PlatformWalletError::InvalidIdentityData("no scan".to_string())) + } + + async fn scan_private_document_pages( + &self, + keys: &PrivateDocumentKeyPaths, + data_contract_id: Identifier, + document_type_name: &str, + pages: &[(u64, &PrivateDocumentEntries)], + ) -> Result, PlatformWalletError> { + // Once for the batch, as a signer-backed provider resolves its seed once. + self.scan_key_derivations + .fetch_add(1, std::sync::atomic::Ordering::SeqCst); let keys = keys.scan_keys(|path| self.secret_key_at(path))?; - Ok(keys.scan_page( - &platform_encryption::PrivateDocumentTarget { - contract_id: data_contract_id.to_buffer(), - document_type_name, - }, - start_position, - page, - )) + let target = platform_encryption::PrivateDocumentTarget { + contract_id: data_contract_id.to_buffer(), + document_type_name, + }; + Ok(pages + .iter() + .map(|(start_position, page)| keys.scan_page(&target, *start_position, page)) + .collect()) } async fn seal_private_contact_request( diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/private_contact_requests.rs b/packages/rs-platform-wallet/src/wallet/identity/network/private_contact_requests.rs index bceb724b8e6..1a10e597a00 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/private_contact_requests.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/private_contact_requests.rs @@ -37,11 +37,10 @@ use dash_sdk::platform::dashpay::{ private_contact_requests_from_scan, recipient_key_purpose_is_valid, PrivateContactRequest, ProvedIdentity, RejectedPrivateContactRequest, PRIVATE_CONTACT_REQUEST_DOCUMENT_TYPE_NAME, }; -use dash_sdk::platform::private_document_entries::{ - PrivateDocumentEntries, PrivateDocumentEntriesQuery, PrivateDocumentEntry, +use dash_sdk::platform::private_document_entries::{PrivateDocumentEntries, PrivateDocumentEntry}; +use dash_sdk::platform::private_document_scan::{ + PrivateDocumentScanKeys, MAX_CONCURRENT_PRIVATE_DOCUMENT_PAGE_FETCHES, }; -use dash_sdk::platform::private_document_scan::PrivateDocumentScanKeys; -use dash_sdk::platform::Fetch; use dpp::data_contract::accessors::v0::DataContractV0Getters; use dpp::data_contract::document_type::decode_private_document_payload; use dpp::identity::accessors::IdentityGettersV0; @@ -54,6 +53,7 @@ use dpp::prelude::Identifier; use dpp::secp256k1::rand::rngs::StdRng; use dpp::secp256k1::rand::{RngCore, SeedableRng}; use dpp::secp256k1::{PublicKey, SecretKey}; +use futures::StreamExt; use key_wallet::bip32::{DerivationPath, KeyDerivationType}; use key_wallet_manager::WalletManager; use platform_encryption::{encrypt_account_label, encrypt_extended_public_key, COMPACT_XPUB_LEN}; @@ -122,15 +122,21 @@ pub struct PrivateContactSyncReport { pub unpersisted_identities: Vec, } -/// How many pages of the store a send reads back after paying: enough for an -/// identity that synced recently, whose new entry is in the last page or two. -/// A request not read back within them stays pending for a later sync. -const POST_SEND_SYNC_PAGES: usize = 4; +/// How many pages of the store a send reads back after paying: one window of +/// pages fetched at once, enough for an identity that synced recently, whose +/// new entry is in the last page or two. A request not read back within them +/// stays pending for a later sync. +const POST_SEND_SYNC_PAGES: usize = MAX_CONCURRENT_PRIVATE_DOCUMENT_PAGE_FETCHES; /// How many pages of the store each private sync reads that a private send /// runs, before it seals anything, when the store was not yet read to its end -/// since load. -const PRIVATE_STORE_READ_PAGES: usize = 16; +/// since load: sixteen windows of pages fetched at once. With the read +/// position kept across restarts, the read is from there to the end. +const PRIVATE_STORE_READ_PAGES: usize = 16 * MAX_CONCURRENT_PRIVATE_DOCUMENT_PAGE_FETCHES; + +/// How many fetched pages a sync scans at once, one key derivation for them +/// all: at most the pages in flight. +const PRIVATE_SCAN_BATCH_PAGES: usize = MAX_CONCURRENT_PRIVATE_DOCUMENT_PAGE_FETCHES; /// How many such syncs a private send runs before it refuses with /// [`PlatformWalletError::PrivateContactRequestPending`]: a later send goes on @@ -896,296 +902,332 @@ impl DashPayView<'_, B> { // the key id of the key a request is sealed to, as they were when it // was appended or later. let mut recipients: BTreeMap = BTreeMap::new(); - let mut position = start; report.next_position = start; - while report.pages_read < max_pages { - let query = PrivateDocumentEntriesQuery::new( + // The pages are fetched many at once while earlier ones are processed, + // and scanned in batches of those ready, one key derivation a batch. + let mut batches = std::pin::pin!(self + .sdk + .private_document_pages( contract.id(), PRIVATE_CONTACT_REQUEST_DOCUMENT_TYPE_NAME, - position, - ); - let page = PrivateDocumentEntries::fetch(&self.sdk, query) - .await? - .ok_or_else(|| { - PlatformWalletError::InvalidIdentityData(format!( - "no page of DashPay's private contact requests from position {position}" - )) - })?; - report.pages_read += 1; - - let scan = crypto - .scan_private_document_page( - &paths, - contract.id(), - PRIVATE_CONTACT_REQUEST_DOCUMENT_TYPE_NAME, - position, - &page, - ) - .await?; - let stale: BTreeMap = private_contact_request_identities(&scan) - .into_iter() - .filter(|(identity_id, height)| { - proved_identities - .get(identity_id) - .is_none_or(|proved| proved.proved_at_height < *height) - }) - .collect(); - proved_identities.extend( - self.sdk - .fetch_private_contact_request_identities(&stale) - .await?, - ); - let read = private_contact_requests_from_scan( - &contract, - scan, - &proved_identities, - self.sdk.version(), - )?; - // Each recipient is fetched on its own, so one that cannot be read - // holds back only the identities whose sent requests it is for. - let mut stale_recipients: BTreeMap = BTreeMap::new(); - for sent in &read.sent { - let height = stale_recipients.entry(sent.recipient_id).or_default(); - *height = (*height).max(sent.block_height); - } - stale_recipients.retain(|recipient_id, height| { - recipients - .get(recipient_id) - .is_none_or(|proved| proved.proved_at_height < *height) - }); - let mut unreadable_recipients = BTreeSet::new(); - for (recipient_id, height) in stale_recipients { - match self - .sdk - .fetch_private_contact_request_identities(&BTreeMap::from([( - recipient_id, - height, - )])) - .await - { - Ok(fetched) => recipients.extend(fetched), + start, + MAX_CONCURRENT_PRIVATE_DOCUMENT_PAGE_FETCHES, + ) + .take(max_pages) + .ready_chunks(PRIVATE_SCAN_BATCH_PAGES)); + while let Some(batch) = batches.next().await { + // The pages before a failed fetch are processed, then the error + // ends the pass. + let mut fetched = Vec::with_capacity(batch.len()); + let mut failure = None; + for page in batch { + match page { + Ok(page) => fetched.push(page), Err(error) => { - tracing::warn!( - recipient = %recipient_id, - %error, - "the recipient of a sent private contact request could not be read; \ - its sender's cursor is held" - ); - unreadable_recipients.insert(recipient_id); + failure = Some(error); + break; } } } - let held: BTreeSet = read - .sent - .iter() - .filter(|sent| unreadable_recipients.contains(&sent.recipient_id)) - .map(|sent| sent.sender_id) - .collect(); - - // The wallet's records of the page's requests, under the shared - // keys the signer computes. - let mut records: Vec<(Identifier, ContactRequest, bool, Option<[u8; 43]>)> = Vec::new(); - // The entry commitment of each sent request's record: a pending - // request is settled by it once that record is reconciled. - let mut sent_commitments: Vec<([u8; 32], ContactRequest)> = Vec::new(); - for received in &read.received { - let Some(sender) = proved_identities - .get(&received.sender_id) - .and_then(|proved| proved.identity.as_ref()) - else { - continue; - }; - let sender_key = identity_public_key(sender, received.sender_key_id)?; - let shared_secret = crypto - .ecdh_shared_secret( - &key_path(&received.recipient_id, received.recipient_key_id)?, - &sender_key, + let scans = if fetched.is_empty() { + Vec::new() + } else { + let pages: Vec<(u64, &PrivateDocumentEntries)> = fetched + .iter() + .map(|page| (page.start_position, &page.entries)) + .collect(); + crypto + .scan_private_document_pages( + &paths, + contract.id(), + PRIVATE_CONTACT_REQUEST_DOCUMENT_TYPE_NAME, + &pages, ) - .await?; - // Accepting replies privately, by hand: no auto-accept. - let mut record = private_request_record( - received.sender_id, - received.recipient_id, - received.sender_key_id, - received.recipient_key_id, - &received.request, - &received.commitment, - &shared_secret, - received.block_time_ms, - ); - record.auto_accept_proof = None; - records.push(( - received.recipient_id, - record, - true, - received.request.shielded_address, - )); + .await? + }; + if scans.len() != fetched.len() { + return Err(PlatformWalletError::InvalidIdentityData(format!( + "the crypto provider sorted {} pages into {} scans", + fetched.len(), + scans.len() + ))); } - for sent in &read.sent { - if unreadable_recipients.contains(&sent.recipient_id) { - continue; + let mut reached_end = false; + for scan in scans { + report.pages_read += 1; + let stale: BTreeMap = private_contact_request_identities(&scan) + .into_iter() + .filter(|(identity_id, height)| { + proved_identities + .get(identity_id) + .is_none_or(|proved| proved.proved_at_height < *height) + }) + .collect(); + proved_identities.extend( + self.sdk + .fetch_private_contact_request_identities(&stale) + .await?, + ); + let read = private_contact_requests_from_scan( + &contract, + scan, + &proved_identities, + self.sdk.version(), + )?; + // Each recipient is fetched on its own, so one that cannot be read + // holds back only the identities whose sent requests it is for. + let mut stale_recipients: BTreeMap = BTreeMap::new(); + for sent in &read.sent { + let height = stale_recipients.entry(sent.recipient_id).or_default(); + *height = (*height).max(sent.block_height); } - let recipient_key = sent.recipient_public_key.serialize(); - let recipient_key_id = recipients - .get(&sent.recipient_id) - .and_then(|proved| proved.identity.as_ref()) - .and_then(|recipient| { - recipient - .public_keys() - .iter() - .find(|(_, key)| key.data().as_slice() == recipient_key.as_slice()) - .map(|(key_id, _)| *key_id) - }); - let Some(recipient_key_id) = recipient_key_id else { - tracing::warn!( - sender = %sent.sender_id, - recipient = %sent.recipient_id, - position = sent.position, - "sent private contact request is sealed to a key its recipient does not \ - hold; not recorded" + stale_recipients.retain(|recipient_id, height| { + recipients + .get(recipient_id) + .is_none_or(|proved| proved.proved_at_height < *height) + }); + let mut unreadable_recipients = BTreeSet::new(); + for (recipient_id, height) in stale_recipients { + match self + .sdk + .fetch_private_contact_request_identities(&BTreeMap::from([( + recipient_id, + height, + )])) + .await + { + Ok(fetched) => recipients.extend(fetched), + Err(error) => { + tracing::warn!( + recipient = %recipient_id, + %error, + "the recipient of a sent private contact request could not be read; \ + its sender's cursor is held" + ); + unreadable_recipients.insert(recipient_id); + } + } + } + let held: BTreeSet = read + .sent + .iter() + .filter(|sent| unreadable_recipients.contains(&sent.recipient_id)) + .map(|sent| sent.sender_id) + .collect(); + + // The wallet's records of the page's requests, under the shared + // keys the signer computes. + let mut records: Vec<(Identifier, ContactRequest, bool, Option<[u8; 43]>)> = + Vec::new(); + // The entry commitment of each sent request's record: a pending + // request is settled by it once that record is reconciled. + let mut sent_commitments: Vec<([u8; 32], ContactRequest)> = Vec::new(); + for received in &read.received { + let Some(sender) = proved_identities + .get(&received.sender_id) + .and_then(|proved| proved.identity.as_ref()) + else { + continue; + }; + let sender_key = identity_public_key(sender, received.sender_key_id)?; + let shared_secret = crypto + .ecdh_shared_secret( + &key_path(&received.recipient_id, received.recipient_key_id)?, + &sender_key, + ) + .await?; + // Accepting replies privately, by hand: no auto-accept. + let mut record = private_request_record( + received.sender_id, + received.recipient_id, + received.sender_key_id, + received.recipient_key_id, + &received.request, + &received.commitment, + &shared_secret, + received.block_time_ms, ); - continue; - }; - let shared_secret = crypto - .ecdh_shared_secret( - &key_path(&sent.sender_id, sent.sender_key_id)?, - &sent.recipient_public_key, - ) - .await?; - let record = private_request_record( - sent.sender_id, - sent.recipient_id, - sent.sender_key_id, - recipient_key_id, - &sent.request, - &sent.commitment, - &shared_secret, - sent.block_time_ms, - ); - sent_commitments.push((sent.commitment, record.clone())); - records.push((sent.sender_id, record, false, None)); - } - for rejected in &read.rejected { - tracing::debug!( - position = rejected.position, - reason = ?rejected.reason, - "private contact request refused" - ); - } - report.rejected.extend(read.rejected.iter().cloned()); + record.auto_accept_proof = None; + records.push(( + received.recipient_id, + record, + true, + received.request.shielded_address, + )); + } + for sent in &read.sent { + if unreadable_recipients.contains(&sent.recipient_id) { + continue; + } + let recipient_key = sent.recipient_public_key.serialize(); + let recipient_key_id = recipients + .get(&sent.recipient_id) + .and_then(|proved| proved.identity.as_ref()) + .and_then(|recipient| { + recipient + .public_keys() + .iter() + .find(|(_, key)| key.data().as_slice() == recipient_key.as_slice()) + .map(|(key_id, _)| *key_id) + }); + let Some(recipient_key_id) = recipient_key_id else { + tracing::warn!( + sender = %sent.sender_id, + recipient = %sent.recipient_id, + position = sent.position, + "sent private contact request is sealed to a key its recipient does not \ + hold; not recorded" + ); + continue; + }; + let shared_secret = crypto + .ecdh_shared_secret( + &key_path(&sent.sender_id, sent.sender_key_id)?, + &sent.recipient_public_key, + ) + .await?; + let record = private_request_record( + sent.sender_id, + sent.recipient_id, + sent.sender_key_id, + recipient_key_id, + &sent.request, + &sent.commitment, + &shared_secret, + sent.block_time_ms, + ); + sent_commitments.push((sent.commitment, record.clone())); + records.push((sent.sender_id, record, false, None)); + } + for rejected in &read.rejected { + tracing::debug!( + position = rejected.position, + reason = ?rejected.reason, + "private contact request refused" + ); + } + report.rejected.extend(read.rejected.iter().cloned()); - // Ingest what the page held for each identity, then advance its - // cursor past the page if that reached disk. - let mut candidates = Vec::new(); - { - let mut wm = self.wallet_manager.write().await; - let (wallet, info) = - wm.get_wallet_mut_and_info_mut(&self.wallet_id) + // Ingest what the page held for each identity, then advance its + // cursor past the page if that reached disk. + let mut candidates = Vec::new(); + { + let mut wm = self.wallet_manager.write().await; + let (wallet, info) = wm + .get_wallet_mut_and_info_mut(&self.wallet_id) .ok_or_else(|| { PlatformWalletError::WalletNotFound(hex::encode(self.wallet_id)) })?; - for identity in identities.iter_mut() { - let identity_id = identity.identity_id; - let Some(managed) = info.identity_manager.managed_identity_mut(&identity_id) - else { - continue; - }; - if held.contains(&identity_id) && !identity.failed { - identity.failed = true; - report.unpersisted_identities.push(identity_id); - } - let mut received = Vec::new(); - let mut addresses = Vec::new(); - let mut sent = Vec::new(); - for (owner, record, is_received, shielded_address) in &records { - if *owner != identity_id { + for identity in identities.iter_mut() { + let identity_id = identity.identity_id; + let Some(managed) = + info.identity_manager.managed_identity_mut(&identity_id) + else { continue; + }; + if held.contains(&identity_id) && !identity.failed { + identity.failed = true; + report.unpersisted_identities.push(identity_id); } - if *is_received { - addresses.push((record.clone(), *shielded_address)); - received.push(record.clone()); - } else { - // Our receiving account for the contact is - // scanned from the wallet's birth. - managed.note_sent_request_core_height(record.recipient_id, 0); - sent.push(record.clone()); + let mut received = Vec::new(); + let mut addresses = Vec::new(); + let mut sent = Vec::new(); + for (owner, record, is_received, shielded_address) in &records { + if *owner != identity_id { + continue; + } + if *is_received { + addresses.push((record.clone(), *shielded_address)); + received.push(record.clone()); + } else { + // Our receiving account for the contact is + // scanned from the wallet's birth. + managed.note_sent_request_core_height(record.recipient_id, 0); + sent.push(record.clone()); + } } - } - let mut rotated = Vec::new(); - let mut ingested = Vec::new(); - let received_ok = ingest_received_requests( - managed, - &self.persister, - identity_id, - newest_received_per_sender(received), - &mut rotated, - &mut ingested, - ); - let sent_ok = ingest_sent_requests( - managed, - &self.persister, - identity_id, - newest_sent_per_recipient(sent), - &mut ingested, - ); - report.requests.extend(ingested); - // The address of the request tracked from each contact, - // once ingest decided which that is. - for (record, shielded_address) in &addresses { - managed.note_private_shielded_address(record, *shielded_address); - } - if received_ok && sent_ok { - // A pending private request is settled once its - // entry's record is reconciled: the request tracked - // to the recipient is that record, carries its - // account reference (another copy of it), or is - // newer. An entry whose record was not built, its - // recipient unreadable, settles nothing. - for (commitment, record) in sent_commitments - .iter() - .filter(|(_, record)| record.sender_id == identity_id) - { - let reconciled = managed - .dashpay() - .outgoing_request(&record.recipient_id) - .is_some_and(|tracked| { - tracked.account_reference == record.account_reference - || predates(record, tracked) - }); - if reconciled { - managed.settle_private_reply(&record.recipient_id, commitment); + let mut rotated = Vec::new(); + let mut ingested = Vec::new(); + let received_ok = ingest_received_requests( + managed, + &self.persister, + identity_id, + newest_received_per_sender(received), + &mut rotated, + &mut ingested, + ); + let sent_ok = ingest_sent_requests( + managed, + &self.persister, + identity_id, + newest_sent_per_recipient(sent), + &mut ingested, + ); + report.requests.extend(ingested); + // The address of the request tracked from each contact, + // once ingest decided which that is. + for (record, shielded_address) in &addresses { + managed.note_private_shielded_address(record, *shielded_address); + } + if received_ok && sent_ok { + // A pending private request is settled once its + // entry's record is reconciled: the request tracked + // to the recipient is that record, carries its + // account reference (another copy of it), or is + // newer. An entry whose record was not built, its + // recipient unreadable, settles nothing. + for (commitment, record) in sent_commitments + .iter() + .filter(|(_, record)| record.sender_id == identity_id) + { + let reconciled = managed + .dashpay() + .outgoing_request(&record.recipient_id) + .is_some_and(|tracked| { + tracked.account_reference == record.account_reference + || predates(record, tracked) + }); + if reconciled { + managed.settle_private_reply(&record.recipient_id, commitment); + } } + identity.advance(managed, read.next_position); + } else if !identity.failed { + identity.failed = true; + report.unpersisted_identities.push(identity_id); } - identity.advance(managed, read.next_position); - } else if !identity.failed { - identity.failed = true; - report.unpersisted_identities.push(identity_id); + // Read to the store's end with nothing held back this + // pass: every entry was ingested for this identity. + if read.is_complete() && !identity.failed { + managed.note_private_contact_requests_read(); + } + tear_down_stale_external_accounts(wallet, info, &identity_id, rotated); + candidates.push(( + identity_id, + Self::collect_account_build_candidates(info, &identity_id), + )); } - // Read to the store's end with nothing held back this - // pass: every entry was ingested for this identity. - if read.is_complete() && !identity.failed { - managed.note_private_contact_requests_read(); + } + for (identity_id, identity_candidates) in candidates { + for candidate in identity_candidates { + self.build_contact_accounts(&identity_id, candidate).await; } - tear_down_stale_external_accounts(wallet, info, &identity_id, rotated); - candidates.push(( - identity_id, - Self::collect_account_build_candidates(info, &identity_id), - )); } - } - for (identity_id, identity_candidates) in candidates { - for candidate in identity_candidates { - self.build_contact_accounts(&identity_id, candidate).await; + + report.next_position = read.next_position; + if read.is_complete() { + // A pass whose ingest did not reach disk for an identity is + // not complete: that identity reads its entries again. + report.complete = report.unpersisted_identities.is_empty(); + reached_end = true; + break; } } - - report.next_position = read.next_position; - position = read.next_position; - if read.is_complete() { - // A pass whose ingest did not reach disk for an identity is - // not complete: that identity reads its entries again. - report.complete = report.unpersisted_identities.is_empty(); + if let Some(error) = failure { + return Err(error.into()); + } + if reached_end { break; } } @@ -1376,7 +1418,9 @@ mod tests { use crate::wallet::platform_wallet::{PlatformWallet, WalletId}; use crate::PlatformWalletManager; use dash_sdk::error::ContextProviderError; - use dash_sdk::platform::private_document_entries::PrivateDocumentEntry; + use dash_sdk::platform::private_document_entries::{ + PrivateDocumentEntriesQuery, PrivateDocumentEntry, + }; use dash_sdk::platform::ContextProvider; use dpp::address_funds::AddressWitness; use dpp::data_contract::{DataContract, TokenConfiguration}; @@ -1402,6 +1446,9 @@ mod tests { "legal winner thank year wave sausage worth useful legal winner thank yellow"; const ALICE: [u8; 32] = [0xA1; 32]; const BOB: [u8; 32] = [0xB1; 32]; + const CAROL_PHRASE: &str = + "letter advice cage absurd amount doctor acoustic avoid letter advice cage above"; + const CAROL: [u8; 32] = [0xC2; 32]; /// The time of the block that appends the test entries. const BLOCK_TIME_MS: u64 = 1_700_000_000_000; /// The key ids of each test identity's signing key and DashPay keys. @@ -2917,6 +2964,161 @@ mod tests { ); } + /// From now on, `wallet`'s mock SDK serves a store holding `entries` in + /// chunks of `chunk_size`, a page from each chunk's start to its end or + /// the store's. + async fn publish_chunked(wallet: &PlatformWallet, entries: &[Vec], chunk_size: usize) { + let total = entries.len() as u64; + for (index, chunk) in entries.chunks(chunk_size).enumerate() { + let start = (index * chunk_size) as u64; + let mut page = store_page(start, chunk.to_vec()); + page.total_count = total; + let contract = dashpay_contract().expect("bundled DashPay"); + let query = PrivateDocumentEntriesQuery::new( + contract.id(), + PRIVATE_CONTACT_REQUEST_DOCUMENT_TYPE_NAME, + start, + ); + let mut sdk = (*wallet.identity().sdk).clone(); + let mut mock = sdk.mock(); + mock.remove_fetch_expectation::(query.clone()) + .await; + mock.expect_fetch(query, Some(page)) + .await + .expect("page expectation"); + } + } + + /// What `wallet` tracks with `contacts` after a sync: each one's requests + /// both ways. + async fn tracked( + wallet: &PlatformWallet, + identity_id: [u8; 32], + contacts: &[Identifier], + ) -> Vec<(Option, Option)> { + managed(wallet, identity_id, |managed| { + contacts + .iter() + .map(|contact| { + ( + managed.dashpay().outgoing_request(contact).cloned(), + managed.dashpay().incoming_request(contact).cloned(), + ) + }) + .collect() + }) + .await + } + + /// A store of many pages is read in one pass, its pages fetched together + /// and scanned in batches: the wallet records what it records from the + /// same entries in one page, and derives the scan keys once a batch, not + /// once a page. + #[tokio::test] + async fn should_sync_a_store_of_many_pages_in_one_pass() { + let alice = identity(ALICE_PHRASE, ALICE); + let bob = identity(BOB_PHRASE, BOB); + let carol = identity(CAROL_PHRASE, CAROL); + let alice_crypto = SeedCryptoProvider::from_seed(seed(ALICE_PHRASE), Network::Testnet); + let bob_crypto = SeedCryptoProvider::from_seed(seed(BOB_PHRASE), Network::Testnet); + let alice_signer = signer(ALICE_PHRASE, &alice); + let bob_signer = signer(BOB_PHRASE, &bob); + let everyone = [&alice, &bob, &carol]; + + // Alice's requests to Bob and Carol, and Bob's to Alice. + let (_alice_manager, alice_wallet) = wallet(ALICE_PHRASE, &alice, &everyone, vec![]).await; + let kept = Arc::new(Mutex::new(Vec::new())); + for (start, recipient) in [(0, bob.id()), (1, carol.id())] { + alice_wallet + .identity() + .dashpay() + .send_private_contact_request( + &alice.id(), + &recipient, + None, + AutoAcceptProofSource::None, + None, + &alice_signer, + &alice_crypto, + append_entry(&alice_wallet, start, vec![], &kept), + ) + .await + .expect("Alice sends"); + } + let to_bob = kept.lock().expect("kept")[0].to_vec(); + let (_bob_manager, bob_wallet) = wallet(BOB_PHRASE, &bob, &everyone, vec![to_bob]).await; + bob_wallet + .identity() + .dashpay() + .send_private_contact_request( + &bob.id(), + &alice.id(), + None, + AutoAcceptProofSource::None, + None, + &bob_signer, + &bob_crypto, + append_entry(&bob_wallet, 1, vec![], &kept), + ) + .await + .expect("Bob sends"); + let entries: Vec> = kept + .lock() + .expect("kept") + .iter() + .map(|entry| entry.to_vec()) + .collect(); + assert_eq!(entries.len(), 3); + let contacts = [bob.id(), carol.id()]; + + // Restored from seed, once with the store in one page and once in + // pages of one entry. + let (_one_manager, one_page) = + wallet(ALICE_PHRASE, &alice, &everyone, entries.clone()).await; + let one_page_crypto = SeedCryptoProvider::from_seed(seed(ALICE_PHRASE), Network::Testnet); + let one = one_page + .identity() + .dashpay() + .sync_private_contact_requests(&one_page_crypto, 64) + .await + .expect("the one-page store reads"); + assert_eq!(one.pages_read, 1); + + let (_many_manager, many_pages) = wallet(ALICE_PHRASE, &alice, &everyone, vec![]).await; + publish_chunked(&many_pages, &entries, 1).await; + let many_pages_crypto = SeedCryptoProvider::from_seed(seed(ALICE_PHRASE), Network::Testnet); + let many = many_pages + .identity() + .dashpay() + .sync_private_contact_requests(&many_pages_crypto, 64) + .await + .expect("the store of many pages reads"); + assert_eq!(many.pages_read, 3); + assert!(many.complete); + assert_eq!(many.next_position, 3); + assert_eq!(many.rejected, vec![]); + assert_eq!(many.requests.len(), 3, "{:?}", many.requests); + // A page's received requests are recorded before its sent ones, so + // the order differs; the requests do not. + let sorted = |mut requests: Vec| { + requests.sort_by_key(|request| (request.sender_id, request.recipient_id)); + requests + }; + assert_eq!(sorted(many.requests.clone()), sorted(one.requests.clone())); + assert_eq!( + tracked(&many_pages, ALICE, &contacts).await, + tracked(&one_page, ALICE, &contacts).await + ); + let derivations = many_pages_crypto + .scan_key_derivations + .load(std::sync::atomic::Ordering::SeqCst); + assert!( + (1..many.pages_read).contains(&derivations), + "{derivations} derivations for {} pages", + many.pages_read + ); + } + /// A `pay` that must not run. fn no_payment( _: BinaryData, From 76c5418a5c04b574bd42774b7dd03133c83cfdc6 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 9 Nov 2026 05:09:41 +0700 Subject: [PATCH 032/117] feat(platform-wallet): keep the private contact request read position across restarts Each identity's position in DashPay's private contact request store, and each private contact's shielded address, lived in memory only, so every launch read the whole store again before the first private send. Both now go through the host persister, for a backend attesting the new `PersistenceCapabilities::PRIVATE_CONTACT_REQUEST_SCAN_STATE` (bit 14): - `ContactChangeSet` carries `private_contact_requests_positions` (owner to position, last write wins), `private_shielded_addresses` ((owner, contact) to the 43-byte address) and its tombstone set `removed_private_shielded_addresses`. - A sync stores a page's addresses after the page's requests, and the identity's new position only after both reached disk. A failed address store holds the cursor back like a failed ingest; a failed position store leaves the cursor advanced in memory, so the next launch reads from the last position stored and ingest repeats harmlessly. - Un-ignoring a sender stores the rewound position. - A loader restores them with `ManagedIdentity::apply_private_contact_requests_position` and `apply_private_shielded_address`; the changeset replay applies them too. A backend without the bit is sent none of it, and a load drops any position or address it restored, so it keeps today's behaviour: the store is read from its start. Co-Authored-By: Claude Opus 5.5 --- .../src/changeset/changeset.rs | 56 ++- .../changeset/identity_manager_start_state.rs | 19 + .../src/changeset/persistence_capabilities.rs | 25 +- .../src/changeset/serde_adapters.rs | 38 ++ .../rs-platform-wallet/src/manager/load.rs | 20 +- .../rs-platform-wallet/src/wallet/apply.rs | 20 + .../identity/network/contact_requests.rs | 11 +- .../network/private_contact_requests.rs | 395 +++++++++++++++++- .../managed_identity/contact_requests.rs | 33 ++ .../state/managed_identity/dashpay.rs | 24 +- .../src/wallet/persister.rs | 8 + 11 files changed, 626 insertions(+), 23 deletions(-) diff --git a/packages/rs-platform-wallet/src/changeset/changeset.rs b/packages/rs-platform-wallet/src/changeset/changeset.rs index 36d3faeef7c..c58a6f60af7 100644 --- a/packages/rs-platform-wallet/src/changeset/changeset.rs +++ b/packages/rs-platform-wallet/src/changeset/changeset.rs @@ -1298,9 +1298,11 @@ pub struct ReceivedContactRequestKey { /// (a `sent_requests` insert clears a prior `removed_sent`, an un-ignore /// clears a prior ignore, and vice versa), keeping the two sets /// disjoint. This covers `sent_requests` vs `removed_sent`, -/// `incoming_requests` vs `removed_incoming`, and `ignored` vs -/// `unignored`. `established` has no opposing tombstone set and rides -/// plain last-write-wins. +/// `incoming_requests` vs `removed_incoming`, `ignored` vs +/// `unignored`, and `private_shielded_addresses` vs +/// `removed_private_shielded_addresses`. `established` and +/// `private_contact_requests_positions` have no opposing tombstone set +/// and ride plain last-write-wins. #[derive(Debug, Clone, Default, PartialEq)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] pub struct ContactChangeSet { @@ -1330,6 +1332,37 @@ pub struct ContactChangeSet { /// sweep. Kept as a separate set (rather than a shrinking `ignored` /// snapshot) so the changeset's insert-XOR-tombstone discipline holds. pub unignored: BTreeSet<(Identifier, Identifier)>, + /// The position of DashPay's private contact request store each owner + /// identity reads from next, keyed by owner: every entry before it was + /// read and what it held for the owner stored. Written only for a backend + /// attesting + /// [`PRIVATE_CONTACT_REQUEST_SCAN_STATE`](crate::changeset::PersistenceCapabilities::PRIVATE_CONTACT_REQUEST_SCAN_STATE), + /// and only after the stores of what the entries before it held, so a + /// restored position never covers a request that did not reach disk. A + /// rewind (un-ignoring a sender) writes the lower position. Last write + /// wins. + #[cfg_attr(feature = "serde", serde(default))] + pub private_contact_requests_positions: BTreeMap, + /// The Orchard address each contact's tracked private request to the + /// owner carries, keyed by `(owner, contact)`: 43 bytes, an 11-byte + /// diversifier and a 32-byte transmission key. Written only for a + /// backend attesting + /// [`PRIVATE_CONTACT_REQUEST_SCAN_STATE`](crate::changeset::PersistenceCapabilities::PRIVATE_CONTACT_REQUEST_SCAN_STATE). + /// Reconciled last-write-wins against + /// [`Self::removed_private_shielded_addresses`] on merge. + #[cfg_attr( + feature = "serde", + serde( + default, + with = "crate::changeset::serde_adapters::private_shielded_addresses" + ) + )] + pub private_shielded_addresses: BTreeMap<(Identifier, Identifier), [u8; 43]>, + /// `(owner, contact)` pairs whose tracked private request carries no + /// shielded address any more: the removal tombstone for + /// [`Self::private_shielded_addresses`]. + #[cfg_attr(feature = "serde", serde(default))] + pub removed_private_shielded_addresses: BTreeSet<(Identifier, Identifier)>, } impl Merge for ContactChangeSet { @@ -1370,6 +1403,20 @@ impl Merge for ContactChangeSet { } self.ignored.extend(other.ignored); self.unignored.extend(other.unignored); + + self.private_contact_requests_positions + .extend(other.private_contact_requests_positions); + + for key in other.removed_private_shielded_addresses.iter() { + self.private_shielded_addresses.remove(key); + } + for key in other.private_shielded_addresses.keys() { + self.removed_private_shielded_addresses.remove(key); + } + self.private_shielded_addresses + .extend(other.private_shielded_addresses); + self.removed_private_shielded_addresses + .extend(other.removed_private_shielded_addresses); } fn is_empty(&self) -> bool { @@ -1380,6 +1427,9 @@ impl Merge for ContactChangeSet { && self.established.is_empty() && self.ignored.is_empty() && self.unignored.is_empty() + && self.private_contact_requests_positions.is_empty() + && self.private_shielded_addresses.is_empty() + && self.removed_private_shielded_addresses.is_empty() } } diff --git a/packages/rs-platform-wallet/src/changeset/identity_manager_start_state.rs b/packages/rs-platform-wallet/src/changeset/identity_manager_start_state.rs index a4072b61848..850edff799e 100644 --- a/packages/rs-platform-wallet/src/changeset/identity_manager_start_state.rs +++ b/packages/rs-platform-wallet/src/changeset/identity_manager_start_state.rs @@ -37,3 +37,22 @@ pub struct IdentityManagerStartState { /// forces a rescan. See [`IdentityScanStateEntry`]. pub scan_states: BTreeMap, } + +impl IdentityManagerStartState { + /// Drop the private contact request scan state every restored identity + /// carries (its read position and its contacts' shielded addresses), as a + /// load does for a backend that does not attest + /// [`PRIVATE_CONTACT_REQUEST_SCAN_STATE`](crate::changeset::PersistenceCapabilities::PRIVATE_CONTACT_REQUEST_SCAN_STATE): + /// nothing keeps such a backend's copy current, so the store is read from + /// its start. + pub(crate) fn forget_private_contact_scan_state(&mut self) { + for managed in self + .wallet_identities + .values_mut() + .flat_map(BTreeMap::values_mut) + .chain(self.out_of_wallet_identities.values_mut()) + { + managed.forget_private_contact_scan_state(); + } + } +} diff --git a/packages/rs-platform-wallet/src/changeset/persistence_capabilities.rs b/packages/rs-platform-wallet/src/changeset/persistence_capabilities.rs index 6c17b550b1e..21d07be3706 100644 --- a/packages/rs-platform-wallet/src/changeset/persistence_capabilities.rs +++ b/packages/rs-platform-wallet/src/changeset/persistence_capabilities.rs @@ -111,13 +111,26 @@ impl PersistenceCapabilities { /// empty result. Required before a wallet drain can treat absence as not sent. pub const ASSET_LOCK_OPERATION_IDS: Self = Self(1 << 13); + /// The private contact request sync's scan state round-trips: each + /// identity's read position in DashPay's private contact request store + /// (`ContactChangeSet::private_contact_requests_positions`) and each + /// private contact's shielded address + /// (`ContactChangeSet::private_shielded_addresses`, with its removals) + /// are stored, and loading restores them onto each `ManagedIdentity` + /// (`ManagedIdentity::apply_private_contact_requests_position`, + /// `ManagedIdentity::apply_private_shielded_address`), so a relaunch + /// reads the store from where it stopped. Without this bit nothing of + /// it is written, a restored value is dropped, and each launch reads the + /// store from its start. + pub const PRIVATE_CONTACT_REQUEST_SCAN_STATE: Self = Self(1 << 14); + /// Index of the highest bit declared above. It lives here, beside the /// constants, so adding a bit and bumping this is one edit in one place /// — and `every_declared_bit_has_a_stable_name` walks up to it, so a new /// bit that never reaches `KNOWN` fails a test instead of gating /// behaviour invisibly. The same test asserts nothing above it is named, /// which is what catches a bit added without bumping this. - const HIGHEST_DECLARED_BIT: u32 = 13; + const HIGHEST_DECLARED_BIT: u32 = 14; /// Capabilities required before exporting and funding an invitation voucher. pub const INVITATION_CREATION: Self = Self( @@ -221,6 +234,10 @@ impl PersistenceCapabilities { PersistenceCapabilities::ASSET_LOCK_OPERATION_IDS, "asset_lock_operation_ids", ), + ( + PersistenceCapabilities::PRIVATE_CONTACT_REQUEST_SCAN_STATE, + "private_contact_request_scan_state", + ), ]; KNOWN @@ -238,7 +255,7 @@ impl PersistenceCapabilities { /// failure the test exists to catch. Written as a module-level `const _` so /// it is evaluated in every build, test or not. const _: () = assert!( - PersistenceCapabilities::ASSET_LOCK_OPERATION_IDS.bits() + PersistenceCapabilities::PRIVATE_CONTACT_REQUEST_SCAN_STATE.bits() == 1u64 << PersistenceCapabilities::HIGHEST_DECLARED_BIT, "HIGHEST_DECLARED_BIT must name the highest declared capability bit" ); @@ -270,6 +287,10 @@ mod tests { PersistenceCapabilities::ASSET_LOCK_OPERATION_IDS.bits(), 0x2000 ); + assert_eq!( + PersistenceCapabilities::PRIVATE_CONTACT_REQUEST_SCAN_STATE.bits(), + 0x4000 + ); assert_eq!(PersistenceCapabilities::WALLET_DRAIN.bits(), 0x2285); assert_eq!( PersistenceCapabilities::ASSET_LOCK_RECONCILIATION.bits(), diff --git a/packages/rs-platform-wallet/src/changeset/serde_adapters.rs b/packages/rs-platform-wallet/src/changeset/serde_adapters.rs index a4dddbd2092..379e0a99260 100644 --- a/packages/rs-platform-wallet/src/changeset/serde_adapters.rs +++ b/packages/rs-platform-wallet/src/changeset/serde_adapters.rs @@ -11,6 +11,44 @@ use dpp::prelude::AddressNonce; use key_wallet::wallet::managed_wallet_info::asset_lock_builder::AssetLockFundingType; use serde::{Deserialize, Deserializer, Serialize, Serializer}; +/// Adapter for the private shielded addresses of a contact changeset: +/// serde derives no 43-byte array, so each `(owner, contact)` address rides +/// as an `(owner, contact, bytes)` row, its length checked on the way back. +pub mod private_shielded_addresses { + use super::*; + use dpp::prelude::Identifier; + use serde::de::Error as _; + use std::collections::BTreeMap; + + type Addresses = BTreeMap<(Identifier, Identifier), [u8; 43]>; + + pub fn serialize( + addresses: &Addresses, + serializer: S, + ) -> Result { + serializer.collect_seq( + addresses + .iter() + .map(|((owner, contact), address)| (owner, contact, address.as_slice())), + ) + } + + pub fn deserialize<'de, D: Deserializer<'de>>(deserializer: D) -> Result { + Vec::<(Identifier, Identifier, Vec)>::deserialize(deserializer)? + .into_iter() + .map(|(owner, contact, address)| { + let address: [u8; 43] = address.as_slice().try_into().map_err(|_| { + D::Error::custom(format!( + "a private shielded address is 43 bytes, not {}", + address.len() + )) + })?; + Ok(((owner, contact), address)) + }) + .collect() + } +} + /// Adapter for `AssetLockFundingType` (upstream has no serde derive). /// /// Encodes each variant as a stable u8 tag — same tag space the diff --git a/packages/rs-platform-wallet/src/manager/load.rs b/packages/rs-platform-wallet/src/manager/load.rs index 1b612536087..b1ce7e9e0f2 100644 --- a/packages/rs-platform-wallet/src/manager/load.rs +++ b/packages/rs-platform-wallet/src/manager/load.rs @@ -3,7 +3,9 @@ use std::collections::BTreeMap; use std::sync::Arc; -use crate::changeset::{ClientStartState, ClientWalletStartState, PlatformWalletPersistence}; +use crate::changeset::{ + ClientStartState, ClientWalletStartState, PersistenceCapabilities, PlatformWalletPersistence, +}; use crate::error::PlatformWalletError; use crate::wallet::core::WalletGeneration; use crate::wallet::identity::IdentityManager; @@ -143,7 +145,7 @@ impl PlatformWalletManager

{ let ClientWalletStartState { mut wallet, mut wallet_info, - identity_manager, + mut identity_manager, unused_asset_locks, unconfirmed_outgoing_txs, dashpay_backfill, @@ -244,7 +246,19 @@ impl PlatformWalletManager

{ observed_input_conflicts: Default::default(), core_wallet: wallet_info, generation: Arc::clone(&generation), - identity_manager: IdentityManager::from(identity_manager), + identity_manager: { + // A backend that does not attest the private contact + // request scan state keeps no current copy of it: the + // store is read from its start. + if !self + .persister + .persistence_capabilities() + .contains(PersistenceCapabilities::PRIVATE_CONTACT_REQUEST_SCAN_STATE) + { + identity_manager.forget_private_contact_scan_state(); + } + IdentityManager::from(identity_manager) + }, tracked_asset_locks, dpns_name_states: std::collections::BTreeMap::new(), dashpay_backfill, diff --git a/packages/rs-platform-wallet/src/wallet/apply.rs b/packages/rs-platform-wallet/src/wallet/apply.rs index 25b8022e280..96d89dd0908 100644 --- a/packages/rs-platform-wallet/src/wallet/apply.rs +++ b/packages/rs-platform-wallet/src/wallet/apply.rs @@ -234,6 +234,9 @@ impl PlatformWalletInfo { established, ignored, unignored, + private_contact_requests_positions, + private_shielded_addresses, + removed_private_shielded_addresses, } = contact_cs; for (key, entry) in sent_requests { @@ -303,6 +306,23 @@ impl PlatformWalletInfo { managed.apply_unignored_sender(&sender_id); } } + // The private contact request scan state: each owner's read + // position, and its contacts' shielded addresses, removals last. + for (owner_id, position) in private_contact_requests_positions { + if let Some(managed) = self.identity_manager.managed_identity_mut(&owner_id) { + managed.apply_private_contact_requests_position(position); + } + } + for ((owner_id, contact_id), address) in private_shielded_addresses { + if let Some(managed) = self.identity_manager.managed_identity_mut(&owner_id) { + managed.apply_private_shielded_address(contact_id, address); + } + } + for (owner_id, contact_id) in removed_private_shielded_addresses { + if let Some(managed) = self.identity_manager.managed_identity_mut(&owner_id) { + managed.apply_removed_private_shielded_address(&contact_id); + } + } } // 3b. DashPay profile/payment overlays. Applied AFTER identities diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs b/packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs index a00b67e2668..484f388d89c 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs @@ -4462,11 +4462,20 @@ impl DashPayView<'_, B> { // `unignore_sender` removes the sender + rewinds the cursor and // returns the removal changeset (empty if the sender wasn't // ignored). Persist it so the ignored-sender row is deleted. - let cs = managed.unignore_sender(contact_identity_id); + let mut cs = managed.unignore_sender(contact_identity_id); if ::is_empty(&cs) { // Not ignored — nothing to persist, but not an error. return Ok(()); } + // The private read position was rewound with the received cursor: a + // backend that keeps it stores the rewind, so a relaunch reads the + // un-ignored sender's private requests again too. + if self.persister.persists_private_contact_scan_state() { + cs.private_contact_requests_positions.insert( + *identity_id, + managed.dashpay().private_contact_requests_position(), + ); + } self.persister.store(cs.into()).map_err(|e| { PlatformWalletError::Persistence(format!("un-ignore not persisted: {e}")) })?; diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/private_contact_requests.rs b/packages/rs-platform-wallet/src/wallet/identity/network/private_contact_requests.rs index 1a10e597a00..a7d063bfb9e 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/private_contact_requests.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/private_contact_requests.rs @@ -68,10 +68,12 @@ use super::contact_requests::{ use super::signing_key::first_available; use super::{dashpay_contract, DashPayView, IdentityWallet}; use crate::broadcaster::TransactionBroadcaster; +use crate::changeset::ContactChangeSet; use crate::error::PlatformWalletError; use crate::wallet::identity::types::dashpay::contact_request::ContactRequest; use crate::wallet::identity::types::dashpay::established_contact::EstablishedContact; use crate::wallet::identity::ManagedIdentity; +use crate::wallet::persister::WalletPersister; use crate::wallet::platform_wallet::{PlatformWalletInfo, WalletId}; /// The paths of the keys a wallet's identities read DashPay's private contact @@ -261,6 +263,47 @@ fn release_reservation( } } +/// The changeset recording, for each contact one of `addresses` came from, the +/// shielded address `owner` keeps for it now, or its removal. +fn shielded_address_changeset( + managed: &ManagedIdentity, + owner: Identifier, + addresses: &[(ContactRequest, Option<[u8; 43]>)], +) -> ContactChangeSet { + let mut changeset = ContactChangeSet::default(); + for (record, _) in addresses { + let key = (owner, record.sender_id); + match managed + .dashpay() + .private_shielded_address(&record.sender_id) + { + Some(address) => { + changeset.private_shielded_addresses.insert(key, address); + } + None => { + changeset.removed_private_shielded_addresses.insert(key); + } + } + } + changeset +} + +/// Store `owner`'s private read position. A failed store leaves the cursor +/// advanced in memory: the next launch reads from the last position stored, +/// and ingest is a fixpoint. +fn store_private_position(persister: &WalletPersister, owner: Identifier, position: u64) { + let mut changeset = ContactChangeSet::default(); + changeset + .private_contact_requests_positions + .insert(owner, position); + if let Err(error) = persister.store(changeset.into()) { + tracing::warn!( + owner = %owner, position, %error, + "private read position not stored; the next launch reads from the last one stored" + ); + } +} + /// The local record of a private request: a [`ContactRequest`] carrying the /// request's compact xpub and label encrypted under `shared_key`, the pair's /// DIP-15 shared key, as a `contactRequest` document carries them. The IVs are @@ -903,6 +946,7 @@ impl DashPayView<'_, B> { // was appended or later. let mut recipients: BTreeMap = BTreeMap::new(); report.next_position = start; + let persists_scan_state = self.persister.persists_private_contact_scan_state(); // The pages are fetched many at once while earlier ones are processed, // and scanned in batches of those ready, one key derivation a batch. @@ -1170,7 +1214,22 @@ impl DashPayView<'_, B> { for (record, shielded_address) in &addresses { managed.note_private_shielded_address(record, *shielded_address); } - if received_ok && sent_ok { + // The addresses are stored before the read position + // that covers the entries they came from. + let mut stored = received_ok && sent_ok; + if stored && persists_scan_state && !addresses.is_empty() { + let addresses_changeset = + shielded_address_changeset(managed, identity_id, &addresses); + if let Err(error) = self.persister.store(addresses_changeset.into()) { + tracing::error!( + owner = %identity_id, %error, + "private contacts' shielded addresses not stored; leaving \ + the private cursor for retry" + ); + stored = false; + } + } + if stored { // A pending private request is settled once its // entry's record is reconciled: the request tracked // to the recipient is that record, carries its @@ -1192,7 +1251,12 @@ impl DashPayView<'_, B> { managed.settle_private_reply(&record.recipient_id, commitment); } } + let before = managed.dashpay().private_contact_requests_position(); identity.advance(managed, read.next_position); + let after = managed.dashpay().private_contact_requests_position(); + if persists_scan_state && after != before { + store_private_position(&self.persister, identity_id, after); + } } else if !identity.failed { identity.failed = true; report.unpersisted_identities.push(identity_id); @@ -1410,7 +1474,8 @@ impl DashPayView<'_, B> { mod tests { use super::*; use crate::changeset::{ - ClientStartState, PersistenceError, PlatformWalletChangeSet, PlatformWalletPersistence, + ClientStartState, IdentityManagerStartState, PersistenceCapabilities, PersistenceError, + PlatformWalletChangeSet, PlatformWalletPersistence, }; use crate::events::{EventHandler, PlatformEventHandler}; use crate::wallet::identity::network::identity_auth_derivation_path_for_type; @@ -1598,6 +1663,55 @@ mod tests { signer } + /// A backend recording every changeset stored, attesting `capabilities`. + #[derive(Default)] + struct RecordingPersistence { + capabilities: PersistenceCapabilities, + stored: Mutex>, + } + + impl RecordingPersistence { + fn attesting(capabilities: PersistenceCapabilities) -> Arc { + Arc::new(Self { + capabilities, + stored: Mutex::new(Vec::new()), + }) + } + + /// The contact changesets stored, in order. + fn contacts(&self) -> Vec { + self.stored + .lock() + .expect("stored") + .iter() + .filter_map(|changeset| changeset.contacts.clone()) + .collect() + } + } + + impl PlatformWalletPersistence for RecordingPersistence { + fn store( + &self, + _wallet_id: WalletId, + changeset: PlatformWalletChangeSet, + ) -> Result<(), PersistenceError> { + self.stored.lock().expect("stored").push(changeset); + Ok(()) + } + + fn flush(&self, _wallet_id: WalletId) -> Result<(), PersistenceError> { + Ok(()) + } + + fn load(&self) -> Result { + Ok(ClientStartState::default()) + } + + fn persistence_capabilities(&self) -> PersistenceCapabilities { + self.capabilities + } + } + /// A wallet of `phrase` holding `own` at identity index 0, on a mock SDK that /// answers identity fetches with `known` and page 0 of DashPay's private /// contact request store with `entries`. @@ -1607,6 +1721,17 @@ mod tests { known: &[&Identity], entries: Vec>, ) -> (PlatformWalletManager, Arc) { + wallet_on(Arc::new(NoPersistence), phrase, own, known, entries).await + } + + /// [`wallet`] on the backend `persistence`. + async fn wallet_on( + persistence: Arc

, + phrase: &str, + own: &Identity, + known: &[&Identity], + entries: Vec>, + ) -> (PlatformWalletManager

, Arc) { let contract = dashpay_contract().expect("bundled DashPay"); let mut sdk = dash_sdk::SdkBuilder::new_mock() .with_network(Network::Testnet) @@ -1648,11 +1773,8 @@ mod tests { .await .expect("page expectation"); - let manager = PlatformWalletManager::new( - Arc::new(sdk), - Arc::new(NoPersistence), - Arc::new(NoopEventHandler), - ); + let manager = + PlatformWalletManager::new(Arc::new(sdk), persistence, Arc::new(NoopEventHandler)); let wallet = manager .create_wallet_from_seed_bytes( Network::Testnet, @@ -3119,6 +3241,265 @@ mod tests { ); } + /// Alice's private request to Bob, carrying `shielded_address`, read back + /// from the store: its entry and Bob's record of it. + async fn alice_to_bob(shielded_address: [u8; 43]) -> (Vec, ContactRequest) { + let alice = identity(ALICE_PHRASE, ALICE); + let bob = identity(BOB_PHRASE, BOB); + let alice_crypto = SeedCryptoProvider::from_seed(seed(ALICE_PHRASE), Network::Testnet); + let bob_crypto = SeedCryptoProvider::from_seed(seed(BOB_PHRASE), Network::Testnet); + let (_alice_manager, alice_wallet) = + wallet(ALICE_PHRASE, &alice, &[&alice, &bob], vec![]).await; + let kept = Arc::new(Mutex::new(Vec::new())); + alice_wallet + .identity() + .dashpay() + .send_private_contact_request( + &alice.id(), + &bob.id(), + None, + AutoAcceptProofSource::None, + Some(shielded_address), + &signer(ALICE_PHRASE, &alice), + &alice_crypto, + append_entry(&alice_wallet, 0, vec![], &kept), + ) + .await + .expect("Alice sends"); + let entry = kept.lock().expect("kept").remove(0).to_vec(); + let (_bob_manager, bob_wallet) = + wallet(BOB_PHRASE, &bob, &[&alice, &bob], vec![entry.clone()]).await; + bob_wallet + .identity() + .dashpay() + .sync_private_contact_requests(&bob_crypto, 4) + .await + .expect("Bob reads the store"); + let received = managed(&bob_wallet, BOB, |managed| { + managed.dashpay().incoming_request(&alice.id()).cloned() + }) + .await + .expect("Bob holds Alice's request"); + (entry, received) + } + + /// For a backend attesting the scan state, a sync stores the private + /// contact's shielded address and then the read position, after the + /// request the entry held. + #[tokio::test] + async fn should_store_the_read_position_after_what_the_entries_held() { + let alice = identity(ALICE_PHRASE, ALICE); + let bob = identity(BOB_PHRASE, BOB); + let bob_crypto = SeedCryptoProvider::from_seed(seed(BOB_PHRASE), Network::Testnet); + let address = [0x55; 43]; + let (entry, _) = alice_to_bob(address).await; + let persistence = RecordingPersistence::attesting( + PersistenceCapabilities::PRIVATE_CONTACT_REQUEST_SCAN_STATE, + ); + let (_manager, bob_wallet) = wallet_on( + Arc::clone(&persistence), + BOB_PHRASE, + &bob, + &[&alice, &bob], + vec![entry], + ) + .await; + bob_wallet + .identity() + .dashpay() + .sync_private_contact_requests(&bob_crypto, 4) + .await + .expect("Bob reads the store"); + + let contacts = persistence.contacts(); + let index_of = |found: &dyn Fn(&ContactChangeSet) -> bool| { + contacts + .iter() + .position(found) + .expect("the changeset is stored") + }; + let request = index_of(&|changeset| !changeset.incoming_requests.is_empty()); + let addresses = index_of(&|changeset| { + changeset + .private_shielded_addresses + .get(&(bob.id(), alice.id())) + == Some(&address) + }); + let position = index_of(&|changeset| { + changeset.private_contact_requests_positions.get(&bob.id()) == Some(&1) + }); + assert!( + request < addresses && addresses < position, + "request {request}, addresses {addresses}, position {position}" + ); + } + + /// A wallet loaded with a stored read position and shielded address, as a + /// backend attesting them restores them, reads the store on from there: + /// no page before it is fetched, which the mock, answering none, would + /// fail. The contact's shielded address is kept. + #[tokio::test] + async fn should_resume_a_private_sync_from_a_restored_position() { + let alice = identity(ALICE_PHRASE, ALICE); + let bob = identity(BOB_PHRASE, BOB); + let bob_crypto = SeedCryptoProvider::from_seed(seed(BOB_PHRASE), Network::Testnet); + let address = [0x55; 43]; + let (entry, received) = alice_to_bob(address).await; + let persistence = RecordingPersistence::attesting( + PersistenceCapabilities::PRIVATE_CONTACT_REQUEST_SCAN_STATE, + ); + let (_manager, restored) = + wallet_on(persistence, BOB_PHRASE, &bob, &[&alice, &bob], vec![entry]).await; + { + let iw = restored.identity(); + let mut wm = iw.wallet_manager.write().await; + let managed = wm + .get_wallet_info_mut(&restored.wallet_id()) + .expect("wallet info") + .identity_manager + .managed_identity_mut(&bob.id()) + .expect("Bob"); + managed.apply_incoming_contact_request(received.clone()); + managed.apply_private_contact_requests_position(1); + managed.apply_private_shielded_address(alice.id(), address); + } + let contract = dashpay_contract().expect("bundled DashPay"); + { + let mut sdk = (*restored.identity().sdk).clone(); + sdk.mock() + .remove_fetch_expectation::( + PrivateDocumentEntriesQuery::new( + contract.id(), + PRIVATE_CONTACT_REQUEST_DOCUMENT_TYPE_NAME, + 0, + ), + ) + .await; + } + publish(&restored, 1, vec![]).await; + + let report = restored + .identity() + .dashpay() + .sync_private_contact_requests(&bob_crypto, 4) + .await + .expect("the store reads on from position 1"); + assert_eq!(report.pages_read, 1); + assert!(report.complete); + assert_eq!(report.next_position, 1); + managed(&restored, BOB, |managed| { + assert_eq!( + managed + .dashpay() + .private_contact(&alice.id()) + .expect("a private contact") + .shielded_address, + Some(address) + ); + assert_eq!( + managed.dashpay().incoming_request(&alice.id()), + Some(&received) + ); + }) + .await; + } + + /// Un-ignoring a sender rewinds the private read position, and a backend + /// attesting the scan state stores the rewind. + #[tokio::test] + async fn should_store_a_rewound_position_when_a_sender_is_unignored() { + let alice = identity(ALICE_PHRASE, ALICE); + let bob = identity(BOB_PHRASE, BOB); + let persistence = RecordingPersistence::attesting( + PersistenceCapabilities::PRIVATE_CONTACT_REQUEST_SCAN_STATE, + ); + let (_manager, bob_wallet) = wallet_on( + Arc::clone(&persistence), + BOB_PHRASE, + &bob, + &[&alice, &bob], + vec![], + ) + .await; + let view = bob_wallet.identity().dashpay(); + { + let iw = bob_wallet.identity(); + let mut wm = iw.wallet_manager.write().await; + wm.get_wallet_info_mut(&bob_wallet.wallet_id()) + .expect("wallet info") + .identity_manager + .managed_identity_mut(&bob.id()) + .expect("Bob") + .apply_private_contact_requests_position(7); + } + view.ignore_contact_sender(&bob.id(), &alice.id()) + .await + .expect("ignored"); + view.unignore_contact_sender(&bob.id(), &alice.id()) + .await + .expect("un-ignored"); + let unignore = persistence + .contacts() + .into_iter() + .find(|changeset| changeset.unignored.contains(&(bob.id(), alice.id()))) + .expect("the un-ignore is stored"); + assert_eq!( + unignore.private_contact_requests_positions, + BTreeMap::from([(bob.id(), 0)]) + ); + } + + /// A backend that does not attest the scan state gets none of it, and a + /// load drops whatever it restored: the store is read from its start. + #[tokio::test] + async fn should_read_the_store_from_its_start_for_a_backend_without_the_capability() { + let alice = identity(ALICE_PHRASE, ALICE); + let bob = identity(BOB_PHRASE, BOB); + let bob_crypto = SeedCryptoProvider::from_seed(seed(BOB_PHRASE), Network::Testnet); + let (entry, _) = alice_to_bob([0x55; 43]).await; + let persistence = RecordingPersistence::attesting(PersistenceCapabilities::NONE); + let (_manager, bob_wallet) = wallet_on( + Arc::clone(&persistence), + BOB_PHRASE, + &bob, + &[&alice, &bob], + vec![entry], + ) + .await; + let view = bob_wallet.identity().dashpay(); + view.sync_private_contact_requests(&bob_crypto, 4) + .await + .expect("Bob reads the store"); + view.ignore_contact_sender(&bob.id(), &alice.id()) + .await + .expect("ignored"); + view.unignore_contact_sender(&bob.id(), &alice.id()) + .await + .expect("un-ignored"); + assert!(persistence.contacts().iter().all(|changeset| { + changeset.private_contact_requests_positions.is_empty() + && changeset.private_shielded_addresses.is_empty() + && changeset.removed_private_shielded_addresses.is_empty() + })); + + let mut loaded = ManagedIdentity::new(bob.clone(), 0); + loaded.apply_private_contact_requests_position(5); + loaded.apply_private_shielded_address(alice.id(), [0x55; 43]); + let mut start_state = IdentityManagerStartState::default(); + start_state + .wallet_identities + .entry(bob_wallet.wallet_id()) + .or_default() + .insert(0, loaded); + start_state.forget_private_contact_scan_state(); + let forgotten = &start_state.wallet_identities[&bob_wallet.wallet_id()][&0]; + assert_eq!(forgotten.dashpay().private_contact_requests_position(), 0); + assert_eq!( + forgotten.dashpay().private_shielded_address(&alice.id()), + None + ); + } + /// A `pay` that must not run. fn no_payment( _: BinaryData, diff --git a/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/contact_requests.rs b/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/contact_requests.rs index 7caefda41ce..d528ca16df0 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/contact_requests.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/contact_requests.rs @@ -820,6 +820,39 @@ impl ManagedIdentity { } } + /// Restore the position of DashPay's private contact request store this + /// identity reads from next, as a backend persisted it + /// (`ContactChangeSet::private_contact_requests_positions`): the loader's + /// replay, for a backend attesting + /// [`PRIVATE_CONTACT_REQUEST_SCAN_STATE`](crate::changeset::PersistenceCapabilities::PRIVATE_CONTACT_REQUEST_SCAN_STATE). + pub fn apply_private_contact_requests_position(&mut self, position: u64) { + self.dashpay.private_contact_requests_position = position; + } + + /// Restore the shielded address `contact`'s tracked private request to + /// this identity carries, as a backend persisted it + /// (`ContactChangeSet::private_shielded_addresses`): the loader's replay. + pub fn apply_private_shielded_address(&mut self, contact: Identifier, address: [u8; 43]) { + self.dashpay + .private_shielded_addresses + .insert(contact, address); + } + + /// Replay a persisted removal of `contact`'s shielded address + /// (`ContactChangeSet::removed_private_shielded_addresses`). + pub fn apply_removed_private_shielded_address(&mut self, contact: &Identifier) { + self.dashpay.private_shielded_addresses.remove(contact); + } + + /// Drop the private contact request scan state a load restored, for a + /// backend that does not attest it persists it: the store is read from + /// its start, and the addresses come back with the requests that carry + /// them. + pub(crate) fn forget_private_contact_scan_state(&mut self) { + self.dashpay.private_contact_requests_position = 0; + self.dashpay.private_shielded_addresses.clear(); + } + /// Record the shielded address `request`, a private request from /// `request.sender_id`, carries, when it is the request tracked from that /// contact: an older request ingest passed over does not replace the diff --git a/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/dashpay.rs b/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/dashpay.rs index f052125f022..27c17765e85 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/dashpay.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/dashpay.rs @@ -106,10 +106,12 @@ pub struct DashPayState { /// The position of DashPay's private contact request store (protocol /// version 15) to scan from next for this identity: every entry before it - /// was read and what it held for this identity ingested. In memory only, - /// like the high-water marks: a cold start scans the store again from 0, - /// and ingest is a fixpoint. [`ManagedIdentity::unignore_sender`] rewinds - /// it, as it does the received cursor. + /// was read and what it held for this identity ingested. Persisted, after + /// what the entries before it held, for a backend attesting + /// `PRIVATE_CONTACT_REQUEST_SCAN_STATE`, so a relaunch reads on from it; + /// otherwise a cold start scans the store again from 0, and ingest is a + /// fixpoint. [`ManagedIdentity::unignore_sender`] rewinds it, as it does + /// the received cursor. /// /// [`ManagedIdentity::unignore_sender`]: super::ManagedIdentity::unignore_sender pub(super) private_contact_requests_position: u64, @@ -123,9 +125,10 @@ pub struct DashPayState { pub(super) private_contact_requests_read: bool, /// The shielded address each contact's tracked private request to this - /// identity carries. In memory only, rebuilt by the scan a cold start - /// runs. Whether a request came privately is the request's own - /// ([`ContactRequest::came_privately`]), so it holds across restarts. + /// identity carries. Persisted for a backend attesting + /// `PRIVATE_CONTACT_REQUEST_SCAN_STATE`; otherwise rebuilt by the scan a + /// cold start runs. Whether a request came privately is the request's + /// own ([`ContactRequest::came_privately`]), so it holds across restarts. pub(super) private_shielded_addresses: BTreeMap, /// The private requests to each recipient that are being paid for, or @@ -313,6 +316,13 @@ impl DashPayState { self.pending_private_replies.contains_key(contact) } + /// The shielded address kept for `contact`, whichever way the tracked + /// requests went; [`Self::private_contact`] gives it only while the + /// contact's request to us came privately. + pub(crate) fn private_shielded_address(&self, contact: &Identifier) -> Option<[u8; 43]> { + self.private_shielded_addresses.get(contact).copied() + } + /// Whether a private sync read DashPay's private contact request store /// to its end for this identity since load; see the field doc. pub fn private_contact_requests_read(&self) -> bool { diff --git a/packages/rs-platform-wallet/src/wallet/persister.rs b/packages/rs-platform-wallet/src/wallet/persister.rs index a61e9082e17..02fa67c4859 100644 --- a/packages/rs-platform-wallet/src/wallet/persister.rs +++ b/packages/rs-platform-wallet/src/wallet/persister.rs @@ -136,6 +136,14 @@ impl WalletPersister { self.inner.persistence_capabilities() } + /// Whether the backend stores and restores the private contact request + /// sync's scan state + /// ([`PersistenceCapabilities::PRIVATE_CONTACT_REQUEST_SCAN_STATE`]). + pub(crate) fn persists_private_contact_scan_state(&self) -> bool { + self.persistence_capabilities() + .contains(PersistenceCapabilities::PRIVATE_CONTACT_REQUEST_SCAN_STATE) + } + pub(crate) fn load(&self) -> Result { self.inner.load() } From d5db080eb4bc02e9609e8a952029c9c694df841f Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 9 Nov 2026 05:21:25 +0700 Subject: [PATCH 033/117] fix(platform-wallet): claim a contact pair for a public request as a private one reserves it The public send's privacy check was a snapshot. A public send could pass it, suspend during the recipient lookup, xpub derivation or signing, and then publish the pair after a private send reserved the recipient and paid. A public send now claims the pair (sender, recipient) in the same wallet lock as its channel check, and holds the claim through submission and the local record; a guard releases it when the send ends, dropped or not. A private reservation and a public claim exclude each other: a private send while the pair is claimed gets `PrivateContactRequestPending`, and a public send while it is reserved gets the existing refusals. Public sends still run concurrently with each other (claims count). The accept paths send through these, so they take part too. No lock is held across network work. Co-Authored-By: Claude Opus 5.5 --- .../identity/network/contact_requests.rs | 148 ++++++-- .../network/private_contact_requests.rs | 334 ++++++++++++++++-- .../managed_identity/contact_requests.rs | 31 ++ .../state/managed_identity/dashpay.rs | 12 + 4 files changed, 478 insertions(+), 47 deletions(-) diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs b/packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs index 484f388d89c..4d6906b1d05 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs @@ -22,7 +22,12 @@ use crate::wallet::identity::types::dashpay::contact_request::ContactRequest; use crate::wallet::identity::types::dashpay::established_contact::EstablishedContact; use crate::wallet::identity::ManagedIdentity; use crate::wallet::persister::WalletPersister; -use crate::wallet::platform_wallet::PlatformWalletInfo; +use crate::wallet::platform_wallet::{PlatformWalletInfo, WalletId}; +use key_wallet_manager::WalletManager; +use std::sync::Arc; +use tokio::sync::RwLock; + +use super::private_contact_requests::apply_detached; use dash_sdk::platform::dashpay::PrivateContactRequest; use dash_sdk::platform::private_document_entries::PrivateDocumentEntries; use dash_sdk::platform::private_document_scan::PrivateDocumentScan; @@ -559,6 +564,57 @@ pub enum AutoAcceptProofSource { }, } +/// The claim a public request holds on its pair from before its channel is +/// decided until it is submitted and recorded +/// (`DashPayView::claim_public_pair`): while it is held no private request to +/// the recipient is reserved. Released by [`Self::release`] or on drop. +pub(super) struct PublicContactClaim { + wallet_manager: Arc>>, + wallet_id: WalletId, + sender: Identifier, + recipient: Identifier, + held: bool, +} + +impl PublicContactClaim { + /// Release the claim now. Dropped while it waits for the wallet lock, + /// the guard releases it. + pub(super) async fn release(mut self) { + let mut wm = self.wallet_manager.write().await; + release_public_claim(&mut wm, &self.wallet_id, &self.sender, &self.recipient); + self.held = false; + } +} + +impl Drop for PublicContactClaim { + fn drop(&mut self) { + if !self.held { + return; + } + let (wallet_id, sender, recipient) = (self.wallet_id, self.sender, self.recipient); + apply_detached( + &self.wallet_manager, + "a dropped public contact request's claim", + move |wm| release_public_claim(wm, &wallet_id, &sender, &recipient), + ); + } +} + +/// Release one claim `sender` holds on its pair with `recipient`. +fn release_public_claim( + wm: &mut WalletManager, + wallet_id: &WalletId, + sender: &Identifier, + recipient: &Identifier, +) { + if let Some(managed) = wm + .get_wallet_info_mut(wallet_id) + .and_then(|info| info.identity_manager.managed_identity_mut(sender)) + { + managed.release_public_contact_request(recipient); + } +} + /// What a contact request to send is built from, whether it is published as a /// `contactRequest` document or sealed privately: see /// `DashPayView::prepare_contact_request`. @@ -643,35 +699,81 @@ impl DashPayView<'_, B> { S: Signer + Send + Sync, C: ContactCryptoProvider + Sync, { - // A contact whose requests came or went privately is kept off chain: a - // `contactRequest` document would publish the relationship. Replies - // and rotations go privately instead. A private request still pending - // counts too, as it may already be on chain. This reads what the - // wallet tracks, so on a restored or second device it holds only once - // the private sync has read the store. - let (private, private_pending) = { - let wm = self.wallet_manager.read().await; - wm.get_wallet_info(&self.wallet_id) - .and_then(|info| info.identity_manager.managed_identity(sender_identity_id)) - .map_or((false, false), |managed| { - let dashpay = managed.dashpay(); - ( - dashpay.private_contact(recipient_identity_id).is_some(), - dashpay.private_reply_pending(recipient_identity_id), - ) - }) - }; - if private_pending { + // The pair is claimed before its channel is decided and held until + // the request is submitted and recorded, so a private request cannot + // be reserved and paid for in between; the claim is released when + // the send ends, however it ends. + let claim = self + .claim_public_pair(sender_identity_id, recipient_identity_id) + .await?; + let sent = self + .send_claimed_contact_request( + sender_identity_id, + recipient_identity_id, + account_label, + auto_accept_proof, + signer, + crypto, + ) + .await; + claim.release().await; + sent + } + + /// Claim the pair `sender`, `recipient` for a public request: refused, + /// in the same lock, while a private request to the recipient is + /// reserved, or once their requests came or went privately, as a + /// `contactRequest` document would publish the relationship (replies and + /// rotations go privately instead). The refusal reads what the wallet + /// tracks, so on a restored or second device it holds only once the + /// private sync has read the store. + pub(super) async fn claim_public_pair( + &self, + sender: &Identifier, + recipient: &Identifier, + ) -> Result { + let mut wm = self.wallet_manager.write().await; + let managed = wm + .get_wallet_info_mut(&self.wallet_id) + .ok_or_else(|| PlatformWalletError::WalletNotFound(hex::encode(self.wallet_id)))? + .identity_manager + .managed_identity_mut(sender) + .ok_or(PlatformWalletError::IdentityNotFound(*sender))?; + if managed.dashpay().private_reply_pending(recipient) { return Err(PlatformWalletError::PrivateContactRequestPending( - *recipient_identity_id, + *recipient, )); } - if private { + if managed.dashpay().private_contact(recipient).is_some() { return Err(PlatformWalletError::PrivateContactRequestNeedsPrivateReply( - *recipient_identity_id, + *recipient, )); } + managed.claim_public_contact_request(*recipient); + Ok(PublicContactClaim { + wallet_manager: Arc::clone(&self.wallet_manager), + wallet_id: self.wallet_id, + sender: *sender, + recipient: *recipient, + held: true, + }) + } + /// The body of [`Self::send_contact_request_with_external_signer`], once + /// the pair is claimed. + async fn send_claimed_contact_request( + &self, + sender_identity_id: &Identifier, + recipient_identity_id: &Identifier, + account_label: Option, + auto_accept_proof: AutoAcceptProofSource, + signer: &S, + crypto: &C, + ) -> Result + where + S: Signer + Send + Sync, + C: ContactCryptoProvider + Sync, + { // 1-4c. The keys, the receiving xpub, the masked account reference and // the auto-accept proof, as a private request is built from them too. let PreparedContactRequest { diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/private_contact_requests.rs b/packages/rs-platform-wallet/src/wallet/identity/network/private_contact_requests.rs index a7d063bfb9e..d57a254a698 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/private_contact_requests.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/private_contact_requests.rs @@ -217,35 +217,43 @@ impl Drop for PrivateReplyReservation { if !self.armed { return; } - if let Ok(mut wm) = self.wallet_manager.try_write() { - release_reservation( - &mut wm, - &self.wallet_id, - &self.sender, - &self.recipient, - self.attempt, - ); - return; - } - let Ok(runtime) = tokio::runtime::Handle::try_current() else { - tracing::warn!( - sender = %self.sender, - recipient = %self.recipient, - "a cancelled private contact request could not release its reservation; it stays \ - until restart" - ); - return; - }; - let wallet_manager = Arc::clone(&self.wallet_manager); let (wallet_id, sender, recipient, attempt) = (self.wallet_id, self.sender, self.recipient, self.attempt); - runtime.spawn(async move { - let mut wm = wallet_manager.write().await; - release_reservation(&mut wm, &wallet_id, &sender, &recipient, attempt); - }); + apply_detached( + &self.wallet_manager, + "a cancelled private contact request's reservation", + move |wm| release_reservation(wm, &wallet_id, &sender, &recipient, attempt), + ); } } +/// Run `apply` on the wallet manager, for a guard released on drop, which +/// cannot wait for the lock: now when the lock is free, otherwise on a +/// spawned task. Outside a runtime it is logged, and `what` stays until +/// restart. +pub(super) fn apply_detached( + wallet_manager: &Arc>>, + what: &'static str, + apply: impl FnOnce(&mut WalletManager) + Send + 'static, +) { + if let Ok(mut wm) = wallet_manager.try_write() { + apply(&mut wm); + return; + } + let Ok(runtime) = tokio::runtime::Handle::try_current() else { + tracing::warn!( + what, + "released on drop outside a runtime; it stays until restart" + ); + return; + }; + let wallet_manager = Arc::clone(wallet_manager); + runtime.spawn(async move { + let mut wm = wallet_manager.write().await; + apply(&mut wm); + }); +} + /// Release the reservation `attempt` holds on `sender`'s private request to /// `recipient`. fn release_reservation( @@ -3500,6 +3508,284 @@ mod tests { ); } + /// A hook a [`HookedCrypto`] runs before one of its calls. + type Hook = Arc std::pin::Pin + Send>> + Send + Sync>; + + /// A provider deriving from a test seed that runs hooks first: before + /// deriving a receiving xpub, as a slow or interactive signer pauses a + /// send's preparation, and before scanning pages. + struct HookedCrypto { + inner: SeedCryptoProvider, + before_receiving_xpub: Option, + before_scan: Option, + } + + impl HookedCrypto { + fn new(phrase: &str) -> Self { + Self { + inner: SeedCryptoProvider::from_seed(seed(phrase), Network::Testnet), + before_receiving_xpub: None, + before_scan: None, + } + } + } + + #[async_trait::async_trait] + impl ContactCryptoProvider for HookedCrypto { + async fn receiving_xpub( + &self, + path: &DerivationPath, + ) -> Result { + if let Some(hook) = &self.before_receiving_xpub { + hook().await; + } + self.inner.receiving_xpub(path).await + } + + async fn ecdh_shared_secret( + &self, + path: &DerivationPath, + peer: &PublicKey, + ) -> Result, PlatformWalletError> { + self.inner.ecdh_shared_secret(path, peer).await + } + + async fn export_auto_accept_private_key( + &self, + path: &DerivationPath, + ) -> Result { + self.inner.export_auto_accept_private_key(path).await + } + + async fn account_reference( + &self, + path: &DerivationPath, + compact_xpub: &[u8], + account_index: u32, + version: u32, + ) -> Result { + self.inner + .account_reference(path, compact_xpub, account_index, version) + .await + } + + async fn unmask_account_reference( + &self, + path: &DerivationPath, + compact_xpub: &[u8], + account_reference: u32, + ) -> Result<(u32, u32), PlatformWalletError> { + self.inner + .unmask_account_reference(path, compact_xpub, account_reference) + .await + } + + async fn contact_info_seal( + &self, + root_path: &DerivationPath, + derivation_index: u32, + contact_id: &[u8; 32], + private_data_plaintext: &[u8], + private_data_iv: &[u8; 16], + ) -> Result + { + self.inner + .contact_info_seal( + root_path, + derivation_index, + contact_id, + private_data_plaintext, + private_data_iv, + ) + .await + } + + async fn contact_info_open( + &self, + root_path: &DerivationPath, + derivation_index: u32, + enc_to_user_id: &[u8; 32], + private_data_blob: &[u8], + ) -> Result + { + self.inner + .contact_info_open( + root_path, + derivation_index, + enc_to_user_id, + private_data_blob, + ) + .await + } + + async fn scan_private_document_pages( + &self, + keys: &PrivateDocumentKeyPaths, + data_contract_id: Identifier, + document_type_name: &str, + pages: &[(u64, &PrivateDocumentEntries)], + ) -> Result< + Vec, + PlatformWalletError, + > { + if let Some(hook) = &self.before_scan { + hook().await; + } + self.inner + .scan_private_document_pages(keys, data_contract_id, document_type_name, pages) + .await + } + + #[allow(clippy::too_many_arguments)] + async fn seal_private_contact_request( + &self, + sender_encryption_key_path: &DerivationPath, + dashpay_contract: &DataContract, + sender: &Identity, + sender_encryption_key_id: KeyID, + signing_key_id: KeyID, + signature: &[u8; 65], + recipient: &Identity, + recipient_key_id: KeyID, + request: &PrivateContactRequest, + esk_seed: &[u8; 32], + platform_version: &PlatformVersion, + ) -> Result { + self.inner + .seal_private_contact_request( + sender_encryption_key_path, + dashpay_contract, + sender, + sender_encryption_key_id, + signing_key_id, + signature, + recipient, + recipient_key_id, + request, + esk_seed, + platform_version, + ) + .await + } + } + + /// A hook that, the first time it runs, says so on `reached` and waits + /// for `release`. + fn pause_once(reached: Arc, release: Arc) -> Hook { + let first = Arc::new(AtomicBool::new(true)); + Arc::new(move || { + let (reached, release, first) = ( + Arc::clone(&reached), + Arc::clone(&release), + Arc::clone(&first), + ); + Box::pin(async move { + if first.swap(false, Ordering::SeqCst) { + reached.notify_one(); + release.notified().await; + } + }) + }) + } + + /// A public request that passed its channel check and is still being + /// prepared holds the pair: a private request started meanwhile is + /// refused before anything is paid, and only the public one goes on. + /// Once it ends, the pair is free again. + #[tokio::test] + async fn should_let_only_one_channel_send_to_a_pair_at_a_time() { + let alice = identity(ALICE_PHRASE, ALICE); + let bob = identity(BOB_PHRASE, BOB); + let alice_crypto = SeedCryptoProvider::from_seed(seed(ALICE_PHRASE), Network::Testnet); + let alice_signer = signer(ALICE_PHRASE, &alice); + let (_manager, alice_wallet) = wallet(ALICE_PHRASE, &alice, &[&alice, &bob], vec![]).await; + let view = alice_wallet.identity().dashpay(); + let reached = Arc::new(tokio::sync::Notify::new()); + let release = Arc::new(tokio::sync::Notify::new()); + let mut pausing = HookedCrypto::new(ALICE_PHRASE); + pausing.before_receiving_xpub = + Some(pause_once(Arc::clone(&reached), Arc::clone(&release))); + let paid = Arc::new(Mutex::new(Vec::new())); + + let (alice_id, bob_id) = (alice.id(), bob.id()); + let public = view.send_contact_request_with_external_signer( + &alice_id, + &bob_id, + None, + AutoAcceptProofSource::None, + &alice_signer, + &pausing, + ); + let private = async { + reached.notified().await; + let claimed = managed(&alice_wallet, ALICE, |managed| { + managed.dashpay().public_contact_request_claimed(&bob.id()) + }) + .await; + let sent = view + .send_private_contact_request( + &alice.id(), + &bob.id(), + None, + AutoAcceptProofSource::None, + None, + &alice_signer, + &alice_crypto, + keep_entry(&paid), + ) + .await; + release.notify_one(); + (claimed, sent) + }; + let (public, (claimed, private)) = tokio::join!(public, private); + assert!(claimed, "the paused public send holds the pair"); + assert!( + matches!(&private, Err(PlatformWalletError::PrivateContactRequestPending(recipient)) + if *recipient == bob.id()), + "{private:?}" + ); + assert!(paid.lock().expect("paid").is_empty(), "nothing is paid"); + assert!( + !matches!( + &public, + Err(PlatformWalletError::PrivateContactRequestPending(_) + | PlatformWalletError::PrivateContactRequestNeedsPrivateReply(_)) + ), + "the public send goes on to its submission, which this mock refuses: {public:?}" + ); + + assert!(!pending_to(&alice_wallet, ALICE, &bob).await); + managed(&alice_wallet, ALICE, |managed| { + assert!(!managed.dashpay().public_contact_request_claimed(&bob.id())); + }) + .await; + let kept = Arc::new(Mutex::new(Vec::new())); + let after = view + .send_private_contact_request( + &alice.id(), + &bob.id(), + None, + AutoAcceptProofSource::None, + None, + &alice_signer, + &alice_crypto, + keep_entry(&kept), + ) + .await; + assert!( + matches!( + after, + Err(PlatformWalletError::PrivateContactRequestPending(_)) + ), + "{after:?}" + ); + assert_eq!( + kept.lock().expect("kept").len(), + 1, + "the pair is free again" + ); + } + /// A `pay` that must not run. fn no_payment( _: BinaryData, diff --git a/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/contact_requests.rs b/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/contact_requests.rs index d528ca16df0..6277f443d82 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/contact_requests.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/contact_requests.rs @@ -891,16 +891,47 @@ impl ManagedIdentity { /// returning the attempt that holds it; `None` when one already is. See /// [`DashPayState::private_reply_pending`](super::DashPayState::private_reply_pending). pub(crate) fn reserve_private_reply(&mut self, recipient: Identifier) -> Option { + // A public request being sent to the recipient holds the pair too. if self .dashpay .pending_private_replies .contains_key(&recipient) + || self + .dashpay + .public_contact_request_claims + .contains_key(&recipient) { return None; } Some(self.insert_private_reply(recipient, None)) } + /// Claim the pair with `recipient` for a public request about to be + /// sent: until [`Self::release_public_contact_request`], no private + /// request to it is reserved. Claims count, so concurrent public sends + /// each hold one. + pub(crate) fn claim_public_contact_request(&mut self, recipient: Identifier) { + *self + .dashpay + .public_contact_request_claims + .entry(recipient) + .or_default() += 1; + } + + /// Release one claim [`Self::claim_public_contact_request`] took. + pub(crate) fn release_public_contact_request(&mut self, recipient: &Identifier) { + if let Some(claims) = self + .dashpay + .public_contact_request_claims + .get_mut(recipient) + { + *claims = claims.saturating_sub(1); + if *claims == 0 { + self.dashpay.public_contact_request_claims.remove(recipient); + } + } + } + /// Reserve `recipient` under a new attempt, `commitment` its entry's. fn insert_private_reply(&mut self, recipient: Identifier, commitment: Option<[u8; 32]>) -> u64 { let attempt = NEXT_PRIVATE_REPLY_ATTEMPT.fetch_add(1, Ordering::Relaxed); diff --git a/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/dashpay.rs b/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/dashpay.rs index 27c17765e85..4f550659279 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/dashpay.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/dashpay.rs @@ -141,6 +141,12 @@ pub struct DashPayState { /// restored from the shielded store's unconfirmed spends. pub(super) pending_private_replies: BTreeMap, + /// The public requests to each recipient being sent, counted: each claims + /// the pair from before its channel is decided until it is submitted and + /// recorded, and while one does no private request to the recipient is + /// reserved, as a reserved one refuses a public send. In memory only. + pub(super) public_contact_request_claims: BTreeMap, + /// Lowest Platform-assigned `$createdAtCoreBlockHeight` among OUR sent /// `contactRequest` docs to each recipient seen by a sweep this process. /// @@ -323,6 +329,12 @@ impl DashPayState { self.private_shielded_addresses.get(contact).copied() } + /// Whether a public request to `contact` is being sent; see the field + /// doc. + pub fn public_contact_request_claimed(&self, contact: &Identifier) -> bool { + self.public_contact_request_claims.contains_key(contact) + } + /// Whether a private sync read DashPay's private contact request store /// to its end for this identity since load; see the field doc. pub fn private_contact_requests_read(&self) -> bool { From 3698f6afd0a7730a73c68258ba26dc218e39ba4b Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 9 Nov 2026 05:28:56 +0700 Subject: [PATCH 034/117] fix(platform-wallet): read the private store again when an identity's scanning keys change Scan paths come from the cached managed identity, but the read position was not tied to them. An identity that gained a DashPay encryption or decryption key on another device was scanned with its older keys here, the entries sealed to or from the new key never opened, and the position moved past them; a later refresh folded the key in without rewinding the position or clearing the read-to-the-end flag. The position and the flag now hold for a fingerprint of the scanning keys: SHA-256 over the HD index and each ECDSA_SECP256K1 ENCRYPTION or DECRYPTION key (id, purpose and data). Before every pass and every read a private send or accept runs, `reconcile_private_scan_keys` rewinds both to 0 when the keys no longer match, and stores the rewind. A pass records the fingerprint it scans with, and advances or sets the flag only while that is still the identity's and the one its position holds for, so a pass with older keys commits nothing over an update made under it. The stored position becomes a `PrivateContactRequestsPosition { position, key_fingerprint }`, and a loaded one with another fingerprint is read again from 0. Co-Authored-By: Claude Opus 5.5 --- .../src/changeset/changeset.rs | 26 +- .../rs-platform-wallet/src/changeset/mod.rs | 6 +- .../identity/network/contact_requests.rs | 2 +- .../network/private_contact_requests.rs | 246 +++++++++++++++++- .../managed_identity/contact_requests.rs | 82 +++++- .../state/managed_identity/dashpay.rs | 8 + 6 files changed, 345 insertions(+), 25 deletions(-) diff --git a/packages/rs-platform-wallet/src/changeset/changeset.rs b/packages/rs-platform-wallet/src/changeset/changeset.rs index c58a6f60af7..39cbd6f3039 100644 --- a/packages/rs-platform-wallet/src/changeset/changeset.rs +++ b/packages/rs-platform-wallet/src/changeset/changeset.rs @@ -1333,16 +1333,17 @@ pub struct ContactChangeSet { /// snapshot) so the changeset's insert-XOR-tombstone discipline holds. pub unignored: BTreeSet<(Identifier, Identifier)>, /// The position of DashPay's private contact request store each owner - /// identity reads from next, keyed by owner: every entry before it was - /// read and what it held for the owner stored. Written only for a backend + /// identity reads from next, keyed by owner, with the fingerprint of the + /// scanning keys it holds for: every entry before it was read with those + /// keys and what it held for the owner stored. Written only for a backend /// attesting /// [`PRIVATE_CONTACT_REQUEST_SCAN_STATE`](crate::changeset::PersistenceCapabilities::PRIVATE_CONTACT_REQUEST_SCAN_STATE), /// and only after the stores of what the entries before it held, so a /// restored position never covers a request that did not reach disk. A - /// rewind (un-ignoring a sender) writes the lower position. Last write - /// wins. + /// rewind (un-ignoring a sender, or the scanning keys changing) writes + /// the lower position. Last write wins. #[cfg_attr(feature = "serde", serde(default))] - pub private_contact_requests_positions: BTreeMap, + pub private_contact_requests_positions: BTreeMap, /// The Orchard address each contact's tracked private request to the /// owner carries, keyed by `(owner, contact)`: 43 bytes, an 11-byte /// diversifier and a 32-byte transmission key. Written only for a @@ -1365,6 +1366,21 @@ pub struct ContactChangeSet { pub removed_private_shielded_addresses: BTreeSet<(Identifier, Identifier)>, } +/// Where an identity reads DashPay's private contact request store from +/// next, and the scanning keys that position holds for. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct PrivateContactRequestsPosition { + /// The store position read from next. + pub position: u64, + /// SHA-256 over the identity's HD index and each `ECDSA_SECP256K1` + /// `ENCRYPTION` or `DECRYPTION` key (id, purpose and data, in key id + /// order) the entries before `position` were scanned with + /// (`ManagedIdentity::private_scan_key_fingerprint`). A loaded position + /// whose fingerprint is not the identity's is read again from 0. + pub key_fingerprint: [u8; 32], +} + impl Merge for ContactChangeSet { fn merge(&mut self, other: Self) { // Insert-vs-tombstone pairs are reconciled last-write-wins per key: diff --git a/packages/rs-platform-wallet/src/changeset/mod.rs b/packages/rs-platform-wallet/src/changeset/mod.rs index f246d3f6056..d61e3eae156 100644 --- a/packages/rs-platform-wallet/src/changeset/mod.rs +++ b/packages/rs-platform-wallet/src/changeset/mod.rs @@ -40,9 +40,9 @@ pub use changeset::{ IdentityKeysChangeSet, InvitationChangeSet, InvitationEntry, InvitationStatus, KeyDerivationBreadcrumb, KeyWithBreadcrumb, PendingContactCrypto, PendingContactCryptoKey, PendingContactCryptoKind, PendingContactCryptoOp, PlatformAddressBalanceEntry, - PlatformAddressChangeSet, PlatformWalletChangeSet, ProviderKeyAccountEntry, - ProviderKeyExtendedPubKey, ProviderPlatformNodePubKey, ReceivedContactRequestKey, - SentContactRequestKey, TokenBalanceChangeSet, WalletMetadataEntry, + PlatformAddressChangeSet, PlatformWalletChangeSet, PrivateContactRequestsPosition, + ProviderKeyAccountEntry, ProviderKeyExtendedPubKey, ProviderPlatformNodePubKey, + ReceivedContactRequestKey, SentContactRequestKey, TokenBalanceChangeSet, WalletMetadataEntry, }; pub use client_start_state::ClientStartState; pub use client_wallet_start_state::ClientWalletStartState; diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs b/packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs index 4d6906b1d05..fb74a77b130 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs @@ -4575,7 +4575,7 @@ impl DashPayView<'_, B> { if self.persister.persists_private_contact_scan_state() { cs.private_contact_requests_positions.insert( *identity_id, - managed.dashpay().private_contact_requests_position(), + managed.private_contact_requests_position_record(), ); } self.persister.store(cs.into()).map_err(|e| { diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/private_contact_requests.rs b/packages/rs-platform-wallet/src/wallet/identity/network/private_contact_requests.rs index d57a254a698..a5f216b866c 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/private_contact_requests.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/private_contact_requests.rs @@ -68,7 +68,7 @@ use super::contact_requests::{ use super::signing_key::first_available; use super::{dashpay_contract, DashPayView, IdentityWallet}; use crate::broadcaster::TransactionBroadcaster; -use crate::changeset::ContactChangeSet; +use crate::changeset::{ContactChangeSet, PrivateContactRequestsPosition}; use crate::error::PlatformWalletError; use crate::wallet::identity::types::dashpay::contact_request::ContactRequest; use crate::wallet::identity::types::dashpay::established_contact::EstablishedContact; @@ -154,9 +154,14 @@ struct ScannedIdentity { position: u64, /// Its ingest failed this pass: it is not advanced again until the next. failed: bool, - /// Its cursor was rewound under the pass (unignoring a sender): it is not - /// advanced again this pass, so the next reads from the rewound position. + /// Its cursor was rewound under the pass (unignoring a sender, or its + /// scanning keys changing): it is not advanced again this pass, so the + /// next reads from the rewound position. rewound: bool, + /// The fingerprint of the keys the pass scans with for it + /// (`ManagedIdentity::private_scan_key_fingerprint`): progress made + /// with them holds only while they are still the identity's. + key_fingerprint: [u8; 32], } impl ScannedIdentity { @@ -168,6 +173,11 @@ impl ScannedIdentity { if self.failed || self.rewound { return; } + // Keys added under the pass opened nothing it read. + if !self.keys_current(managed) { + self.rewound = true; + return; + } if managed.dashpay().private_contact_requests_position() != self.position { self.rewound = true; return; @@ -175,6 +185,16 @@ impl ScannedIdentity { managed.advance_private_contact_requests_position(self.position, next_position); self.position = managed.dashpay().private_contact_requests_position(); } + + /// Whether the keys the pass scans with are still the identity's, and + /// the ones its read position holds for. + fn keys_current(&self, managed: &ManagedIdentity) -> bool { + managed.private_scan_key_fingerprint() == self.key_fingerprint + && managed + .private_contact_requests_position_record() + .key_fingerprint + == self.key_fingerprint + } } /// The reservation one send attempt holds on the private request to a @@ -299,14 +319,18 @@ fn shielded_address_changeset( /// Store `owner`'s private read position. A failed store leaves the cursor /// advanced in memory: the next launch reads from the last position stored, /// and ingest is a fixpoint. -fn store_private_position(persister: &WalletPersister, owner: Identifier, position: u64) { +fn store_private_position( + persister: &WalletPersister, + owner: Identifier, + position: PrivateContactRequestsPosition, +) { let mut changeset = ContactChangeSet::default(); changeset .private_contact_requests_positions .insert(owner, position); if let Err(error) = persister.store(changeset.into()) { tracing::warn!( - owner = %owner, position, %error, + owner = %owner, position = position.position, %error, "private read position not stored; the next launch reads from the last one stored" ); } @@ -495,6 +519,8 @@ impl DashPayView<'_, B> { where C: ContactCryptoProvider + Sync, { + // Keys added since the store was read clear the flag first. + self.reconcile_private_scan_keys().await?; if self.private_store_read(identity_id).await? { return Ok(()); } @@ -527,6 +553,36 @@ impl DashPayView<'_, B> { )) } + /// Rewind the private read position and the read flag of each of the + /// wallet's identities whose scanning keys changed since they were set + /// (`ManagedIdentity::reconcile_private_scan_keys`), storing the rewind + /// for a backend that keeps the position. A refresh that folds in a key + /// another device added is caught here, before the next scan or send. + pub(crate) async fn reconcile_private_scan_keys(&self) -> Result<(), PlatformWalletError> { + let persists_scan_state = self.persister.persists_private_contact_scan_state(); + let mut wm = self.wallet_manager.write().await; + let info = wm + .get_wallet_info_mut(&self.wallet_id) + .ok_or_else(|| PlatformWalletError::WalletNotFound(hex::encode(self.wallet_id)))?; + let identity_ids: Vec = info + .identity_manager + .all_identities() + .iter() + .map(|identity| identity.id()) + .collect(); + for identity_id in identity_ids { + let Some(managed) = info.identity_manager.managed_identity_mut(&identity_id) else { + continue; + }; + if let Some(position) = managed.reconcile_private_scan_keys() { + if persists_scan_state { + store_private_position(&self.persister, identity_id, position); + } + } + } + Ok(()) + } + /// Whether a private sync read DashPay's private contact request store /// to its end for `identity_id` since load. async fn private_store_read( @@ -896,6 +952,7 @@ impl DashPayView<'_, B> { position: managed.dashpay().private_contact_requests_position(), failed: false, rewound: false, + key_fingerprint: managed.private_scan_key_fingerprint(), }); } Ok((identities, paths)) @@ -922,6 +979,7 @@ impl DashPayView<'_, B> { where C: ContactCryptoProvider + Sync, { + self.reconcile_private_scan_keys().await?; let (mut identities, paths) = self.private_scan_identities().await?; let mut report = PrivateContactSyncReport::default(); let Some(start) = identities.iter().map(|identity| identity.position).min() else { @@ -1263,7 +1321,11 @@ impl DashPayView<'_, B> { identity.advance(managed, read.next_position); let after = managed.dashpay().private_contact_requests_position(); if persists_scan_state && after != before { - store_private_position(&self.persister, identity_id, after); + store_private_position( + &self.persister, + identity_id, + managed.private_contact_requests_position_record(), + ); } } else if !identity.failed { identity.failed = true; @@ -1271,7 +1333,8 @@ impl DashPayView<'_, B> { } // Read to the store's end with nothing held back this // pass: every entry was ingested for this identity. - if read.is_complete() && !identity.failed { + if read.is_complete() && !identity.failed && identity.keys_current(managed) + { managed.note_private_contact_requests_read(); } tear_down_stale_external_accounts(wallet, info, &identity_id, rotated); @@ -3334,7 +3397,11 @@ mod tests { == Some(&address) }); let position = index_of(&|changeset| { - changeset.private_contact_requests_positions.get(&bob.id()) == Some(&1) + changeset + .private_contact_requests_positions + .get(&bob.id()) + .map(|position| position.position) + == Some(1) }); assert!( request < addresses && addresses < position, @@ -3368,7 +3435,11 @@ mod tests { .managed_identity_mut(&bob.id()) .expect("Bob"); managed.apply_incoming_contact_request(received.clone()); - managed.apply_private_contact_requests_position(1); + let key_fingerprint = managed.private_scan_key_fingerprint(); + managed.apply_private_contact_requests_position(PrivateContactRequestsPosition { + position: 1, + key_fingerprint, + }); managed.apply_private_shielded_address(alice.id(), address); } let contract = dashpay_contract().expect("bundled DashPay"); @@ -3438,7 +3509,10 @@ mod tests { .identity_manager .managed_identity_mut(&bob.id()) .expect("Bob") - .apply_private_contact_requests_position(7); + .apply_private_contact_requests_position(PrivateContactRequestsPosition { + position: 7, + key_fingerprint: [0; 32], + }); } view.ignore_contact_sender(&bob.id(), &alice.id()) .await @@ -3452,8 +3526,12 @@ mod tests { .find(|changeset| changeset.unignored.contains(&(bob.id(), alice.id()))) .expect("the un-ignore is stored"); assert_eq!( - unignore.private_contact_requests_positions, - BTreeMap::from([(bob.id(), 0)]) + unignore + .private_contact_requests_positions + .iter() + .map(|(owner, position)| (*owner, position.position)) + .collect::>(), + vec![(bob.id(), 0)] ); } @@ -3491,7 +3569,11 @@ mod tests { })); let mut loaded = ManagedIdentity::new(bob.clone(), 0); - loaded.apply_private_contact_requests_position(5); + let key_fingerprint = loaded.private_scan_key_fingerprint(); + loaded.apply_private_contact_requests_position(PrivateContactRequestsPosition { + position: 5, + key_fingerprint, + }); loaded.apply_private_shielded_address(alice.id(), [0x55; 43]); let mut start_state = IdentityManagerStartState::default(); start_state @@ -3786,6 +3868,143 @@ mod tests { ); } + /// `identity` without its key `key_id`, as a device that has not yet + /// fetched a key another device added holds it. + fn without_key(identity: &Identity, key_id: KeyID) -> Identity { + Identity::V0(IdentityV0 { + id: identity.id(), + public_keys: identity + .public_keys() + .iter() + .filter(|(id, _)| **id != key_id) + .map(|(id, key)| (*id, key.clone())) + .collect(), + balance: identity.balance(), + revision: identity.revision(), + }) + } + + /// A store read with keys missing one an entry is sealed to is read again + /// once a refresh brings the key: the refresh folds it in, the read + /// position and the read-to-the-end flag are rewound, and the next sync + /// records the request. + #[tokio::test] + async fn should_read_the_store_again_once_an_identity_gains_a_scanning_key() { + let alice = identity(ALICE_PHRASE, ALICE); + let bob = identity(BOB_PHRASE, BOB); + let bob_crypto = SeedCryptoProvider::from_seed(seed(BOB_PHRASE), Network::Testnet); + let (entry, _) = alice_to_bob([0x55; 43]).await; + let stale = without_key(&bob, DECRYPTION_KEY_ID); + let (_manager, bob_wallet) = wallet(BOB_PHRASE, &stale, &[&alice, &bob], vec![entry]).await; + let view = bob_wallet.identity().dashpay(); + + let report = view + .sync_private_contact_requests(&bob_crypto, 4) + .await + .expect("Bob reads the store with his older keys"); + assert_eq!( + report.requests, + vec![], + "the entry opens with no key he holds" + ); + assert!(store_read(&bob_wallet, BOB).await); + managed(&bob_wallet, BOB, |managed| { + assert_eq!(managed.dashpay().private_contact_requests_position(), 1); + }) + .await; + + bob_wallet + .identity() + .refresh_identity(&bob.id()) + .await + .expect("the refresh folds in the decryption key"); + view.reconcile_private_scan_keys() + .await + .expect("reconciled"); + assert!( + !store_read(&bob_wallet, BOB).await, + "a send reads the store again" + ); + managed(&bob_wallet, BOB, |managed| { + assert_eq!(managed.dashpay().private_contact_requests_position(), 0); + }) + .await; + + let report = view + .sync_private_contact_requests(&bob_crypto, 4) + .await + .expect("Bob reads the store again"); + assert_eq!(report.requests.len(), 1, "{:?}", report.requests); + managed(&bob_wallet, BOB, |managed| { + assert!(managed.dashpay().incoming_request(&alice.id()).is_some()); + }) + .await; + } + + /// A pass that started with older keys commits no progress once a key + /// update lands under it, even when the rewind that update brings is + /// taken while the pass runs: the next pass reads with the new keys from + /// where the update left the position. + #[tokio::test] + async fn should_not_advance_over_a_key_update_during_a_pass() { + let alice = identity(ALICE_PHRASE, ALICE); + let bob = identity(BOB_PHRASE, BOB); + let bob_crypto = SeedCryptoProvider::from_seed(seed(BOB_PHRASE), Network::Testnet); + let (entry, _) = alice_to_bob([0x55; 43]).await; + let stale = without_key(&bob, DECRYPTION_KEY_ID); + let (_manager, bob_wallet) = wallet(BOB_PHRASE, &stale, &[&alice, &bob], vec![entry]).await; + + let mut updating = HookedCrypto::new(BOB_PHRASE); + updating.before_scan = Some({ + let bob_wallet = Arc::clone(&bob_wallet); + let bob = bob.clone(); + Arc::new(move || { + let (bob_wallet, bob) = (Arc::clone(&bob_wallet), bob.clone()); + Box::pin(async move { + let iw = bob_wallet.identity(); + let mut wm = iw.wallet_manager.write().await; + let managed = wm + .get_wallet_info_mut(&bob_wallet.wallet_id()) + .expect("wallet info") + .identity_manager + .managed_identity_mut(&bob.id()) + .expect("Bob"); + // A refresh's key, and a read before a send rewinding for it. + managed.identity = bob; + managed.reconcile_private_scan_keys(); + }) + }) + }); + bob_wallet + .identity() + .dashpay() + .sync_private_contact_requests(&updating, 4) + .await + .expect("the pass with older keys"); + managed(&bob_wallet, BOB, |managed| { + assert_eq!( + managed.dashpay().private_contact_requests_position(), + 0, + "no progress over the update" + ); + assert!(!managed.dashpay().private_contact_requests_read()); + assert!(managed.dashpay().incoming_request(&alice.id()).is_none()); + }) + .await; + + bob_wallet + .identity() + .dashpay() + .sync_private_contact_requests(&bob_crypto, 4) + .await + .expect("the pass with the new keys"); + managed(&bob_wallet, BOB, |managed| { + assert!(managed.dashpay().incoming_request(&alice.id()).is_some()); + assert_eq!(managed.dashpay().private_contact_requests_position(), 1); + }) + .await; + } + /// A `pay` that must not run. fn no_payment( _: BinaryData, @@ -4173,6 +4392,7 @@ mod tests { .managed_identity_mut(&alice.id()) .expect("Alice"); let mut scanned = ScannedIdentity { + key_fingerprint: managed.private_scan_key_fingerprint(), identity_id: alice.id(), identity_index: 0, position: 0, diff --git a/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/contact_requests.rs b/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/contact_requests.rs index 6277f443d82..acc779988b8 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/contact_requests.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/contact_requests.rs @@ -8,13 +8,18 @@ use super::dashpay::PendingPrivateReply; use super::ManagedIdentity; use crate::changeset::{ - ContactChangeSet, ContactRequestEntry, ReceivedContactRequestKey, SentContactRequestKey, + ContactChangeSet, ContactRequestEntry, PrivateContactRequestsPosition, + ReceivedContactRequestKey, SentContactRequestKey, }; use crate::wallet::identity::crypto::contact_info::ContactInfoPrivateData; use crate::wallet::persister::WalletPersister; use crate::{ContactRequest, EstablishedContact}; +use dpp::identity::accessors::IdentityGettersV0; +use dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; +use dpp::identity::{KeyType, Purpose}; use dpp::prelude::Identifier; use platform_encryption::account_reference_version; +use sha2::{Digest, Sha256}; use std::sync::atomic::{AtomicU64, Ordering}; /// The attempt id the next private request reservation takes. Process-wide, @@ -825,8 +830,78 @@ impl ManagedIdentity { /// (`ContactChangeSet::private_contact_requests_positions`): the loader's /// replay, for a backend attesting /// [`PRIVATE_CONTACT_REQUEST_SCAN_STATE`](crate::changeset::PersistenceCapabilities::PRIVATE_CONTACT_REQUEST_SCAN_STATE). - pub fn apply_private_contact_requests_position(&mut self, position: u64) { - self.dashpay.private_contact_requests_position = position; + pub fn apply_private_contact_requests_position( + &mut self, + position: PrivateContactRequestsPosition, + ) { + self.dashpay.private_contact_requests_position = position.position; + self.dashpay.private_scan_keys = Some(position.key_fingerprint); + } + + /// The fingerprint of the keys a private sync scans DashPay's private + /// contact request store with for this identity: SHA-256 over its HD + /// index and each `ECDSA_SECP256K1` `ENCRYPTION` or `DECRYPTION` key, by + /// id, purpose and data, in key id order. Disabled keys count, as an + /// entry sealed before a key was disabled still opens with it. + pub fn private_scan_key_fingerprint(&self) -> [u8; 32] { + let mut hasher = Sha256::new(); + match self.identity_index { + Some(index) => { + hasher.update([1]); + hasher.update(index.to_le_bytes()); + } + None => hasher.update([0]), + } + for (key_id, key) in self.identity.public_keys() { + if key.key_type() != KeyType::ECDSA_SECP256K1 + || !matches!(key.purpose(), Purpose::ENCRYPTION | Purpose::DECRYPTION) + { + continue; + } + let data = key.data().as_slice(); + hasher.update(key_id.to_le_bytes()); + hasher.update([key.purpose() as u8]); + hasher.update((data.len() as u64).to_le_bytes()); + hasher.update(data); + } + hasher.finalize().into() + } + + /// Tie the private read position and the read-to-the-end flag to the + /// identity's scanning keys: when the keys no longer match the ones they + /// hold for, entries to or from a key added since were never opened, so + /// both are rewound to read the store again from 0. Returns the position + /// record to store when it changed. + pub(crate) fn reconcile_private_scan_keys(&mut self) -> Option { + let key_fingerprint = self.private_scan_key_fingerprint(); + if self.dashpay.private_scan_keys == Some(key_fingerprint) { + return None; + } + if self.dashpay.private_scan_keys.is_some() + || self.dashpay.private_contact_requests_position > 0 + { + self.dashpay.private_contact_requests_position = 0; + self.dashpay.private_contact_requests_read = false; + } + self.dashpay.private_scan_keys = Some(key_fingerprint); + Some(PrivateContactRequestsPosition { + position: self.dashpay.private_contact_requests_position, + key_fingerprint, + }) + } + + /// The private read position as a backend stores it: with the + /// fingerprint of the keys it holds for. + pub(crate) fn private_contact_requests_position_record( + &self, + ) -> PrivateContactRequestsPosition { + PrivateContactRequestsPosition { + position: self.dashpay.private_contact_requests_position, + key_fingerprint: self + .dashpay + .private_scan_keys + .unwrap_or_else(|| self.private_scan_key_fingerprint()), + } } /// Restore the shielded address `contact`'s tracked private request to @@ -850,6 +925,7 @@ impl ManagedIdentity { /// them. pub(crate) fn forget_private_contact_scan_state(&mut self) { self.dashpay.private_contact_requests_position = 0; + self.dashpay.private_scan_keys = None; self.dashpay.private_shielded_addresses.clear(); } diff --git a/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/dashpay.rs b/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/dashpay.rs index 4f550659279..dafa2d7fe2c 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/dashpay.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/dashpay.rs @@ -124,6 +124,14 @@ pub struct DashPayState { /// In memory only; a cursor rewind leaves it set. pub(super) private_contact_requests_read: bool, + /// The fingerprint of the scanning keys `private_contact_requests_position` + /// and `private_contact_requests_read` hold for + /// (`ManagedIdentity::private_scan_key_fingerprint`): entries to or from + /// a key added since were not opened. When the identity's keys no + /// longer match it, both are rewound (`reconcile_private_scan_keys`). + /// `None` before the first reconcile. + pub(super) private_scan_keys: Option<[u8; 32]>, + /// The shielded address each contact's tracked private request to this /// identity carries. Persisted for a backend attesting /// `PRIVATE_CONTACT_REQUEST_SCAN_STATE`; otherwise rebuilt by the scan a From f7761d090cea1136da247ba29e4f4cd6ef26bcde Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 9 Nov 2026 05:36:52 +0700 Subject: [PATCH 035/117] fix(platform-wallet): release a private reservation until its payment may reach the network The send's cancellation guard was disarmed right before `pay` was called, but the in-kind accept's `pay` then awaits the host's payment resolver and the spend-key derivation, and the shielded payment awaits the restore check and the proof, all before anything is broadcast. Dropping the send in any of those left a reservation nothing would settle, blocking private and public sends to the contact until restart. `pay` now gets a `PaymentStarted` token (exported) that it marks right before anything it does may reach the network. The guard releases the reservation on drop until the token is marked, and keeps it after; the send also marks it once `pay` returns. The shielded payment marks it right before the type-29 transition is broadcast, after the payment source, the spend key, the restore check and the proof. The pay closure of the `DashPayView` private sends and accepts takes `(entry, PaymentStarted)`. Co-Authored-By: Claude Opus 5.5 --- packages/rs-platform-wallet/src/lib.rs | 2 +- .../src/wallet/identity/network/mod.rs | 4 +- .../network/private_contact_requests.rs | 172 ++++++++++++++---- .../src/wallet/platform_wallet.rs | 52 ++++-- .../src/wallet/shielded/operations.rs | 7 +- 5 files changed, 187 insertions(+), 50 deletions(-) diff --git a/packages/rs-platform-wallet/src/lib.rs b/packages/rs-platform-wallet/src/lib.rs index 0cd7448d1f1..653b8d47098 100644 --- a/packages/rs-platform-wallet/src/lib.rs +++ b/packages/rs-platform-wallet/src/lib.rs @@ -77,7 +77,7 @@ pub use wallet::core_address_key::{CoreAddressKeyError, CoreAddressPrivateKey}; pub use wallet::identity::network::{ derive_identity_auth_keypair, fetch_identity_public_keys, AutoAcceptProofSource, ContactCryptoProvider, ContactInfoOpened, ContactInfoPublishOutcome, ContactInfoSealed, - FetchedIdentityPublicKeys, PreparedDashPayPayment, PrivateContactSyncReport, + FetchedIdentityPublicKeys, PaymentStarted, PreparedDashPayPayment, PrivateContactSyncReport, PrivateDocumentKeyPaths, SeedBindingVerification, IDENTITY_GAP_LIMIT, MASTER_KEY_INDEX, }; pub use wallet::identity::{ diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/mod.rs b/packages/rs-platform-wallet/src/wallet/identity/network/mod.rs index c52b1e610ee..85edb5c33e5 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/mod.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/mod.rs @@ -76,7 +76,9 @@ pub(crate) use payments::{record_incoming_dashpay_payments, sent_payment_status_ mod prepared_payment; pub use prepared_payment::PreparedDashPayPayment; mod private_contact_requests; -pub use private_contact_requests::{PrivateContactSyncReport, PrivateDocumentKeyPaths}; +pub use private_contact_requests::{ + PaymentStarted, PrivateContactSyncReport, PrivateDocumentKeyPaths, +}; mod profile; mod public_keys; pub use public_keys::{fetch_identity_public_keys, FetchedIdentityPublicKeys}; diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/private_contact_requests.rs b/packages/rs-platform-wallet/src/wallet/identity/network/private_contact_requests.rs index a5f216b866c..cfb461bffb2 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/private_contact_requests.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/private_contact_requests.rs @@ -28,6 +28,7 @@ use std::collections::{BTreeMap, BTreeSet}; use std::future::Future; +use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; use dash_sdk::dapi_grpc::platform::v0::ResponseMetadata; @@ -197,29 +198,53 @@ impl ScannedIdentity { } } +/// Handed to a private request's `pay`, which marks it right before anything +/// it does may reach the network. Until it is marked, a send dropped (a +/// cancelled accept, a timeout) releases its reservation, as nothing was paid +/// and no sync could settle it; once marked, the reservation stays for a +/// sync to settle. A `pay` that awaits anything first, such as resolving its +/// payment source, a spend key or a proof, marks it only after those. +#[derive(Clone, Debug, Default)] +pub struct PaymentStarted(Arc); + +impl PaymentStarted { + /// The payment may reach the network from now on. + pub fn mark(&self) { + self.0.store(true, Ordering::Release); + } + + /// Whether [`Self::mark`] was called. + pub fn is_marked(&self) -> bool { + self.0.load(Ordering::Acquire) + } +} + /// The reservation one send attempt holds on the private request to a -/// recipient that may be in flight. Dropped while armed, it releases the -/// reservation: a send cancelled before it pays leaves nothing pending, which -/// no sync could settle. It is disarmed once payment may have started. +/// recipient that may be in flight. Dropped before its payment is marked +/// started ([`PaymentStarted`]), it releases the reservation: a send +/// cancelled before it pays leaves nothing pending. Once the payment may +/// have started, or the send kept it, the reservation stays for the sync. struct PrivateReplyReservation { wallet_manager: Arc>>, wallet_id: WalletId, sender: Identifier, recipient: Identifier, attempt: u64, - armed: bool, + payment_started: PaymentStarted, + kept: bool, } impl PrivateReplyReservation { /// Leave the reservation for the private sync to settle. fn keep(&mut self) { - self.armed = false; + self.kept = true; } /// Release the reservation now, kept or not. Dropped while it waits for /// the wallet lock, the guard releases it. async fn release(mut self) { - self.armed = true; + self.kept = false; + self.payment_started = PaymentStarted::default(); let mut wm = self.wallet_manager.write().await; release_reservation( &mut wm, @@ -228,13 +253,13 @@ impl PrivateReplyReservation { &self.recipient, self.attempt, ); - self.armed = false; + self.kept = true; } } impl Drop for PrivateReplyReservation { fn drop(&mut self) { - if !self.armed { + if self.kept || self.payment_started.is_marked() { return; } let (wallet_id, sender, recipient, attempt) = @@ -424,7 +449,9 @@ impl DashPayView<'_, B> { /// read back yet, the send returns `PrivateContactRequestPending` and the /// request stays pending until a later sync reads it. An unconfirmed /// payment stays pending the same way; one that definitely failed - /// releases it, and so does a send dropped before it pays. `pay` itself + /// releases it, and so does a send dropped before its payment started: + /// `pay` gets a [`PaymentStarted`] it marks right before anything it does + /// may reach the network, after any resolving it awaits first. `pay` itself /// may return `PrivateContactRequestPending` to keep the request pending /// unpaid. The pending request is kept in memory only; after a restart, /// [`Self::restore_pending_private_contact_requests`] restores those whose @@ -450,7 +477,7 @@ impl DashPayView<'_, B> { where S: Signer + Send + Sync, C: ContactCryptoProvider + Sync, - F: FnOnce(BinaryData) -> Fut + Send, + F: FnOnce(BinaryData, PaymentStarted) -> Fut + Send, Fut: Future> + Send, { self.read_private_store_before_sending(sender_identity_id, recipient_identity_id, crypto) @@ -469,7 +496,8 @@ impl DashPayView<'_, B> { sender: *sender_identity_id, recipient: *recipient_identity_id, attempt, - armed: true, + payment_started: PaymentStarted::default(), + kept: false, }; let sent = self .send_reserved_private_contact_request( @@ -634,7 +662,7 @@ impl DashPayView<'_, B> { where S: Signer + Send + Sync, C: ContactCryptoProvider + Sync, - F: FnOnce(BinaryData) -> Fut + Send, + F: FnOnce(BinaryData, PaymentStarted) -> Fut + Send, Fut: Future> + Send, { let PreparedContactRequest { @@ -758,10 +786,12 @@ impl DashPayView<'_, B> { 0, ); - // Once payment may have started, the reservation is the sync's to - // settle, whatever happens to this send. - reservation.keep(); - pay(entry).await?; + // `pay` marks the payment started right before it may reach the + // network: from then on the reservation is the sync's to settle, + // whatever happens to this send. + let paid = pay(entry, reservation.payment_started.clone()).await; + reservation.payment_started.mark(); + paid?; // Recorded only as the store proves it: read it back. if let Err(error) = self @@ -833,7 +863,7 @@ impl DashPayView<'_, B> { where S: Signer + Send + Sync, C: ContactCryptoProvider + Sync, - F: FnOnce(BinaryData) -> Fut + Send, + F: FnOnce(BinaryData, PaymentStarted) -> Fut + Send, Fut: Future> + Send, { self.read_private_store_before_sending(&request.recipient_id, &request.sender_id, crypto) @@ -880,7 +910,7 @@ impl DashPayView<'_, B> { where S: Signer + Send + Sync, C: ContactCryptoProvider + Sync, - F: FnOnce(BinaryData) -> Fut + Send, + F: FnOnce(BinaryData, PaymentStarted) -> Fut + Send, Fut: Future> + Send, { if request.came_privately() || self.needs_private_reply(request).await { @@ -1913,12 +1943,14 @@ mod tests { kept: &Arc>>, ) -> impl FnOnce( BinaryData, + PaymentStarted, ) -> std::pin::Pin< Box> + Send>, > + Send { let wallet = Arc::clone(wallet); let kept = Arc::clone(kept); - move |entry| { + move |entry, started| { + started.mark(); Box::pin(async move { let mut entries = before; entries.push(entry.to_vec()); @@ -1937,10 +1969,14 @@ mod tests { /// the store. fn keep_entry( kept: &Arc>>, - ) -> impl FnOnce(BinaryData) -> std::future::Ready> + ) -> impl FnOnce( + BinaryData, + PaymentStarted, + ) -> std::future::Ready> + Send { let kept = Arc::clone(kept); - move |entry| { + move |entry, started| { + started.mark(); kept.lock().expect("kept entries").push(entry); std::future::ready(Ok(ResponseMetadata { time_ms: BLOCK_TIME_MS, @@ -2459,11 +2495,9 @@ mod tests { let pending = |result: &Result| matches!(result, Err(PlatformWalletError::PrivateContactRequestPending(id)) if *id == bob.id()); // A payment that definitely failed releases the recipient. - let failed = send!( - |_| std::future::ready(Err(PlatformWalletError::InvalidIdentityData( - "no notes".to_string() - ))) - ); + let failed = send!(|_, _| std::future::ready(Err( + PlatformWalletError::InvalidIdentityData("no notes".to_string()) + ))); assert!(!pending(&failed), "{failed:?}"); // Paid for, but the entry is not in the store yet: pending. @@ -2512,7 +2546,8 @@ mod tests { None, &alice_signer, &alice_crypto, - |_| { + |_, started: PaymentStarted| { + started.mark(); std::future::ready(Err(PlatformWalletError::ShieldedSpendUnconfirmed { operation: "private document create", reason: "the proof fetch timed out".to_string(), @@ -2928,7 +2963,7 @@ mod tests { None, &alice_signer, &alice_crypto, - |entry: BinaryData| { + |entry: BinaryData, started: PaymentStarted| { let paid = Arc::clone(&paid); let unconfirmed = &unconfirmed; let crypto = &alice_crypto; @@ -2944,6 +2979,7 @@ mod tests { { return Err(PlatformWalletError::PrivateContactRequestPending(bob_id)); } + started.mark(); paid.lock().expect("paid").push(entry); Ok(ResponseMetadata::default()) } @@ -3000,7 +3036,8 @@ mod tests { let v0_entry = kept.lock().expect("kept").remove(0); let unconfirmed = { let kept = Arc::clone(&kept); - move |entry: BinaryData| { + move |entry: BinaryData, started: PaymentStarted| { + started.mark(); kept.lock().expect("kept").push(entry); std::future::ready(Err::( PlatformWalletError::ShieldedSpendUnconfirmed { @@ -3099,7 +3136,7 @@ mod tests { None, &alice_signer, &alice_crypto, - |entry: BinaryData| { + |entry: BinaryData, started: PaymentStarted| { let paid = Arc::clone(&paid); let unconfirmed_entries = &unconfirmed_entries; let crypto = &alice_crypto; @@ -3115,6 +3152,7 @@ mod tests { { return Err(PlatformWalletError::PrivateContactRequestPending(bob_id)); } + started.mark(); paid.lock().expect("paid").push(entry); Ok(ResponseMetadata::default()) } @@ -4005,9 +4043,78 @@ mod tests { .await; } + /// A reply dropped while its payment source is still being resolved, as + /// a host's prompt left open and abandoned, keeps no reservation: + /// nothing was paid, and a retry goes on to pay. + #[cfg(feature = "shielded")] + #[tokio::test] + async fn should_release_a_reply_dropped_while_its_payment_is_resolved() { + use crate::wallet::platform_wallet::PrivateReplyPayment; + + struct NoProver; + impl dpp::shielded::builder::OrchardProver for NoProver { + fn proving_key(&self) -> &grovedb_commitment_tree::ProvingKey { + panic!("nothing is proved") + } + } + + let alice = identity(ALICE_PHRASE, ALICE); + let bob = identity(BOB_PHRASE, BOB); + let bob_crypto = SeedCryptoProvider::from_seed(seed(BOB_PHRASE), Network::Testnet); + let bob_signer = signer(BOB_PHRASE, &bob); + let (entry, received) = alice_to_bob([0x55; 43]).await; + let (_manager, bob_wallet) = wallet(BOB_PHRASE, &bob, &[&alice, &bob], vec![entry]).await; + bob_wallet + .identity() + .dashpay() + .sync_private_contact_requests(&bob_crypto, 4) + .await + .expect("Bob reads the store"); + + let dropped = tokio::time::timeout( + Duration::from_millis(200), + bob_wallet.accept_contact_request_replying_in_kind( + &received, + None, + &bob_signer, + &bob_crypto, + || { + std::future::pending::< + Result>, PlatformWalletError>, + >() + }, + ), + ) + .await; + assert!(dropped.is_err(), "the payment source never resolves"); + assert!(!pending_to(&bob_wallet, BOB, &alice).await); + + let reply = Arc::new(Mutex::new(Vec::new())); + let retried = bob_wallet + .identity() + .dashpay() + .accept_private_contact_request( + &received, + None, + &bob_signer, + &bob_crypto, + keep_entry(&reply), + ) + .await; + assert!( + matches!( + retried, + Err(PlatformWalletError::PrivateContactRequestPending(_)) + ), + "paid, not read back: {retried:?}" + ); + assert_eq!(reply.lock().expect("reply").len(), 1, "the retry pays"); + } + /// A `pay` that must not run. fn no_payment( _: BinaryData, + _: PaymentStarted, ) -> std::future::Ready> { panic!("nothing is paid") } @@ -4271,7 +4378,8 @@ mod tests { None, &alice_signer, &alice_crypto, - |_: BinaryData| { + |_: BinaryData, started: PaymentStarted| { + started.mark(); let read_when_paid = Arc::clone(&read_when_paid); let wallet = &alice_wallet; async move { @@ -4364,7 +4472,7 @@ mod tests { None, &alice_signer, &alice_crypto, - |_: BinaryData| -> std::future::Ready> { + |_: BinaryData, _: PaymentStarted| -> std::future::Ready> { panic!("a request that repeats the tracked one is not paid for") }, ) diff --git a/packages/rs-platform-wallet/src/wallet/platform_wallet.rs b/packages/rs-platform-wallet/src/wallet/platform_wallet.rs index eb35842797c..34fb254b71c 100644 --- a/packages/rs-platform-wallet/src/wallet/platform_wallet.rs +++ b/packages/rs-platform-wallet/src/wallet/platform_wallet.rs @@ -48,7 +48,10 @@ use { super::shielded::operations::private_document_create_from_pool_with_metadata, super::shielded::store::PendingRedrive, super::shielded::{NetworkShieldedCoordinator, OrchardKeySet, ShieldedStore}, - crate::{AutoAcceptProofSource, ContactCryptoProvider, ContactRequest, EstablishedContact}, + crate::{ + AutoAcceptProofSource, ContactCryptoProvider, ContactRequest, EstablishedContact, + PaymentStarted, + }, dash_sdk::dapi_grpc::platform::v0::ResponseMetadata, dash_sdk::platform::dashpay::PRIVATE_CONTACT_REQUEST_DOCUMENT_TYPE_NAME, dpp::data_contract::accessors::v0::DataContractV0Getters, @@ -1599,7 +1602,7 @@ impl PlatformWallet { shielded_address, signer, crypto, - |entry| { + |entry, started| { self.pay_private_contact_request( coordinator, keyset, @@ -1608,6 +1611,7 @@ impl PlatformWallet { recipient_identity_id, crypto, entry, + started, prover, ) }, @@ -1642,18 +1646,25 @@ impl PlatformWallet { let keyset = self.derive_spend_keyset(seed, account).await?; self.identity .dashpay() - .accept_private_contact_request(request, shielded_address, signer, crypto, |entry| { - self.pay_private_contact_request( - coordinator, - keyset, - account, - &request.recipient_id, - &request.sender_id, - crypto, - entry, - prover, - ) - }) + .accept_private_contact_request( + request, + shielded_address, + signer, + crypto, + |entry, started| { + self.pay_private_contact_request( + coordinator, + keyset, + account, + &request.recipient_id, + &request.sender_id, + crypto, + entry, + started, + prover, + ) + }, + ) .await } @@ -1690,7 +1701,10 @@ impl PlatformWallet { shielded_address, signer, crypto, - |entry| async move { + |entry, started| async move { + // Resolving the payment and deriving the spend key come + // first, before `started` is marked: a reply dropped + // meanwhile releases its reservation. let PrivateReplyPayment { coordinator, seed, @@ -1707,6 +1721,7 @@ impl PlatformWallet { &request.sender_id, crypto, entry, + started, prover, ) .await @@ -1776,6 +1791,10 @@ impl PlatformWallet { /// re-created since this handle was taken is refused with /// [`PlatformWalletError::WalletRemovedBeforeSend`], with nothing /// restored, recorded or paid. A caller must not hold the gate. + /// + /// `started` is marked right before the transition may reach the + /// network, after the restore check and the proof: until then a send + /// dropped releases its reservation. #[cfg(feature = "shielded")] #[allow(clippy::too_many_arguments)] async fn pay_private_contact_request( @@ -1787,6 +1806,7 @@ impl PlatformWallet { recipient_identity_id: &Identifier, crypto: &C, entry: BinaryData, + started: PaymentStarted, prover: P, ) -> Result where @@ -1833,6 +1853,7 @@ impl PlatformWallet { dashpay_id, PRIVATE_CONTACT_REQUEST_DOCUMENT_TYPE_NAME, vec![entry], + &started, &prover, ) .await @@ -2895,6 +2916,7 @@ mod private_contact_request_payment_tests { &Identifier::from([0xB1; 32]), &crypto, BinaryData::new(vec![1; 589]), + PaymentStarted::default(), UnreachableProver, ) .await diff --git a/packages/rs-platform-wallet/src/wallet/shielded/operations.rs b/packages/rs-platform-wallet/src/wallet/shielded/operations.rs index 73cfc46bab9..55b11585aa1 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/operations.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/operations.rs @@ -32,6 +32,7 @@ use super::store::{PendingRedrive, ShieldedNote, ShieldedStore, SubwalletId}; use crate::broadcast_outcome::{broadcast_definitely_failed, carries_consensus_rejection}; use crate::changeset::{PlatformWalletChangeSet, ShieldedChangeSet}; use crate::error::{preserve_signer_key_unavailable_or, PlatformWalletError}; +use crate::wallet::identity::network::PaymentStarted; use crate::wallet::persister::WalletPersister; use crate::wallet::platform_wallet::WalletId; @@ -1571,7 +1572,8 @@ pub(in crate::wallet) async fn identity_top_up_from_pool_with_metadata< /// builds them. /// /// Waits for the spent nullifiers to be proven before marking the notes -/// spent, and returns the metadata of that proof. +/// spent, and returns the metadata of that proof. `payment_started` is +/// marked right before the transition is broadcast. #[allow(clippy::too_many_arguments)] pub(in crate::wallet) async fn private_document_create_from_pool_with_metadata< S: ShieldedStore, @@ -1586,6 +1588,7 @@ pub(in crate::wallet) async fn private_document_create_from_pool_with_metadata< data_contract_id: Identifier, document_type_name: &str, entries: Vec, + payment_started: &PaymentStarted, prover: &P, ) -> Result { let views = keys.viewing_keys(); @@ -1657,6 +1660,8 @@ pub(in crate::wallet) async fn private_document_create_from_pool_with_metadata< arm_pending_release(store, id, anchor_bytes, &pending_entry, &selected_notes).await; trace!("PrivateDocumentCreateFromShieldedPool: state transition built, broadcasting..."); + // From here the payment may reach the network. + payment_started.mark(); // Type 29 proves its spent nullifiers as a snapshot, which a competing // spend of the same notes shares, as type 22's does: the affected-state // wait accepts it (see `identity_top_up_from_pool_with_metadata`). From 1ebd255f68036bfbdc22a3f079d09aa2a2ac2161 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 9 Nov 2026 05:41:06 +0700 Subject: [PATCH 036/117] fix(platform-wallet): release a private reservation once its payment is rejected for good A private send whose payment was unconfirmed kept its reservation. When the shielded coordinator later settled that spend as never executing (a definitive consensus rejection on redrive, or its anchor pruned), it freed the notes and marked the activity Failed, but the reservation stayed until restart, blocking every private and public request to the contact. On both outcomes the coordinator now notes the commitments of the type-29 entries the saved transition carried on the wallet's `WalletPersister`, which the wallet and the coordinator share. The wallet releases the reservations sealed as one of them, whichever attempt holds them, before a private request is reserved (so the in-kind and private accepts too), before a public request claims its pair, and before a private payment. A reservation is released only on that word, never for a missing redrive: a request paid and not yet read back has none. Co-Authored-By: Claude Opus 5.5 --- .../identity/network/contact_requests.rs | 1 + .../network/private_contact_requests.rs | 134 ++++++++++++++++++ .../managed_identity/contact_requests.rs | 13 ++ .../src/wallet/persister.rs | 33 +++++ .../src/wallet/platform_wallet.rs | 4 + .../src/wallet/shielded/coordinator.rs | 19 +++ .../src/wallet/shielded/operations.rs | 25 ++++ 7 files changed, 229 insertions(+) diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs b/packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs index fb74a77b130..f2956416f78 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs @@ -703,6 +703,7 @@ impl DashPayView<'_, B> { // the request is submitted and recorded, so a private request cannot // be reserved and paid for in between; the claim is released when // the send ends, however it ends. + self.release_rejected_private_requests().await?; let claim = self .claim_public_pair(sender_identity_id, recipient_identity_id) .await?; diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/private_contact_requests.rs b/packages/rs-platform-wallet/src/wallet/identity/network/private_contact_requests.rs index cfb461bffb2..5b7c21104f2 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/private_contact_requests.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/private_contact_requests.rs @@ -482,6 +482,7 @@ impl DashPayView<'_, B> { { self.read_private_store_before_sending(sender_identity_id, recipient_identity_id, crypto) .await?; + self.release_rejected_private_requests().await?; let attempt = self .with_managed(sender_identity_id, |managed| { managed.reserve_private_reply(*recipient_identity_id) @@ -611,6 +612,39 @@ impl DashPayView<'_, B> { Ok(()) } + /// Release the pending private requests whose payment the shielded + /// coordinator settled as never executing, for an entry a definitive + /// rejection or a pruned anchor left unappendable + /// (`WalletPersister::note_rejected_private_entries`): run before a + /// private request is reserved, before a public one claims its pair, + /// and before a private payment, so a retry needs no restart. A + /// reservation is released only on that word, never for a missing + /// unconfirmed spend: a request paid and not yet read back has none. + pub(crate) async fn release_rejected_private_requests( + &self, + ) -> Result<(), PlatformWalletError> { + let rejected = self.persister.take_rejected_private_entries(); + if rejected.is_empty() { + return Ok(()); + } + let mut wm = self.wallet_manager.write().await; + let info = wm + .get_wallet_info_mut(&self.wallet_id) + .ok_or_else(|| PlatformWalletError::WalletNotFound(hex::encode(self.wallet_id)))?; + let identity_ids: Vec = info + .identity_manager + .all_identities() + .iter() + .map(|identity| identity.id()) + .collect(); + for identity_id in identity_ids { + if let Some(managed) = info.identity_manager.managed_identity_mut(&identity_id) { + managed.release_rejected_private_replies(&rejected); + } + } + Ok(()) + } + /// Whether a private sync read DashPay's private contact request store /// to its end for `identity_id` since load. async fn private_store_read( @@ -4111,6 +4145,106 @@ mod tests { assert_eq!(reply.lock().expect("reply").len(), 1, "the retry pays"); } + /// A private request whose payment was unconfirmed, and which the + /// shielded coordinator then settled as never executing (a definitive + /// consensus rejection of its saved transition), frees its pair without + /// a restart: the retry pays. + #[cfg(feature = "shielded")] + #[tokio::test] + async fn should_free_a_pair_once_its_unconfirmed_payment_is_rejected_for_good() { + use crate::wallet::shielded::operations::note_rejected_private_documents; + use dpp::shielded::SerializedAction; + use dpp::state_transition::private_document_create_from_shielded_pool_transition::methods::PrivateDocumentCreateFromShieldedPoolTransitionMethodsV0; + use dpp::state_transition::private_document_create_from_shielded_pool_transition::PrivateDocumentCreateFromShieldedPoolTransition; + + let alice = identity(ALICE_PHRASE, ALICE); + let bob = identity(BOB_PHRASE, BOB); + let alice_crypto = SeedCryptoProvider::from_seed(seed(ALICE_PHRASE), Network::Testnet); + let alice_signer = signer(ALICE_PHRASE, &alice); + let (_manager, alice_wallet) = wallet(ALICE_PHRASE, &alice, &[&alice, &bob], vec![]).await; + let view = alice_wallet.identity().dashpay(); + let kept = Arc::new(Mutex::new(Vec::new())); + let unconfirmed = { + let kept = Arc::clone(&kept); + move |entry: BinaryData, started: PaymentStarted| { + started.mark(); + kept.lock().expect("kept").push(entry); + std::future::ready(Err::( + PlatformWalletError::ShieldedSpendUnconfirmed { + operation: "private document create", + reason: "the proof fetch timed out".to_string(), + }, + )) + } + }; + let sent = view + .send_private_contact_request( + &alice.id(), + &bob.id(), + None, + AutoAcceptProofSource::None, + None, + &alice_signer, + &alice_crypto, + unconfirmed, + ) + .await; + assert!( + matches!( + sent, + Err(PlatformWalletError::ShieldedSpendUnconfirmed { .. }) + ), + "{sent:?}" + ); + assert!(pending_to(&alice_wallet, ALICE, &bob).await); + let entry = kept.lock().expect("kept").remove(0); + + // The redrive pass's definitive rejection of the saved transition. + let action = SerializedAction { + nullifier: [1; 32], + rk: [2; 32], + cmx: [3; 32], + encrypted_note: vec![4; 216], + cv_net: [5; 32], + spend_auth_sig: [6; 64], + }; + let rejected = PrivateDocumentCreateFromShieldedPoolTransition::try_from_bundle( + dashpay_contract().expect("bundled DashPay").id(), + PRIVATE_CONTACT_REQUEST_DOCUMENT_TYPE_NAME.to_string(), + vec![entry], + vec![action.clone(), action], + 0, + [7; 32], + vec![8; 64], + [9; 64], + PlatformVersion::latest(), + ) + .expect("the saved transition"); + note_rejected_private_documents(&rejected, Some(&alice_wallet.identity().persister)); + + let retry = Arc::new(Mutex::new(Vec::new())); + let retried = view + .send_private_contact_request( + &alice.id(), + &bob.id(), + None, + AutoAcceptProofSource::None, + None, + &alice_signer, + &alice_crypto, + keep_entry(&retry), + ) + .await; + assert!( + matches!( + retried, + Err(PlatformWalletError::PrivateContactRequestPending(_)) + ), + "paid, not read back: {retried:?}" + ); + assert_eq!(retry.lock().expect("retry").len(), 1, "the retry pays"); + } + /// A `pay` that must not run. fn no_payment( _: BinaryData, diff --git a/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/contact_requests.rs b/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/contact_requests.rs index acc779988b8..a39ffa78ad2 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/contact_requests.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/contact_requests.rs @@ -1054,6 +1054,19 @@ impl ManagedIdentity { } } + /// Release every pending request sealed as one of `commitments`, whose + /// payments were settled as never executing, whichever attempt holds it. + pub(crate) fn release_rejected_private_replies( + &mut self, + commitments: &std::collections::BTreeSet<[u8; 32]>, + ) { + self.dashpay.pending_private_replies.retain(|_, pending| { + pending + .commitment + .is_none_or(|commitment| !commitments.contains(&commitment)) + }); + } + /// Settle the pending request to `recipient` sealed as `commitment`: the /// private sync read that entry back and recorded it. pub(crate) fn settle_private_reply(&mut self, recipient: &Identifier, commitment: &[u8; 32]) { diff --git a/packages/rs-platform-wallet/src/wallet/persister.rs b/packages/rs-platform-wallet/src/wallet/persister.rs index 02fa67c4859..90025ca7af2 100644 --- a/packages/rs-platform-wallet/src/wallet/persister.rs +++ b/packages/rs-platform-wallet/src/wallet/persister.rs @@ -4,6 +4,7 @@ //! - [`WalletPersister`] — wraps the shared [`PlatformWalletPersistence`] with //! a fixed `wallet_id` so callers don't need to pass the ID on every call. +use std::collections::BTreeSet; use std::sync::Arc; use dashcore::Txid; @@ -57,6 +58,12 @@ pub struct WalletPersister { wallet_id: WalletId, inner: Arc, contact_payment_gate: Arc>, + /// Commitments of private document entries whose payment can never + /// execute: the shielded coordinator notes them when it settles an + /// unconfirmed spend as failed for good, and the wallet releases the + /// private contact request reservations held for them. Shared by the + /// clones the wallet and the coordinator hold. In memory only. + rejected_private_entries: Arc>>, } impl WalletPersister { @@ -65,9 +72,35 @@ impl WalletPersister { wallet_id, inner, contact_payment_gate: Arc::new(tokio::sync::Mutex::new(())), + rejected_private_entries: Arc::new(std::sync::Mutex::new(BTreeSet::new())), } } + /// Note the commitments of private document entries whose payment was + /// settled as never executing, for the wallet to release what it holds + /// for them ([`Self::take_rejected_private_entries`]). + #[cfg(feature = "shielded")] + pub(crate) fn note_rejected_private_entries( + &self, + commitments: impl IntoIterator, + ) { + self.rejected_private_entries + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .extend(commitments); + } + + /// Take the commitments [`Self::note_rejected_private_entries`] noted + /// since the last take. + pub(crate) fn take_rejected_private_entries(&self) -> BTreeSet<[u8; 32]> { + std::mem::take( + &mut *self + .rejected_private_entries + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner), + ) + } + /// Keep contact-pool snapshots in reservation order across Core and /// withdrawal sends, including a Core broadcast rejection's pool rollback. /// diff --git a/packages/rs-platform-wallet/src/wallet/platform_wallet.rs b/packages/rs-platform-wallet/src/wallet/platform_wallet.rs index 34fb254b71c..8359e58079f 100644 --- a/packages/rs-platform-wallet/src/wallet/platform_wallet.rs +++ b/packages/rs-platform-wallet/src/wallet/platform_wallet.rs @@ -1824,6 +1824,10 @@ impl PlatformWallet { self.wallet_id, ))); } + self.identity + .dashpay() + .release_rejected_private_requests() + .await?; let unconfirmed = self .unconfirmed_private_contact_requests(coordinator) .await?; diff --git a/packages/rs-platform-wallet/src/wallet/shielded/coordinator.rs b/packages/rs-platform-wallet/src/wallet/shielded/coordinator.rs index 884f44d8dda..a71b709d85e 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/coordinator.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/coordinator.rs @@ -1797,6 +1797,25 @@ impl NetworkShieldedCoordinator { ); continue; } + // A private document the stranded spend paid for can never be + // appended: the wallet releases what it holds for it. + if let Some(entry_id) = activity_id { + let rejected = store + .pending_redrives(id) + .ok() + .into_iter() + .flatten() + .filter(|redrive| redrive.activity_id == entry_id) + .filter_map(|redrive| { + StateTransition::deserialize_from_bytes_trusted(&redrive.st_bytes).ok() + }); + for transition in rejected { + super::operations::note_rejected_private_documents( + &transition, + persisters.get(&id.wallet_id), + ); + } + } match store.clear_pending(id, &nullifier) { // `still_same` held under this same guard, so the clear // removes exactly the snapshot's reservation. diff --git a/packages/rs-platform-wallet/src/wallet/shielded/operations.rs b/packages/rs-platform-wallet/src/wallet/shielded/operations.rs index 55b11585aa1..7a55ab9c541 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/operations.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/operations.rs @@ -3517,6 +3517,7 @@ pub(super) async fn redrive_pending_spends( "redrive: definitive consensus rejection; the spend can never execute — \ releasing the reservation" ); + note_rejected_private_documents(&st, persister); { let mut guard = store.write().await; for n in &redrive.nullifiers { @@ -3553,6 +3554,30 @@ pub(super) async fn redrive_pending_spends( } } +/// Note on `persister` the commitments of the private document entries the +/// transition `st` appends, when it is one (type 29): its payment was settled +/// as never executing, so the wallet releases the private contact request +/// reservations held for those entries. A commitment matching no +/// reservation is passed over. +pub(crate) fn note_rejected_private_documents( + st: &StateTransition, + persister: Option<&WalletPersister>, +) { + use dpp::state_transition::private_document_create_from_shielded_pool_transition::accessors::PrivateDocumentCreateFromShieldedPoolTransitionAccessorsV0; + + let (Some(persister), StateTransition::PrivateDocumentCreateFromShieldedPool(transition)) = + (persister, st) + else { + return; + }; + persister.note_rejected_private_entries(transition.entries().iter().filter_map(|entry| { + entry + .as_slice() + .get(..32) + .and_then(|commitment| commitment.try_into().ok()) + })); +} + /// Broadcast a built shielded spend transition (unshield / transfer / /// withdraw) and wait for proven execution, staging the two SDK calls /// separately so the caller's reservation rollback only runs when the From 3012b4eaaa49ad10e1dea445546b4bcc29de4212 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 9 Nov 2026 05:46:23 +0700 Subject: [PATCH 037/117] fix(platform-wallet): save a private payment's signed transition before broadcasting it A type-29 private document payment armed its redrive record only after the broadcast and the result wait returned unconfirmed, and the note reservation it rides on is memory-only. A process that exited once the node admitted the transition, before the wait returned, left nothing for the restart restore: another funded request to the pair could follow while the first could still execute. `private_document_create_from_pool_with_metadata` now saves the record durably right before the broadcast, as identity-funded shields arm theirs (`arm_redrive_before_broadcast`), and refuses to broadcast when it cannot be saved. Both outcomes resolve it as before: proven execution marks the notes spent and drops it, a definite failure cancels the notes' reservation and drops it, and an unconfirmed one keeps it for the redrive. The payment is marked started before the record is saved, as a saved record will be redriven. Other note spends keep arming their record on the unconfirmed outcome only. Co-Authored-By: Claude Opus 5.5 --- .../network/private_contact_requests.rs | 145 ++++++++++++++++-- .../src/wallet/shielded/operations.rs | 115 +++++++++++++- 2 files changed, 240 insertions(+), 20 deletions(-) diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/private_contact_requests.rs b/packages/rs-platform-wallet/src/wallet/identity/network/private_contact_requests.rs index 5b7c21104f2..c1718529603 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/private_contact_requests.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/private_contact_requests.rs @@ -4153,9 +4153,6 @@ mod tests { #[tokio::test] async fn should_free_a_pair_once_its_unconfirmed_payment_is_rejected_for_good() { use crate::wallet::shielded::operations::note_rejected_private_documents; - use dpp::shielded::SerializedAction; - use dpp::state_transition::private_document_create_from_shielded_pool_transition::methods::PrivateDocumentCreateFromShieldedPoolTransitionMethodsV0; - use dpp::state_transition::private_document_create_from_shielded_pool_transition::PrivateDocumentCreateFromShieldedPoolTransition; let alice = identity(ALICE_PHRASE, ALICE); let bob = identity(BOB_PHRASE, BOB); @@ -4200,6 +4197,44 @@ mod tests { let entry = kept.lock().expect("kept").remove(0); // The redrive pass's definitive rejection of the saved transition. + note_rejected_private_documents( + &private_contact_request_payment(entry), + Some(&alice_wallet.identity().persister), + ); + + let retry = Arc::new(Mutex::new(Vec::new())); + let retried = view + .send_private_contact_request( + &alice.id(), + &bob.id(), + None, + AutoAcceptProofSource::None, + None, + &alice_signer, + &alice_crypto, + keep_entry(&retry), + ) + .await; + assert!( + matches!( + retried, + Err(PlatformWalletError::PrivateContactRequestPending(_)) + ), + "paid, not read back: {retried:?}" + ); + assert_eq!(retry.lock().expect("retry").len(), 1, "the retry pays"); + } + + /// The type 29 transition paying for `entry`, with placeholder actions: + /// as the shielded store keeps a payment's signed transition. + #[cfg(feature = "shielded")] + fn private_contact_request_payment( + entry: BinaryData, + ) -> dpp::state_transition::StateTransition { + use dpp::shielded::SerializedAction; + use dpp::state_transition::private_document_create_from_shielded_pool_transition::methods::PrivateDocumentCreateFromShieldedPoolTransitionMethodsV0; + use dpp::state_transition::private_document_create_from_shielded_pool_transition::PrivateDocumentCreateFromShieldedPoolTransition; + let action = SerializedAction { nullifier: [1; 32], rk: [2; 32], @@ -4208,7 +4243,7 @@ mod tests { cv_net: [5; 32], spend_auth_sig: [6; 64], }; - let rejected = PrivateDocumentCreateFromShieldedPoolTransition::try_from_bundle( + PrivateDocumentCreateFromShieldedPoolTransition::try_from_bundle( dashpay_contract().expect("bundled DashPay").id(), PRIVATE_CONTACT_REQUEST_DOCUMENT_TYPE_NAME.to_string(), vec![entry], @@ -4219,30 +4254,112 @@ mod tests { [9; 64], PlatformVersion::latest(), ) - .expect("the saved transition"); - note_rejected_private_documents(&rejected, Some(&alice_wallet.identity().persister)); + .expect("a type 29 transition") + } - let retry = Arc::new(Mutex::new(Vec::new())); - let retried = view - .send_private_contact_request( + /// A process that exits after its private payment was admitted, during + /// the wait for its result, still leaves the payment's record, saved + /// before the broadcast: a restarted wallet restores the request's + /// reservation from it, so neither a second private request nor a + /// public one goes to the recipient. + #[cfg(feature = "shielded")] + #[tokio::test] + async fn should_restore_a_request_whose_process_exited_during_its_result_wait() { + use crate::wallet::shielded::operations::arm_redrive_before_broadcast; + use crate::wallet::shielded::{ + FileBackedShieldedStore, NetworkShieldedCoordinator, SubwalletId, + }; + + let alice = identity(ALICE_PHRASE, ALICE); + let bob = identity(BOB_PHRASE, BOB); + let alice_crypto = SeedCryptoProvider::from_seed(seed(ALICE_PHRASE), Network::Testnet); + let alice_signer = signer(ALICE_PHRASE, &alice); + let dir = tempfile::tempdir().expect("temporary shielded store"); + let db_path = dir.path().join("tree.sqlite"); + let coordinator_at = |db_path: std::path::PathBuf| { + let store = FileBackedShieldedStore::open_path(&db_path, 100).expect("open the store"); + Arc::new(NetworkShieldedCoordinator::new( + Arc::new(dash_sdk::Sdk::new_mock()), + Network::Testnet, + db_path, + store, + )) + }; + + let before = coordinator_at(db_path.clone()); + let (_manager, alice_wallet) = wallet(ALICE_PHRASE, &alice, &[&alice, &bob], vec![]).await; + let subwallet = SubwalletId::new(alice_wallet.wallet_id(), 0); + let pay = { + let store = Arc::clone(before.store()); + move |entry: BinaryData, started: PaymentStarted| async move { + // As the shielded payment does: marked, the record saved, + // broadcast and admitted; then the result never comes. + let transition = private_contact_request_payment(entry); + started.mark(); + arm_redrive_before_broadcast( + &store, + subwallet, + Some([0x77; 32]), + [7; 32], + vec![[1; 32]], + &transition, + "private document create from shielded pool", + ) + .await?; + std::future::pending::<()>().await; + Ok(ResponseMetadata::default()) + } + }; + let exited = tokio::time::timeout( + Duration::from_millis(200), + alice_wallet + .identity() + .dashpay() + .send_private_contact_request( + &alice.id(), + &bob.id(), + None, + AutoAcceptProofSource::None, + None, + &alice_signer, + &alice_crypto, + pay, + ), + ) + .await; + assert!(exited.is_err(), "the process exits during the wait"); + drop(before); + + let after = coordinator_at(db_path); + let (_restarted_manager, restarted) = + wallet(ALICE_PHRASE, &alice, &[&alice, &bob], vec![]).await; + assert_eq!(restarted.wallet_id(), alice_wallet.wallet_id()); + assert!(!pending_to(&restarted, ALICE, &bob).await); + let restored = restarted + .restore_pending_private_contact_requests(&after, &alice_crypto) + .await + .expect("restored from the saved record"); + assert_eq!(restored, vec![(alice.id(), bob.id())]); + assert!(pending_to(&restarted, ALICE, &bob).await); + let public = restarted + .identity() + .dashpay() + .send_contact_request_with_external_signer( &alice.id(), &bob.id(), None, AutoAcceptProofSource::None, - None, &alice_signer, &alice_crypto, - keep_entry(&retry), ) .await; assert!( matches!( - retried, + public, Err(PlatformWalletError::PrivateContactRequestPending(_)) ), - "paid, not read back: {retried:?}" + "{public:?}" ); - assert_eq!(retry.lock().expect("retry").len(), 1, "the retry pays"); } /// A `pay` that must not run. diff --git a/packages/rs-platform-wallet/src/wallet/shielded/operations.rs b/packages/rs-platform-wallet/src/wallet/shielded/operations.rs index 7a55ab9c541..6a12ae77ab0 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/operations.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/operations.rs @@ -1662,16 +1662,26 @@ pub(in crate::wallet) async fn private_document_create_from_pool_with_metadata< trace!("PrivateDocumentCreateFromShieldedPool: state transition built, broadcasting..."); // From here the payment may reach the network. payment_started.mark(); + // Saved before the broadcast, not after an unconfirmed wait: a + // process that exits once the node admitted the transition, before + // the wait returns, still leaves the record a restart restores the + // request's reservation from, and the redrive resolves. The record + // is dropped with the notes' reservation on either outcome. + arm_redrive_before_broadcast( + store, + id, + pending_entry.as_ref().map(|entry| entry.id), + anchor_bytes, + selected_notes.iter().map(|note| note.nullifier).collect(), + &state_transition, + "private document create from shielded pool", + ) + .await?; // Type 29 proves its spent nullifiers as a snapshot, which a competing // spend of the same notes shares, as type 22's does: the affected-state // wait accepts it (see `identity_top_up_from_pool_with_metadata`). - broadcast_shielded_spend_with_redrive_with_metadata( + broadcast_shielded_spend( sdk, - store, - id, - &pending_entry, - anchor_bytes, - &selected_notes, &state_transition, "private document create from shielded pool", SpendResultWait::AffectedState, @@ -3176,6 +3186,52 @@ async fn broadcast_shielded_spend_with_redrive_with_metadata( result } +/// Save the re-drivable record of a spend durably before it is broadcast, +/// as identity-funded shields arm theirs: an exit after the node admitted +/// the transition, before its result returns, leaves the record. The record +/// is keyed by the spend's `activity_id` and carries its `nullifiers`. A +/// spend whose record cannot be saved is refused before anything is +/// broadcast, as the unknown outcome could then not be recovered. +pub(crate) async fn arm_redrive_before_broadcast( + store: &Arc>, + id: SubwalletId, + activity_id: Option<[u8; 32]>, + anchor: [u8; 32], + nullifiers: Vec<[u8; 32]>, + state_transition: &StateTransition, + operation: &'static str, +) -> Result<(), PlatformWalletError> { + use dpp::serialization::PlatformSerializable; + + let activity_id = activity_id.ok_or_else(|| { + PlatformWalletError::ShieldedBuildError(format!( + "{operation}: no activity entry keys the record of this spend, so it is not \ + broadcast" + )) + })?; + let st_bytes = state_transition.serialize_to_bytes().map_err(|e| { + PlatformWalletError::ShieldedBuildError(format!( + "{operation}: the transition does not serialize for its record, so it is not \ + broadcast: {e}" + )) + })?; + let redrive = PendingRedrive { + activity_id, + anchor, + nullifiers, + st_bytes, + attempts: 0, + identity_nonce_finalized: false, + identity_user_abandoned: false, + }; + store.write().await.arm_redrive(id, redrive).map_err(|e| { + PlatformWalletError::ShieldedStoreError(format!( + "{operation}: the record of this spend could not be saved, so it is not broadcast: \ + {e}" + )) + }) +} + /// Persist the re-drivable record for an ambiguous spend. Best-effort: /// a failure here only demotes the resolution path to the anchor-prune /// backstop (plus restart-loss of the reservation), never fails the @@ -4169,6 +4225,53 @@ mod redrive_tests { ); } + /// A spend's record is saved before its broadcast, keyed by its + /// activity entry; one with no entry to key it by is refused, nothing + /// saved. + #[tokio::test] + async fn should_save_a_spends_record_before_its_broadcast() { + let store = Arc::new(RwLock::new(InMemoryShieldedStore::new())); + let id = SubwalletId::new([0x61; 32], 0); + let transition = identity_shield_transition([0xBA; 32], 3); + + let refused = arm_redrive_before_broadcast( + &store, + id, + None, + [7; 32], + vec![[1; 32]], + &transition, + "test spend", + ) + .await; + assert!( + matches!(refused, Err(PlatformWalletError::ShieldedBuildError(_))), + "{refused:?}" + ); + assert!(store + .read() + .await + .pending_redrives(id) + .expect("redrives") + .is_empty()); + + arm_redrive_before_broadcast( + &store, + id, + Some([0x77; 32]), + [7; 32], + vec![[1; 32]], + &transition, + "test spend", + ) + .await + .expect("saved"); + let saved = store.read().await.pending_redrives(id).expect("redrives"); + assert_eq!(saved.len(), 1); + assert_eq!(saved[0].activity_id, [0x77; 32]); + assert_eq!(saved[0].nullifiers, vec![[1; 32]]); + } + #[tokio::test] async fn ordinary_redrive_pass_leaves_identity_guard_untouched() { let sdk = Arc::new(dash_sdk::SdkBuilder::new_mock().build().expect("mock sdk")); From 0a3b859fb22bc5f3c709b463dd4d78520bd89333 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 9 Nov 2026 05:52:07 +0700 Subject: [PATCH 038/117] fix(platform-wallet): adopt a reply another accept recorded once the pair is taken The accept read whether a reply was already sent before the private reservation was taken. Accept B could read none, resume after accept A recorded its reply and settled its reservation, then reserve and pay a new rotation instead of adopting A's reply. The reply now checks for a tracked request to the sender in the same lock that takes the private reservation, after the store read since load, and in the same lock as the public claim for a public reply. When one is there, nothing is reserved, sealed or paid: the send reports `ReplyOutcome::AlreadyReplied`, and `accept_contact_request_replying_with` adopts it as it adopts a reply found up front, establishing the pair as it stands by then. Co-Authored-By: Claude Opus 5.5 --- .../identity/network/contact_requests.rs | 132 ++++++++++--- .../network/private_contact_requests.rs | 173 +++++++++++++++--- 2 files changed, 254 insertions(+), 51 deletions(-) diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs b/packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs index f2956416f78..a906663aca0 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs @@ -564,6 +564,16 @@ pub enum AutoAcceptProofSource { }, } +/// What sending a reply to a contact request came to. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum ReplyOutcome { + /// The reply was sent. + Sent, + /// A reply to the request's sender was already tracked by the time the + /// pair was claimed or reserved: nothing was sent, and it is adopted. + AlreadyReplied, +} + /// The claim a public request holds on its pair from before its channel is /// decided until it is submitted and recorded /// (`DashPayView::claim_public_pair`): while it is held no private request to @@ -703,10 +713,49 @@ impl DashPayView<'_, B> { // the request is submitted and recorded, so a private request cannot // be reserved and paid for in between; the claim is released when // the send ends, however it ends. + self.send_public_contact_request( + sender_identity_id, + recipient_identity_id, + account_label, + auto_accept_proof, + signer, + crypto, + false, + ) + .await? + .ok_or_else(|| { + PlatformWalletError::InvalidParameter( + "no contact request was sent: only a reply adopts one already sent".to_string(), + ) + }) + } + + /// [`Self::send_contact_request_with_external_signer`], for a reply when + /// `adopt_reply`: then a request to the recipient tracked by the time + /// the pair is claimed (another accept's reply) is adopted rather than + /// rotated past, and `None` comes back with nothing sent. + #[allow(clippy::too_many_arguments)] + pub(super) async fn send_public_contact_request( + &self, + sender_identity_id: &Identifier, + recipient_identity_id: &Identifier, + account_label: Option, + auto_accept_proof: AutoAcceptProofSource, + signer: &S, + crypto: &C, + adopt_reply: bool, + ) -> Result, PlatformWalletError> + where + S: Signer + Send + Sync, + C: ContactCryptoProvider + Sync, + { self.release_rejected_private_requests().await?; - let claim = self - .claim_public_pair(sender_identity_id, recipient_identity_id) - .await?; + let Some(claim) = self + .claim_public_pair(sender_identity_id, recipient_identity_id, adopt_reply) + .await? + else { + return Ok(None); + }; let sent = self .send_claimed_contact_request( sender_identity_id, @@ -718,7 +767,7 @@ impl DashPayView<'_, B> { ) .await; claim.release().await; - sent + sent.map(Some) } /// Claim the pair `sender`, `recipient` for a public request: refused, @@ -732,7 +781,8 @@ impl DashPayView<'_, B> { &self, sender: &Identifier, recipient: &Identifier, - ) -> Result { + adopt_reply: bool, + ) -> Result, PlatformWalletError> { let mut wm = self.wallet_manager.write().await; let managed = wm .get_wallet_info_mut(&self.wallet_id) @@ -740,6 +790,10 @@ impl DashPayView<'_, B> { .identity_manager .managed_identity_mut(sender) .ok_or(PlatformWalletError::IdentityNotFound(*sender))?; + // A reply finds the one another accept recorded, in the same lock. + if adopt_reply && managed.dashpay().outgoing_request(recipient).is_some() { + return Ok(None); + } if managed.dashpay().private_reply_pending(recipient) { return Err(PlatformWalletError::PrivateContactRequestPending( *recipient, @@ -751,13 +805,13 @@ impl DashPayView<'_, B> { )); } managed.claim_public_contact_request(*recipient); - Ok(PublicContactClaim { + Ok(Some(PublicContactClaim { wallet_manager: Arc::clone(&self.wallet_manager), wallet_id: self.wallet_id, sender: *sender, recipient: *recipient, held: true, - }) + })) } /// The body of [`Self::send_contact_request_with_external_signer`], once @@ -3986,16 +4040,23 @@ impl DashPayView<'_, B> { )); } self.accept_contact_request_replying_with(request, crypto, || async { - self.send_contact_request_with_external_signer( - &request.recipient_id, - &request.sender_id, - None, - AutoAcceptProofSource::None, - signer, - crypto, + Ok( + match self + .send_public_contact_request( + &request.recipient_id, + &request.sender_id, + None, + AutoAcceptProofSource::None, + signer, + crypto, + true, + ) + .await? + { + Some(_) => ReplyOutcome::Sent, + None => ReplyOutcome::AlreadyReplied, + }, ) - .await - .map(|_| ()) }) .await } @@ -4024,7 +4085,10 @@ impl DashPayView<'_, B> { /// Accept `request`, with `send_reply` sending our reciprocal request to /// its sender unless one already exists: the body of /// [`Self::accept_contact_request_with_external_signer`], shared with the - /// private accept, which replies privately. + /// private accept, which replies privately. `send_reply` reports a reply + /// tracked by the time it claimed or reserved the pair (another accept's, + /// recorded in between) as [`ReplyOutcome::AlreadyReplied`], and that + /// reply is adopted as one found here first is: nothing is sent or paid. pub(super) async fn accept_contact_request_replying_with( &self, request: &ContactRequest, @@ -4034,14 +4098,14 @@ impl DashPayView<'_, B> { where C: ContactCryptoProvider + Sync, R: FnOnce() -> Fut, - Fut: std::future::Future>, + Fut: std::future::Future>, { let our_identity_id = request.recipient_id; let sender_id = request.sender_id; // 1. Verify the incoming request is known, and detect whether an // on-platform reciprocal already exists for this pair. - let (already_established, already_reciprocated) = { + let (_, already_reciprocated) = { let wm = self.wallet_manager.read().await; let info = wm .get_wallet_info(&self.wallet_id) @@ -4089,7 +4153,9 @@ impl DashPayView<'_, B> { // fresh-send local registrations below (receiving account + // validate→decrypt→register external), so the contact becomes // payable without a duplicate broadcast. - if already_reciprocated { + let adopt = + already_reciprocated || matches!(send_reply().await?, ReplyOutcome::AlreadyReplied); + if adopt { tracing::info!( our_identity = %our_identity_id, contact = %sender_id, @@ -4109,19 +4175,27 @@ impl DashPayView<'_, B> { // path the normal flow and the sync sweep use), so the final lookup // is guaranteed non-`None`. Idempotent: `add_incoming_contact_request` // preserves metadata on an already-established pair. - if !already_established { + { let mut wm = self.wallet_manager.write().await; let managed = wm .get_wallet_info_mut(&self.wallet_id) .and_then(|info| info.identity_manager.managed_identity_mut(&our_identity_id)) .ok_or(PlatformWalletError::IdentityNotFound(our_identity_id))?; - managed - .add_incoming_contact_request(request.clone(), &self.persister) - .map_err(|e| { - PlatformWalletError::Persistence(format!( - "accept-adopt: incoming request not persisted: {e}" - )) - })?; + // Read now: a reply adopted after `send_reply` may have + // established the pair since step 1. + if !managed + .dashpay() + .established_contacts() + .contains_key(&sender_id) + { + managed + .add_incoming_contact_request(request.clone(), &self.persister) + .map_err(|e| { + PlatformWalletError::Persistence(format!( + "accept-adopt: incoming request not persisted: {e}" + )) + })?; + } } // Adopt: register the receiving (friendship) account, derived via // the signer (no resident seed), matching the fresh-send path. @@ -4149,8 +4223,6 @@ impl DashPayView<'_, B> { "Accept-adopt: failed to derive receiving xpub via signer; will retry on next sweep" ), } - } else { - send_reply().await?; } // 4. Validate key indices (same gate as the sync sweep and the diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/private_contact_requests.rs b/packages/rs-platform-wallet/src/wallet/identity/network/private_contact_requests.rs index c1718529603..973f1d18204 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/private_contact_requests.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/private_contact_requests.rs @@ -64,7 +64,7 @@ use zeroize::Zeroize; use super::contact_requests::{ ingest_received_requests, ingest_sent_requests, newest_received_per_sender, newest_sent_per_recipient, predates, records_or_supersedes, tear_down_stale_external_accounts, - AutoAcceptProofSource, ContactCryptoProvider, PreparedContactRequest, + AutoAcceptProofSource, ContactCryptoProvider, PreparedContactRequest, ReplyOutcome, }; use super::signing_key::first_available; use super::{dashpay_contract, DashPayView, IdentityWallet}; @@ -474,6 +474,88 @@ impl DashPayView<'_, B> { crypto: &C, pay: F, ) -> Result + where + S: Signer + Send + Sync, + C: ContactCryptoProvider + Sync, + F: FnOnce(BinaryData, PaymentStarted) -> Fut + Send, + Fut: Future> + Send, + { + self.send_private_request( + sender_identity_id, + recipient_identity_id, + account_label, + auto_accept_proof, + shielded_address, + signer, + crypto, + false, + pay, + ) + .await? + .ok_or_else(|| { + PlatformWalletError::InvalidParameter( + "no private contact request was sent: only a reply adopts one already sent" + .to_string(), + ) + }) + } + + /// Reply privately to `request`, paid by `pay`: `AlreadyReplied`, with + /// nothing sealed or paid, when a request to its sender is tracked by the + /// time the pair is reserved (another accept's reply, recorded in + /// between), which the accept then adopts. + pub(super) async fn send_private_reply( + &self, + request: &ContactRequest, + shielded_address: Option<[u8; 43]>, + signer: &S, + crypto: &C, + pay: F, + ) -> Result + where + S: Signer + Send + Sync, + C: ContactCryptoProvider + Sync, + F: FnOnce(BinaryData, PaymentStarted) -> Fut + Send, + Fut: Future> + Send, + { + Ok( + match self + .send_private_request( + &request.recipient_id, + &request.sender_id, + None, + AutoAcceptProofSource::None, + shielded_address, + signer, + crypto, + true, + pay, + ) + .await? + { + Some(_) => ReplyOutcome::Sent, + None => ReplyOutcome::AlreadyReplied, + }, + ) + } + + /// [`Self::send_private_contact_request`], for a reply when + /// `adopt_reply`: then a request to the recipient tracked once the store + /// was read and the pair is reserved, in the same lock, is adopted + /// rather than rotated past, and `None` comes back with nothing paid. + #[allow(clippy::too_many_arguments)] + async fn send_private_request( + &self, + sender_identity_id: &Identifier, + recipient_identity_id: &Identifier, + account_label: Option, + auto_accept_proof: AutoAcceptProofSource, + shielded_address: Option<[u8; 43]>, + signer: &S, + crypto: &C, + adopt_reply: bool, + pay: F, + ) -> Result, PlatformWalletError> where S: Signer + Send + Sync, C: ContactCryptoProvider + Sync, @@ -483,14 +565,25 @@ impl DashPayView<'_, B> { self.read_private_store_before_sending(sender_identity_id, recipient_identity_id, crypto) .await?; self.release_rejected_private_requests().await?; - let attempt = self + let reserved = self .with_managed(sender_identity_id, |managed| { - managed.reserve_private_reply(*recipient_identity_id) + if adopt_reply + && managed + .dashpay() + .outgoing_request(recipient_identity_id) + .is_some() + { + return None; + } + Some(managed.reserve_private_reply(*recipient_identity_id)) }) - .await? - .ok_or(PlatformWalletError::PrivateContactRequestPending( - *recipient_identity_id, - ))?; + .await?; + let Some(reserved) = reserved else { + return Ok(None); + }; + let attempt = reserved.ok_or(PlatformWalletError::PrivateContactRequestPending( + *recipient_identity_id, + ))?; let mut reservation = PrivateReplyReservation { wallet_manager: Arc::clone(&self.wallet_manager), wallet_id: self.wallet_id, @@ -524,7 +617,7 @@ impl DashPayView<'_, B> { } else { reservation.release().await; } - sent + sent.map(Some) } /// Make sure DashPay's private contact request store was read to its end @@ -902,19 +995,8 @@ impl DashPayView<'_, B> { { self.read_private_store_before_sending(&request.recipient_id, &request.sender_id, crypto) .await?; - self.accept_contact_request_replying_with(request, crypto, || async { - self.send_private_contact_request( - &request.recipient_id, - &request.sender_id, - None, - AutoAcceptProofSource::None, - shielded_address, - signer, - crypto, - pay, - ) - .await - .map(|_| ()) + self.accept_contact_request_replying_with(request, crypto, || { + self.send_private_reply(request, shielded_address, signer, crypto, pay) }) .await } @@ -4362,6 +4444,55 @@ mod tests { ); } + /// Two accepts of one private request straddling the first reply's + /// settlement: the second read that no reply was sent before the first + /// ran, and reaches its reply after the first's was recorded and its + /// reservation settled. It adopts that reply, and its payment never runs. + #[tokio::test] + async fn should_adopt_a_reply_another_accept_recorded_in_between() { + let alice = identity(ALICE_PHRASE, ALICE); + let bob = identity(BOB_PHRASE, BOB); + let bob_crypto = SeedCryptoProvider::from_seed(seed(BOB_PHRASE), Network::Testnet); + let bob_signer = signer(BOB_PHRASE, &bob); + let (entry, received) = alice_to_bob([0x55; 43]).await; + let (_manager, bob_wallet) = wallet(BOB_PHRASE, &bob, &[&alice, &bob], vec![entry]).await; + let view = bob_wallet.identity().dashpay(); + view.sync_private_contact_requests(&bob_crypto, 4) + .await + .expect("Bob reads the store"); + let first_done = Arc::new(tokio::sync::Notify::new()); + let reply = Arc::new(Mutex::new(Vec::new())); + + let second = view.accept_contact_request_replying_with(&received, &bob_crypto, || { + let first_done = Arc::clone(&first_done); + let (received, bob_signer, bob_crypto) = (&received, &bob_signer, &bob_crypto); + async move { + first_done.notified().await; + view.send_private_reply(received, None, bob_signer, bob_crypto, no_payment) + .await + } + }); + let first = async { + let accepted = view + .accept_private_contact_request( + &received, + None, + &bob_signer, + &bob_crypto, + append_entry(&bob_wallet, 1, vec![], &reply), + ) + .await; + first_done.notify_one(); + accepted + }; + let (second, first) = tokio::join!(second, first); + let first = first.expect("the first reply is paid and recorded"); + let second = second.expect("the second accept adopts it"); + assert_eq!(second.outgoing_request, first.outgoing_request); + assert_eq!(reply.lock().expect("reply").len(), 1, "one reply is paid"); + assert!(!pending_to(&bob_wallet, BOB, &alice).await); + } + /// A `pay` that must not run. fn no_payment( _: BinaryData, From b8e41ab0a84e990997f4cc339e59422c64895baa Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 9 Nov 2026 05:58:12 +0700 Subject: [PATCH 039/117] fix(sdk): refuse a private document read below protocol version 15 as not active `fetch_private_document_contract` resolved the contract before any protocol check, so a reader on a network below protocol version 15 got a generic "declares no private type" error from the contract the provider or the network gives, not the refusal a page fetch gives. It now runs the page fetch's protocol gate first, and refuses with `Error::ProtocolVersionNotActive` when the contract declares no such private type while the SDK runs below 15, so a reader can tell that there is no store to read yet from a failure. The page stream and `scan_private_document_page` inherit it. Co-Authored-By: Claude Opus 5.5 --- .../dashpay/private_contact_request.rs | 46 +++++++++++++++++++ .../src/platform/private_document_entries.rs | 32 ++++++++++++- packages/rs-sdk/src/sdk/protocol_gate.rs | 19 ++++++++ 3 files changed, 96 insertions(+), 1 deletion(-) diff --git a/packages/rs-sdk/src/platform/dashpay/private_contact_request.rs b/packages/rs-sdk/src/platform/dashpay/private_contact_request.rs index 1ab12688101..0c0a725736f 100644 --- a/packages/rs-sdk/src/platform/dashpay/private_contact_request.rs +++ b/packages/rs-sdk/src/platform/dashpay/private_contact_request.rs @@ -3358,6 +3358,52 @@ mod tests { assert!(Arc::ptr_eq(&resolved, &held)); } + /// Below protocol version 15 there is no private store: a contract that declares no + /// private type, on an SDK running 14, is refused as not active, and the page stream ends + /// with that refusal before any page is fetched, which the mock, answering none, would + /// fail otherwise. + #[cfg(feature = "mocks")] + #[tokio::test] + async fn should_refuse_a_private_page_read_below_protocol_version_15() { + use futures::StreamExt; + + let before = dashpay_without_private_requests(); + let mut sdk: Sdk = crate::SdkBuilder::new_mock() + .with_version(PlatformVersion::get(14).expect("protocol version 14")) + .with_context_provider(HeldContractProvider::new(Some(before.clone()), true)) + .build() + .expect("a mock sdk"); + sdk.mock() + .expect_fetch(before.id(), Some(before.clone())) + .await + .expect("the DashPay fetch is expected"); + + let refused = sdk + .fetch_private_document_contract( + before.id(), + PRIVATE_CONTACT_REQUEST_DOCUMENT_TYPE_NAME, + ) + .await; + assert!( + matches!( + &refused, + Err(Error::ProtocolVersionNotActive { required: 15, .. }) + ), + "{refused:?}" + ); + let mut pages = std::pin::pin!(sdk.private_document_pages( + before.id(), + PRIVATE_CONTACT_REQUEST_DOCUMENT_TYPE_NAME, + 0, + 4, + )); + assert!(matches!( + pages.next().await, + Some(Err(Error::ProtocolVersionNotActive { .. })) + )); + assert!(pages.next().await.is_none()); + } + /// A DashPay the provider does not hold yet is fetched from the network and given to it, /// which the pages are then verified against. #[cfg(feature = "mocks")] diff --git a/packages/rs-sdk/src/platform/private_document_entries.rs b/packages/rs-sdk/src/platform/private_document_entries.rs index 4abc8dadf12..59ea5e126f5 100644 --- a/packages/rs-sdk/src/platform/private_document_entries.rs +++ b/packages/rs-sdk/src/platform/private_document_entries.rs @@ -16,7 +16,9 @@ use std::sync::Arc; -use crate::platform::{Fetch, FetchUnproved, Identifier, Query, QuerySettings}; +use crate::platform::{ + Fetch, FetchUnproved, Identifier, ProtocolRequirement, Query, QuerySettings, +}; use crate::{Error, Sdk}; use dapi_grpc::platform::v0::get_private_document_entries_request::GetPrivateDocumentEntriesRequestV0; use dapi_grpc::platform::v0::{ @@ -26,6 +28,7 @@ use dash_context_provider::ContextProvider; use dpp::data_contract::accessors::v0::DataContractV0Getters; use dpp::data_contract::document_type::accessors::DocumentTypeV2Getters; use dpp::data_contract::DataContract; +use dpp::version::feature_initial_protocol_versions::PRIVATE_DOCUMENT_CREATE_FROM_SHIELDED_POOL_INITIAL_PROTOCOL_VERSION; pub use drive_proof_verifier::types::private_document_entries::{ PrivateDocumentEntries, PrivateDocumentEntry, }; @@ -84,6 +87,9 @@ impl FetchUnproved for PrivateDocumentEntries { type Request = GetPrivateDocumentEntriesRequest; } +/// The gRPC method a page of a private document type's store is read with. +const PRIVATE_DOCUMENT_ENTRIES_METHOD: &str = "get_private_document_entries"; + /// Whether `contract` declares `document_type_name` as a private type. fn declares_private_type(contract: &DataContract, document_type_name: &str) -> bool { contract @@ -102,11 +108,26 @@ impl Sdk { /// refused when it does not declare the type, given to the provider and read back; the /// call fails when the provider then still holds none that declares it. Without a context /// provider the fetched contract is returned. + /// + /// Private types exist from protocol version 15. Below it, as the page fetch is, the call + /// is refused with [`Error::ProtocolVersionNotActive`], before anything is fetched on a + /// network that has not reached it, and when the contract declares no such type while + /// this SDK runs below it: a reader takes that as no store to read yet. pub async fn fetch_private_document_contract( &self, data_contract_id: Identifier, document_type_name: &str, ) -> Result, Error> { + self.ensure_network_serves( + Some(ProtocolRequirement { + protocol_version: + PRIVATE_DOCUMENT_CREATE_FROM_SHIELDED_POOL_INITIAL_PROTOCOL_VERSION, + method: PRIVATE_DOCUMENT_ENTRIES_METHOD, + feature: None, + }), + None, + ) + .await?; let provider = self.context_provider(); if let Some(provider) = &provider { if let Some(held) = provider.get_data_contract(&data_contract_id, self.version())? { @@ -120,6 +141,15 @@ impl Sdk { .map(Arc::new) .ok_or_else(|| Error::Generic(format!("the contract {data_contract_id} is gone")))?; if !declares_private_type(&contract, document_type_name) { + if self.protocol_version_number() + < PRIVATE_DOCUMENT_CREATE_FROM_SHIELDED_POOL_INITIAL_PROTOCOL_VERSION + { + return Err(self.protocol_version_not_active( + PRIVATE_DOCUMENT_CREATE_FROM_SHIELDED_POOL_INITIAL_PROTOCOL_VERSION, + PRIVATE_DOCUMENT_ENTRIES_METHOD, + None, + )); + } return Err(Error::Generic(format!( "the contract {data_contract_id} declares no private type \"{document_type_name}\"" ))); diff --git a/packages/rs-sdk/src/sdk/protocol_gate.rs b/packages/rs-sdk/src/sdk/protocol_gate.rs index 7be2d4f8e46..a75597cbc67 100644 --- a/packages/rs-sdk/src/sdk/protocol_gate.rs +++ b/packages/rs-sdk/src/sdk/protocol_gate.rs @@ -254,6 +254,25 @@ impl Sdk { self } + /// The refusal [`Self::ensure_network_serves`] gives a request that needs `required`, + /// for a caller that finds what `required` introduced missing from the network's data + /// while this SDK runs below it: the network does not serve it yet. + pub(crate) fn protocol_version_not_active( + &self, + required: u32, + method: &'static str, + feature: Option<&'static str>, + ) -> Error { + Error::ProtocolVersionNotActive { + method, + required, + feature, + network: self.query_settings().network_protocol_basis(), + pinned: pinned_below(required, &self.gate_state()), + cause: None, + } + } + /// Refuse a request that needs `requirement` (see [`Query::protocol_requirement`]), /// unless a verified answer shows the network runs its protocol version and this SDK /// speaks it. When the network may have reached it since, ask it first with one proved From afc6b7eda0ebd1e6d1758e98538cd988327de562 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 9 Nov 2026 06:29:33 +0700 Subject: [PATCH 040/117] fix(platform-wallet): hold a contact pair across a restart before any public send or in-kind accept A private reply paid for before a restart left nothing a public send or the accept a host calls would see until a manual sync or a host restore: - Before a public send, a public accept, and before the in-kind accept chooses its reply, DashPay's private store is read to its end when it was not since load, as the private send already does. A network below protocol version 15 (a verified version, or a pinned SDK) serves no private documents, so its refusal reads as nothing to read and the public send goes on; a refusal that may lift soon still refuses. - A type 29 payment's record is saved with the request it pays for (sender, recipient, account reference) in a new nullable `private_contact_payment` column of the file store's pending spends. `PaymentStarted` names the request's account reference for that. Binding the wallet to its shielded coordinator restores those pairs' reservations directly, with no host call and no keys (`PlatformWallet::restore_private_contact_payments`), skipping any whose request or a later one is already recorded. - The host restore call stays for records saved before the column and for identities added after the bind. Co-Authored-By: Claude Opus 5.5 --- packages/rs-platform-wallet/src/error.rs | 10 +- .../identity/network/contact_requests.rs | 23 +- .../network/private_contact_requests.rs | 582 +++++++++++++++++- .../src/wallet/platform_wallet.rs | 100 ++- .../src/wallet/shielded/file_store.rs | 194 +++++- .../src/wallet/shielded/mod.rs | 4 +- .../src/wallet/shielded/operations.rs | 24 +- .../src/wallet/shielded/store.rs | 60 ++ 8 files changed, 924 insertions(+), 73 deletions(-) diff --git a/packages/rs-platform-wallet/src/error.rs b/packages/rs-platform-wallet/src/error.rs index f3110add6c4..23712a42552 100644 --- a/packages/rs-platform-wallet/src/error.rs +++ b/packages/rs-platform-wallet/src/error.rs @@ -667,11 +667,11 @@ pub enum PlatformWalletError { /// paid for and is not yet read back from DashPay's store: no other one is /// sent, privately or publicly, until the private sync reads it back or /// its payment definitely fails, as the store keeps every entry appended. - /// Also returned, with nothing sealed or paid, by the first private send - /// or accept from an identity since load while DashPay's store cannot yet - /// be read to its end for it: a request paid for in an earlier process - /// may be there unread. Syncing private contact requests and trying again - /// goes on from where that read stopped. + /// Also returned, with nothing sealed, paid or broadcast, by the first + /// send or accept from an identity since load, public or private, while + /// DashPay's store cannot yet be read to its end for it: a request paid + /// for in an earlier process may be there unread. Syncing private contact + /// requests and trying again goes on from where that read stopped. #[error( "a private contact request to {0} is in flight or not yet read back from the store; sync \ private contact requests to settle it" diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs b/packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs index a906663aca0..96567caf00e 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs @@ -689,12 +689,15 @@ impl DashPayView<'_, B> { /// So is one a private request is pending to /// ([`PlatformWalletError::PrivateContactRequestPending`]), until its /// payment fails for good or a private sync reads it back: it may already - /// be on chain. The refusal reads what the wallet tracks, so on a restored - /// or second device it holds once the private sync - /// (`sync_private_contact_requests`) has read the store. A pending request - /// is tracked in memory only: after a restart it holds once the host - /// restores it (`PlatformWallet::restore_pending_private_contact_requests`) - /// or that read reads it back. + /// be on chain. Before the pair is claimed, DashPay's private store is + /// read to its end when it was not since load, so a request sent + /// privately in an earlier process or on another device is tracked first; + /// while that read cannot finish the send is refused with + /// [`PlatformWalletError::PrivateContactRequestPending`], and on a network + /// that serves no private documents yet nothing is read. A request whose + /// payment is unconfirmed since before a restart holds the pair from the + /// moment the wallet binds to its shielded coordinator + /// (`PlatformWallet::restore_private_contact_payments`). #[allow(clippy::type_complexity)] pub async fn send_contact_request_with_external_signer( &self, @@ -749,6 +752,8 @@ impl DashPayView<'_, B> { S: Signer + Send + Sync, C: ContactCryptoProvider + Sync, { + self.read_private_store_before_deciding(sender_identity_id, recipient_identity_id, crypto) + .await?; self.release_rejected_private_requests().await?; let Some(claim) = self .claim_public_pair(sender_identity_id, recipient_identity_id, adopt_reply) @@ -4023,7 +4028,9 @@ impl DashPayView<'_, B> { /// [`PlatformWalletError::PrivateContactRequestNeedsPrivateReply`]: a /// `contactRequest` document in reply would publish the relationship the /// sender kept off chain. It is accepted with - /// `accept_private_contact_request`. + /// `accept_private_contact_request`. The store is read first as the send + /// reads it, so the refusal and the reply it adopts see a private reply + /// sent in an earlier process. pub async fn accept_contact_request_with_external_signer( &self, request: &ContactRequest, @@ -4034,6 +4041,8 @@ impl DashPayView<'_, B> { S: Signer + Send + Sync, C: ContactCryptoProvider + Sync, { + self.read_private_store_before_deciding(&request.recipient_id, &request.sender_id, crypto) + .await?; if self.needs_private_reply(request).await { return Err(PlatformWalletError::PrivateContactRequestNeedsPrivateReply( request.sender_id, diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/private_contact_requests.rs b/packages/rs-platform-wallet/src/wallet/identity/network/private_contact_requests.rs index 973f1d18204..7a7ebdff431 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/private_contact_requests.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/private_contact_requests.rs @@ -198,24 +198,54 @@ impl ScannedIdentity { } } +/// Whether `error` is the SDK's refusal of a private document request on a +/// network whose verified protocol version, or this SDK's pinned one, is +/// below the one that serves them: not a refusal that may lift soon. The +/// private sync sends no other request a protocol version gates. +fn serves_no_private_documents(error: &dash_sdk::Error) -> bool { + matches!(error, dash_sdk::Error::ProtocolVersionNotActive { .. }) + && !error.is_transient_protocol_version_refusal() +} + /// Handed to a private request's `pay`, which marks it right before anything /// it does may reach the network. Until it is marked, a send dropped (a /// cancelled accept, a timeout) releases its reservation, as nothing was paid /// and no sync could settle it; once marked, the reservation stays for a /// sync to settle. A `pay` that awaits anything first, such as resolving its /// payment source, a spend key or a proof, marks it only after those. +/// +/// It also names the request it pays for ([`Self::account_reference`]), so +/// the payment's record can be saved with it and a restart restores the +/// reservation without opening the entry. #[derive(Clone, Debug, Default)] -pub struct PaymentStarted(Arc); +pub struct PaymentStarted { + marked: Arc, + account_reference: Option, +} impl PaymentStarted { /// The payment may reach the network from now on. pub fn mark(&self) { - self.0.store(true, Ordering::Release); + self.marked.store(true, Ordering::Release); } /// Whether [`Self::mark`] was called. pub fn is_marked(&self) -> bool { - self.0.load(Ordering::Acquire) + self.marked.load(Ordering::Acquire) + } + + /// The account reference of the private request this payment is for, + /// when the send handed it over. + pub fn account_reference(&self) -> Option { + self.account_reference + } + + /// The same token, naming the request with `account_reference`. + fn for_request(&self, account_reference: u32) -> Self { + Self { + marked: Arc::clone(&self.marked), + account_reference: Some(account_reference), + } } } @@ -675,6 +705,32 @@ impl DashPayView<'_, B> { )) } + /// [`Self::read_private_store_before_sending`], before a public request + /// or reply from `identity_id` to `recipient` is chosen or sent, so a + /// private request paid for in an earlier process holds the pair here + /// too. A network that serves no private documents yet holds none, so + /// its refusal (a verified protocol version, or this SDK's pinned one, + /// below the one that serves them) reads as nothing to read. A refusal + /// that may lift soon, the network's version not being learnt, still + /// refuses: the store may hold one. + pub(super) async fn read_private_store_before_deciding( + &self, + identity_id: &Identifier, + recipient: &Identifier, + crypto: &C, + ) -> Result<(), PlatformWalletError> + where + C: ContactCryptoProvider + Sync, + { + match self + .read_private_store_before_sending(identity_id, recipient, crypto) + .await + { + Err(PlatformWalletError::Sdk(error)) if serves_no_private_documents(&error) => Ok(()), + read => read, + } + } + /// Rewind the private read position and the read flag of each of the /// wallet's identities whose scanning keys changed since they were set /// (`ManagedIdentity::reconcile_private_scan_keys`), storing the rewind @@ -916,7 +972,11 @@ impl DashPayView<'_, B> { // `pay` marks the payment started right before it may reach the // network: from then on the reservation is the sync's to settle, // whatever happens to this send. - let paid = pay(entry, reservation.payment_started.clone()).await; + let paid = pay( + entry, + reservation.payment_started.for_request(account_reference), + ) + .await; reservation.payment_started.mark(); paid?; @@ -979,6 +1039,8 @@ impl DashPayView<'_, B> { /// since load, so a reply paid for in an earlier process is adopted /// rather than paid for again; while that read cannot finish, the accept /// is refused with [`PlatformWalletError::PrivateContactRequestPending`]. + /// A reply whose payment is unconfirmed since before a restart holds the + /// pair from the moment the wallet binds to its shielded coordinator. pub async fn accept_private_contact_request( &self, request: &ContactRequest, @@ -1009,12 +1071,12 @@ impl DashPayView<'_, B> { /// a host calls: which reply a request takes is the wallet's decision. /// `pay` runs only for a private reply. /// - /// When the request came privately, or a private reply would be chosen, - /// the store is read to its end first if it was not since load, before - /// the reply is chosen: a reply paid for in an earlier process is then - /// adopted, with no payment. While that read cannot finish, the accept is - /// refused with [`PlatformWalletError::PrivateContactRequestPending`]. A - /// public request with no private one tracked reads nothing. + /// The store is read to its end first if it was not since load, before + /// the reply is chosen, whatever the request: a reply paid for in an + /// earlier process, to a public request too, is then adopted, with no + /// payment and no public reply. While that read cannot finish, the accept + /// is refused with [`PlatformWalletError::PrivateContactRequestPending`]. + /// On a network that serves no private documents yet, nothing is read. pub async fn accept_contact_request_replying_in_kind( &self, request: &ContactRequest, @@ -1029,14 +1091,8 @@ impl DashPayView<'_, B> { F: FnOnce(BinaryData, PaymentStarted) -> Fut + Send, Fut: Future> + Send, { - if request.came_privately() || self.needs_private_reply(request).await { - self.read_private_store_before_sending( - &request.recipient_id, - &request.sender_id, - crypto, - ) + self.read_private_store_before_deciding(&request.recipient_id, &request.sender_id, crypto) .await?; - } if self.needs_private_reply(request).await { self.accept_private_contact_request(request, shielded_address, signer, crypto, pay) .await @@ -1534,11 +1590,12 @@ impl DashPayView<'_, B> { /// unconfirmed rotation is restored over the request it rotates, and an /// entry read back while its spend still waits for confirmation is not. /// - /// What it does not cover: - /// - A reservation whose payment then fails for good stays until the - /// next restart, as one kept in this process does. - /// - An entry whose request does not decode is left alone: no sync could - /// record it, so its reservation would never settle. + /// An entry whose request does not decode is left alone: no sync could + /// record it, so its reservation would never settle. A record saved with + /// the request it pays for is restored without this call when the wallet + /// binds to its shielded coordinator + /// (`PlatformWallet::restore_private_contact_payments`); this call stays + /// for records saved before that. pub async fn restore_pending_private_contact_requests( &self, entries: &[BinaryData], @@ -1551,6 +1608,47 @@ impl DashPayView<'_, B> { .await } + /// Reserve again, after a restart, the recipients of the private + /// requests whose payment is unconfirmed, from what the payment's record + /// was saved with: (sender, recipient, account reference, the entry's + /// commitment). Nothing is opened, so no keys are needed. A payment whose + /// request the sender's tracked request to that recipient records or + /// supersedes is discharged and left alone, as + /// [`Self::restore_pending_private_contact_requests`] leaves it; a + /// sender that is not one of the wallet's identities is skipped. Returns + /// the (sender, recipient) pairs restored. + #[cfg(feature = "shielded")] + pub(crate) async fn restore_private_payments( + &self, + payments: &[(Identifier, Identifier, u32, [u8; 32])], + ) -> Result, PlatformWalletError> { + if payments.is_empty() { + return Ok(Vec::new()); + } + let mut restored = Vec::new(); + let mut wm = self.wallet_manager.write().await; + let info = wm + .get_wallet_info_mut(&self.wallet_id) + .ok_or_else(|| PlatformWalletError::WalletNotFound(hex::encode(self.wallet_id)))?; + for &(sender, recipient, account_reference, commitment) in payments { + let Some(managed) = info.identity_manager.managed_identity_mut(&sender) else { + continue; + }; + if managed + .dashpay() + .outgoing_request(&recipient) + .is_some_and(|tracked| records_or_supersedes(tracked, account_reference)) + { + continue; + } + managed.restore_private_reply(recipient, commitment); + if !restored.contains(&(sender, recipient)) { + restored.push((sender, recipient)); + } + } + Ok(restored) + } + /// Whether a request from `sender` to `recipient` among `entries`, paid /// for before a restart and unconfirmed, may still execute, neither it nor /// a later request being recorded (a rotation over the tracked request @@ -1949,13 +2047,45 @@ mod tests { known: &[&Identity], entries: Vec>, ) -> (PlatformWalletManager

, Arc) { - let contract = dashpay_contract().expect("bundled DashPay"); + wallet_at( + persistence, + phrase, + own, + known, + entries, + PlatformVersion::latest(), + ) + .await + } + + /// [`wallet_on`], with its SDK pinned to `platform_version` and holding + /// DashPay as that version bundles it, which a DashPay fetch also + /// answers. + async fn wallet_at( + persistence: Arc

, + phrase: &str, + own: &Identity, + known: &[&Identity], + entries: Vec>, + platform_version: &'static PlatformVersion, + ) -> (PlatformWalletManager

, Arc) { + let contract = Arc::new( + dpp::system_data_contracts::load_system_data_contract( + dpp::data_contracts::SystemDataContract::Dashpay, + platform_version, + ) + .expect("bundled DashPay"), + ); let mut sdk = dash_sdk::SdkBuilder::new_mock() .with_network(Network::Testnet) - .with_version(PlatformVersion::latest()) + .with_version(platform_version) .with_context_provider(DashpayContractProvider(Arc::clone(&contract))) .build() .expect("mock sdk"); + sdk.mock() + .expect_fetch(contract.id(), Some((*contract).clone())) + .await + .expect("DashPay expectation"); for identity in known { sdk.mock() .expect_fetch(identity.id(), Some((*identity).clone())) @@ -4386,6 +4516,7 @@ mod tests { vec![[1; 32]], &transition, "private document create from shielded pool", + None, ) .await?; std::future::pending::<()>().await; @@ -4621,6 +4752,409 @@ mod tests { assert!(!pending_to(&restarted, BOB, &alice).await); } + /// Track `request` as an incoming request of `wallet`'s identity + /// `identity_id`, as a restarted wallet restores the public ones. + async fn add_incoming( + wallet: &PlatformWallet, + identity_id: [u8; 32], + request: &ContactRequest, + ) { + let iw = wallet.identity(); + let mut wm = iw.wallet_manager.write().await; + wm.get_wallet_info_mut(&wallet.wallet_id()) + .expect("wallet info") + .identity_manager + .managed_identity_mut(&Identifier::from(identity_id)) + .expect("managed identity") + .add_incoming_contact_request(request.clone(), &iw.persister) + .expect("incoming request"); + } + + /// A private reply to a public request, paid for and not read back, + /// leaves nothing that survives a restart. After one, before any sync, + /// a public send to the contact and the accept a host calls each read + /// the store first and find the reply: the send is refused, as the + /// pair's requests went privately, and the accept adopts the reply. + /// Nothing is broadcast or paid again. + #[tokio::test] + async fn should_not_reply_publicly_after_a_restart_with_a_private_reply_unread() { + let alice = identity(ALICE_PHRASE, ALICE); + let bob = identity(BOB_PHRASE, BOB); + let bob_crypto = SeedCryptoProvider::from_seed(seed(BOB_PHRASE), Network::Testnet); + let bob_signer = signer(BOB_PHRASE, &bob); + let incoming = public_request(&alice, &bob, 3); + let (_before_manager, before) = wallet(BOB_PHRASE, &bob, &[&alice, &bob], vec![]).await; + add_incoming(&before, BOB, &incoming).await; + let reply = Arc::new(Mutex::new(Vec::new())); + let paid = before + .identity() + .dashpay() + .accept_private_contact_request( + &incoming, + None, + &bob_signer, + &bob_crypto, + keep_entry(&reply), + ) + .await; + assert!( + matches!( + paid, + Err(PlatformWalletError::PrivateContactRequestPending(_)) + ), + "paid and confirmed, not read back: {paid:?}" + ); + let reply_entry = reply.lock().expect("reply").remove(0).to_vec(); + + // Restarted with the reply in the store: a public send first. + let (_sending_manager, sending) = + wallet(BOB_PHRASE, &bob, &[&alice, &bob], vec![reply_entry.clone()]).await; + add_incoming(&sending, BOB, &incoming).await; + let public = sending + .identity() + .dashpay() + .send_contact_request_with_external_signer( + &bob.id(), + &alice.id(), + None, + AutoAcceptProofSource::None, + &bob_signer, + &bob_crypto, + ) + .await; + assert!( + matches!( + public, + Err(PlatformWalletError::PrivateContactRequestNeedsPrivateReply(contact)) + if contact == alice.id() + ), + "{public:?}" + ); + + // Restarted again: the accept a host calls first. + let (_accepting_manager, accepting) = + wallet(BOB_PHRASE, &bob, &[&alice, &bob], vec![reply_entry]).await; + add_incoming(&accepting, BOB, &incoming).await; + let established = accepting + .identity() + .dashpay() + .accept_contact_request_replying_in_kind( + &incoming, + None, + &bob_signer, + &bob_crypto, + no_payment, + ) + .await + .expect("the private reply read back is adopted"); + assert_eq!(established.contact_identity_id, alice.id()); + assert_eq!(established.incoming_request, incoming); + assert!(established.outgoing_request.came_privately()); + } + + /// After a restart, before any sync, the accept a host calls reads the + /// store before it chooses the reply: a public request since followed by + /// a private one from the same sender, unread, takes a private reply, + /// where choosing from the public request alone would pick a public one + /// and refuse it once the store is read. + #[tokio::test] + async fn should_reply_privately_in_kind_once_a_restart_reads_a_private_request() { + let alice = identity(ALICE_PHRASE, ALICE); + let bob = identity(BOB_PHRASE, BOB); + let alice_crypto = SeedCryptoProvider::from_seed(seed(ALICE_PHRASE), Network::Testnet); + let bob_crypto = SeedCryptoProvider::from_seed(seed(BOB_PHRASE), Network::Testnet); + let alice_signer = signer(ALICE_PHRASE, &alice); + let bob_signer = signer(BOB_PHRASE, &bob); + let public = public_request(&alice, &bob, 3); + + // Alice's public request, then a private one to Bob. + let (_alice_manager, alice_wallet) = + wallet(ALICE_PHRASE, &alice, &[&alice, &bob], vec![]).await; + { + let iw = alice_wallet.identity(); + let mut wm = iw.wallet_manager.write().await; + wm.get_wallet_info_mut(&alice_wallet.wallet_id()) + .expect("wallet info") + .identity_manager + .managed_identity_mut(&alice.id()) + .expect("Alice") + .add_sent_contact_request(public.clone(), &iw.persister) + .expect("the public request"); + } + let kept = Arc::new(Mutex::new(Vec::new())); + alice_wallet + .identity() + .dashpay() + .send_private_contact_request( + &alice.id(), + &bob.id(), + None, + AutoAcceptProofSource::None, + None, + &alice_signer, + &alice_crypto, + append_entry(&alice_wallet, 0, vec![], &kept), + ) + .await + .expect("Alice sends the private request"); + let private_entry = kept.lock().expect("kept").remove(0).to_vec(); + + // Bob, restarted, holds the public request; the private one is in the + // store, unread. + let (_bob_manager, bob_wallet) = + wallet(BOB_PHRASE, &bob, &[&alice, &bob], vec![private_entry]).await; + add_incoming(&bob_wallet, BOB, &public).await; + let reply = Arc::new(Mutex::new(Vec::new())); + let accepted = bob_wallet + .identity() + .dashpay() + .accept_contact_request_replying_in_kind( + &public, + None, + &bob_signer, + &bob_crypto, + keep_entry(&reply), + ) + .await; + assert!( + matches!( + accepted, + Err(PlatformWalletError::PrivateContactRequestPending(_)) + ), + "a private reply, paid and not read back: {accepted:?}" + ); + assert_eq!(reply.lock().expect("reply").len(), 1, "one reply is paid"); + } + + /// A private reply to a public request whose payment is unconfirmed when + /// the process ends is saved with the request it pays for: binding the + /// restarted wallet to its shielded coordinator reserves the pair again, + /// with no host call and no keys. Before any sync, neither the accept a + /// host calls nor a public send then goes to the contact. + #[cfg(feature = "shielded")] + #[tokio::test] + async fn should_not_reply_publicly_after_a_restart_with_a_private_reply_unconfirmed() { + use crate::wallet::shielded::operations::arm_redrive_before_broadcast; + use crate::wallet::shielded::{ + FileBackedShieldedStore, NetworkShieldedCoordinator, PrivateContactPayment, SubwalletId, + }; + + let alice = identity(ALICE_PHRASE, ALICE); + let bob = identity(BOB_PHRASE, BOB); + let bob_crypto = SeedCryptoProvider::from_seed(seed(BOB_PHRASE), Network::Testnet); + let bob_signer = signer(BOB_PHRASE, &bob); + let incoming = public_request(&alice, &bob, 3); + let dir = tempfile::tempdir().expect("temporary shielded store"); + let db_path = dir.path().join("tree.sqlite"); + let coordinator_at = |db_path: std::path::PathBuf| { + let store = FileBackedShieldedStore::open_path(&db_path, 100).expect("open the store"); + Arc::new(NetworkShieldedCoordinator::new( + Arc::new(dash_sdk::Sdk::new_mock()), + Network::Testnet, + db_path, + store, + )) + }; + + let before_coordinator = coordinator_at(db_path.clone()); + let (_before_manager, before) = wallet(BOB_PHRASE, &bob, &[&alice, &bob], vec![]).await; + add_incoming(&before, BOB, &incoming).await; + let subwallet = SubwalletId::new(before.wallet_id(), 0); + let pay = { + let store = Arc::clone(before_coordinator.store()); + let (sender, recipient) = (bob.id(), alice.id()); + move |entry: BinaryData, started: PaymentStarted| async move { + // As the shielded payment does: marked, the record saved with + // the request it pays for, broadcast; its result unknown. + let transition = private_contact_request_payment(entry); + started.mark(); + let payment = PrivateContactPayment { + sender: sender.to_buffer(), + recipient: recipient.to_buffer(), + account_reference: started + .account_reference() + .expect("the send names its request"), + }; + arm_redrive_before_broadcast( + &store, + subwallet, + Some([0x77; 32]), + [7; 32], + vec![[1; 32]], + &transition, + "private document create from shielded pool", + Some(payment), + ) + .await?; + Err::(PlatformWalletError::ShieldedSpendUnconfirmed { + operation: "private document create", + reason: "the result wait timed out".to_string(), + }) + } + }; + let paid = before + .identity() + .dashpay() + .accept_private_contact_request(&incoming, None, &bob_signer, &bob_crypto, pay) + .await; + assert!( + matches!( + paid, + Err(PlatformWalletError::ShieldedSpendUnconfirmed { .. }) + ), + "{paid:?}" + ); + drop(before_coordinator); + + // The restart: the reply is not in the store and the host restores + // nothing; only the bind can. + let after = coordinator_at(db_path); + let (_restarted_manager, restarted) = wallet_on( + RecordingPersistence::attesting(PersistenceCapabilities::SHIELDED_FVK_RESTART), + BOB_PHRASE, + &bob, + &[&alice, &bob], + vec![], + ) + .await; + add_incoming(&restarted, BOB, &incoming).await; + assert!(!pending_to(&restarted, BOB, &alice).await); + restarted + .bind_shielded(&seed(BOB_PHRASE), &[0], &after) + .await + .expect("bound"); + assert!( + pending_to(&restarted, BOB, &alice).await, + "the bind restores the reservation" + ); + + let accepted = restarted + .identity() + .dashpay() + .accept_contact_request_replying_in_kind( + &incoming, + None, + &bob_signer, + &bob_crypto, + no_payment, + ) + .await; + assert!( + matches!( + accepted, + Err(PlatformWalletError::PrivateContactRequestPending(contact)) + if contact == alice.id() + ), + "{accepted:?}" + ); + let public = restarted + .identity() + .dashpay() + .send_contact_request_with_external_signer( + &bob.id(), + &alice.id(), + None, + AutoAcceptProofSource::None, + &bob_signer, + &bob_crypto, + ) + .await; + assert!( + matches!( + public, + Err(PlatformWalletError::PrivateContactRequestPending(contact)) + if contact == alice.id() + ), + "{public:?}" + ); + } + + /// A payment saved with its request is restored only while neither that + /// request nor a later one is recorded: a request read back before the + /// restart discharges it, a rotation past the tracked request does not, + /// and a sender that is not the wallet's identity is skipped. + #[cfg(feature = "shielded")] + #[tokio::test] + async fn should_restore_a_saved_payment_only_while_its_request_is_unrecorded() { + use platform_encryption::ACCOUNT_REFERENCE_VERSION_SHIFT; + + let alice = identity(ALICE_PHRASE, ALICE); + let bob = identity(BOB_PHRASE, BOB); + let (_manager, bob_wallet) = wallet(BOB_PHRASE, &bob, &[&alice, &bob], vec![]).await; + let reference = 5; + let rotated = reference | (1 << ACCOUNT_REFERENCE_VERSION_SHIFT); + { + let iw = bob_wallet.identity(); + let mut wm = iw.wallet_manager.write().await; + wm.get_wallet_info_mut(&bob_wallet.wallet_id()) + .expect("wallet info") + .identity_manager + .managed_identity_mut(&bob.id()) + .expect("Bob") + .add_sent_contact_request(public_request(&bob, &alice, reference), &iw.persister) + .expect("sent request"); + } + let view = bob_wallet.identity().dashpay(); + + let recorded = view + .restore_private_payments(&[ + (bob.id(), alice.id(), reference, [9; 32]), + (alice.id(), bob.id(), reference, [8; 32]), + ]) + .await + .expect("restore"); + assert!(recorded.is_empty(), "{recorded:?}"); + assert!(!pending_to(&bob_wallet, BOB, &alice).await); + + let rotation = view + .restore_private_payments(&[(bob.id(), alice.id(), rotated, [9; 32])]) + .await + .expect("restore"); + assert_eq!(rotation, vec![(bob.id(), alice.id())]); + assert!(pending_to(&bob_wallet, BOB, &alice).await); + } + + /// On a network below protocol version 15 there is no private store to + /// read: a public send goes on to its broadcast, as it did before private + /// contact requests, rather than being refused for a read that cannot + /// run. + #[tokio::test] + async fn should_send_publicly_on_a_network_below_protocol_version_15() { + let alice = identity(ALICE_PHRASE, ALICE); + let bob = identity(BOB_PHRASE, BOB); + let bob_crypto = SeedCryptoProvider::from_seed(seed(BOB_PHRASE), Network::Testnet); + let bob_signer = signer(BOB_PHRASE, &bob); + let (_manager, bob_wallet) = wallet_at( + Arc::new(NoPersistence), + BOB_PHRASE, + &bob, + &[&alice, &bob], + vec![], + PlatformVersion::get(14).expect("protocol version 14"), + ) + .await; + let sent = bob_wallet + .identity() + .dashpay() + .send_contact_request_with_external_signer( + &bob.id(), + &alice.id(), + None, + AutoAcceptProofSource::None, + &bob_signer, + &bob_crypto, + ) + .await; + // Past the read and the claim: the transition is being built, and + // this mock does not answer its nonce fetch. + assert!( + matches!(&sent, Err(PlatformWalletError::Sdk(error)) + if error.to_string().contains("GetIdentityContractNonceRequest")), + "the send goes on to its transition: {sent:?}" + ); + assert!(!store_read(&bob_wallet, BOB).await, "nothing was read"); + } + /// The first private send since load reads the store first. While that /// read cannot finish, here because the recipient of a request the store /// holds from Alice cannot be read, it is refused with nothing sealed or diff --git a/packages/rs-platform-wallet/src/wallet/platform_wallet.rs b/packages/rs-platform-wallet/src/wallet/platform_wallet.rs index 8359e58079f..fc42440aeb1 100644 --- a/packages/rs-platform-wallet/src/wallet/platform_wallet.rs +++ b/packages/rs-platform-wallet/src/wallet/platform_wallet.rs @@ -46,7 +46,7 @@ use dpp::prelude::Identifier; use { super::identity::network::dashpay_contract, super::shielded::operations::private_document_create_from_pool_with_metadata, - super::shielded::store::PendingRedrive, + super::shielded::store::{PendingRedrive, PrivateContactPayment}, super::shielded::{NetworkShieldedCoordinator, OrchardKeySet, ShieldedStore}, crate::{ AutoAcceptProofSource, ContactCryptoProvider, ContactRequest, EstablishedContact, @@ -1016,6 +1016,11 @@ impl PlatformWallet { /// registration (which would leave sync decrypting under one key /// while addresses and spends use another), nor between the /// restore's registration check and its store write. + /// + /// Once installed, the reservations of the private contact requests the + /// store holds unconfirmed payments for are restored into this wallet's + /// identities ([`Self::restore_private_contact_payments`]), with no host + /// call and no crypto provider. #[cfg(feature = "shielded")] async fn install_shielded_views( &self, @@ -1023,6 +1028,37 @@ impl PlatformWallet { coordinator: &Arc, start: crate::changeset::ClientStartState, snapshot_generation: u64, + ) -> Result<(), PlatformWalletError> { + self.install_shielded_views_in_one_transaction( + account_views, + coordinator, + start, + snapshot_generation, + ) + .await?; + // After the install transaction: the restore takes the store and + // wallet locks on its own. + if let Err(error) = self.restore_private_contact_payments(coordinator).await { + tracing::warn!( + wallet_id = %hex::encode(self.wallet_id), + %error, + "private contact request reservations not restored at bind; a private send \ + still checks its own pair before it pays, a public one holds once the host \ + restores them or a private sync reads them back" + ); + } + Ok(()) + } + + /// The body of [`Self::install_shielded_views`]: one coordinator install + /// transaction. + #[cfg(feature = "shielded")] + async fn install_shielded_views_in_one_transaction( + &self, + account_views: std::collections::BTreeMap, + coordinator: &Arc, + start: crate::changeset::ClientStartState, + snapshot_generation: u64, ) -> Result<(), PlatformWalletError> { // Serialize registration with identity-shield proof construction. Otherwise // an account could disappear before that operation arms its durable guard. @@ -1737,7 +1773,11 @@ impl PlatformWallet { /// recipient is sent, privately or publicly. `crypto` opens the entries. /// Returns the (sender, recipient) pairs restored. A private send runs /// the same check right before it pays, so without this call a second - /// private request is still held; this call holds public ones too. See + /// private request is still held; this call holds public ones too. A + /// payment saved with its request needs no call: binding restores it + /// ([`Self::restore_private_contact_payments`]). This call stays for + /// records saved before that, and for an identity added after the bind. + /// See /// [`DashPayView::restore_pending_private_contact_requests`](crate::DashPayView::restore_pending_private_contact_requests) /// for what it does not cover. #[cfg(feature = "shielded")] @@ -1758,6 +1798,51 @@ impl PlatformWallet { .await } + /// Restore the reservations of the private contact requests whose payment + /// from this wallet's notes `coordinator`'s store holds as unconfirmed, + /// from the request each record was saved with (sender, recipient, + /// account reference) and the commitments its transition carries: no + /// keys open anything. Run when the wallet binds to the coordinator, so + /// restored reservations hold every public and private send from then + /// on. Returns the (sender, recipient) pairs restored. A record saved + /// without its request (before it was kept with it) is restored by + /// [`Self::restore_pending_private_contact_requests`] or the payment's + /// own check. An identity added to the wallet after the bind is not + /// covered until the next bind or that call. + #[cfg(feature = "shielded")] + pub async fn restore_private_contact_payments( + &self, + coordinator: &Arc, + ) -> Result, PlatformWalletError> { + let dashpay_id = dashpay_contract()?.id(); + let saved = { + let store = coordinator.store().read().await; + store + .private_contact_redrives_for_wallet(self.wallet_id) + .map_err(PlatformWalletError::from)? + }; + let payments: Vec<(Identifier, Identifier, u32, [u8; 32])> = saved + .iter() + .flat_map(|(redrive, payment)| { + private_contact_request_redrive_entries([redrive], dashpay_id) + .into_iter() + .filter_map(move |entry| { + let commitment: [u8; 32] = entry.as_slice().get(..32)?.try_into().ok()?; + Some(( + Identifier::from(payment.sender), + Identifier::from(payment.recipient), + payment.account_reference, + commitment, + )) + }) + }) + .collect(); + self.identity + .dashpay() + .restore_private_payments(&payments) + .await + } + /// The entries of the private contact requests whose payment from this /// wallet's notes `coordinator`'s store holds as unconfirmed. #[cfg(feature = "shielded")] @@ -1847,6 +1932,16 @@ impl PlatformWallet { )); } let dashpay_id = dashpay_contract()?.id(); + // Saved with the payment's record, so a restart restores the pair's + // reservation without opening the entry. + let private_contact = + started + .account_reference() + .map(|account_reference| PrivateContactPayment { + sender: sender_identity_id.to_buffer(), + recipient: recipient_identity_id.to_buffer(), + account_reference, + }); private_document_create_from_pool_with_metadata( &self.sdk, coordinator.store(), @@ -1857,6 +1952,7 @@ impl PlatformWallet { dashpay_id, PRIVATE_CONTACT_REQUEST_DOCUMENT_TYPE_NAME, vec![entry], + private_contact, &started, &prover, ) diff --git a/packages/rs-platform-wallet/src/wallet/shielded/file_store.rs b/packages/rs-platform-wallet/src/wallet/shielded/file_store.rs index 0970e75d48a..3468d007992 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/file_store.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/file_store.rs @@ -23,8 +23,8 @@ use grovedb_commitment_tree::{ClientPersistentCommitmentTree, Position, Retentio use super::balance::{ShieldedBalanceSource, ShieldedLocalAccountBalance}; use super::store::{ - PendingRedrive, ShieldedNote, ShieldedOutgoingNote, ShieldedStore, StalePendingSpend, - SubwalletId, SubwalletState, + PendingRedrive, PrivateContactPayment, ShieldedNote, ShieldedOutgoingNote, ShieldedStore, + StalePendingSpend, SubwalletId, SubwalletState, }; use crate::error::PlatformWalletError; use crate::wallet::platform_wallet::WalletId; @@ -109,9 +109,58 @@ pub struct FileBackedShieldedStore { /// two-connection setup safe. `Mutex` for the same `Sync`-shim /// reason as `tree`. pending_conn: Mutex, + /// The private contact request each armed record pays for, by subwallet + /// and activity id, as the `private_contact_payment` column keeps it. + /// Read joined with the live records, so an entry whose record was + /// dropped is passed over. + private_contact_payments: BTreeMap<(SubwalletId, [u8; 32]), PrivateContactPayment>, } impl FileBackedShieldedStore { + /// Persist `redrive`, with the private contact request it pays for when + /// it does, in one row write. + fn arm_redrive_paying_for( + &mut self, + id: SubwalletId, + redrive: PendingRedrive, + payment: Option, + ) -> Result<(), FileShieldedStoreError> { + { + let conn = self.pending_conn.lock().expect("pending_conn mutex"); + let nullifier_blob: Vec = redrive.nullifiers.iter().flatten().copied().collect(); + conn.execute( + "INSERT OR REPLACE INTO shielded_pending_spends \ + (wallet_id, account_index, activity_id, anchor, nullifiers, st_bytes, attempts, identity_nonce_finalized, identity_user_abandoned, private_contact_payment) \ + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)", + rusqlite::params![ + id.wallet_id.as_slice(), + id.account_index, + redrive.activity_id.as_slice(), + redrive.anchor.as_slice(), + nullifier_blob, + redrive.st_bytes, + redrive.attempts, + redrive.identity_nonce_finalized, + redrive.identity_user_abandoned, + payment.map(|payment| payment.to_bytes().to_vec()), + ], + ) + .map_err(|e| FileShieldedStoreError::Storage(format!("persist redrive: {e}")))?; + } + match payment { + Some(payment) => { + self.private_contact_payments + .insert((id, redrive.activity_id), payment); + } + None => { + self.private_contact_payments + .remove(&(id, redrive.activity_id)); + } + } + self.subwallets.entry(id).or_default().arm_redrive(redrive); + Ok(()) + } + /// Called under the coordinator's store read guard so amount and provenance /// cannot straddle a sync or a pending-spend reservation. pub(super) fn local_account_balance( @@ -215,12 +264,27 @@ impl FileBackedShieldedStore { .map_err(|e| FileShieldedStoreError::Storage(format!("upgrade pending schema: {e}")))?; } } + // The private contact request a record pays for, when it does. + if !columns + .iter() + .any(|existing| existing == "private_contact_payment") + { + pending_conn + .execute( + "ALTER TABLE shielded_pending_spends ADD COLUMN private_contact_payment BLOB", + [], + ) + .map_err(|e| { + FileShieldedStoreError::Storage(format!("upgrade pending schema: {e}")) + })?; + } let mut store = Self { tree: Mutex::new(tree), path, max_checkpoints, subwallets: BTreeMap::new(), pending_conn: Mutex::new(pending_conn), + private_contact_payments: BTreeMap::new(), }; store.rehydrate_pending_spends()?; Ok(store) @@ -239,7 +303,8 @@ impl FileBackedShieldedStore { let mut stmt = conn .prepare( "SELECT wallet_id, account_index, activity_id, anchor, nullifiers, st_bytes, \ - attempts, identity_nonce_finalized, identity_user_abandoned FROM shielded_pending_spends", + attempts, identity_nonce_finalized, identity_user_abandoned, \ + private_contact_payment FROM shielded_pending_spends", ) .map_err(|e| FileShieldedStoreError::Storage(format!("prepare rehydrate: {e}")))?; let rows = stmt @@ -249,6 +314,12 @@ impl FileBackedShieldedStore { // A malformed st_bytes value remains unclassifiable and must // fail closed. let classifiable_st_bytes = row.get::<_, Vec>(5).ok(); + // A damaged association only loses the restore from it. + let private_contact_payment = row + .get::<_, Option>>(9) + .ok() + .flatten() + .and_then(|bytes| PrivateContactPayment::from_bytes(&bytes)); let decoded: rusqlite::Result<_> = (|| { Ok(( row.get::<_, Vec>(0)?, @@ -262,11 +333,11 @@ impl FileBackedShieldedStore { row.get::<_, i64>(8)?, )) })(); - Ok((classifiable_st_bytes, decoded)) + Ok((classifiable_st_bytes, private_contact_payment, decoded)) }) .map_err(|e| FileShieldedStoreError::Storage(format!("query rehydrate: {e}")))?; for row in rows { - let (classifiable_st_bytes, decoded) = + let (classifiable_st_bytes, private_contact_payment, decoded) = row.map_err(|e| FileShieldedStoreError::RecoveryCorrupted { account_index: None, reason: format!("cannot decode recovery row: {e}"), @@ -371,6 +442,10 @@ impl FileBackedShieldedStore { return Err(error); } }; + if let Some(payment) = private_contact_payment { + self.private_contact_payments + .insert((id, redrive.activity_id), payment); + } let sw = self.subwallets.entry(id).or_default(); for n in &redrive.nullifiers { sw.mark_pending(n); @@ -621,29 +696,31 @@ impl ShieldedStore for FileBackedShieldedStore { } fn arm_redrive(&mut self, id: SubwalletId, redrive: PendingRedrive) -> Result<(), Self::Error> { - { - let conn = self.pending_conn.lock().expect("pending_conn mutex"); - let nullifier_blob: Vec = redrive.nullifiers.iter().flatten().copied().collect(); - conn.execute( - "INSERT OR REPLACE INTO shielded_pending_spends \ - (wallet_id, account_index, activity_id, anchor, nullifiers, st_bytes, attempts, identity_nonce_finalized, identity_user_abandoned) \ - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)", - rusqlite::params![ - id.wallet_id.as_slice(), - id.account_index, - redrive.activity_id.as_slice(), - redrive.anchor.as_slice(), - nullifier_blob, - redrive.st_bytes, - redrive.attempts, - redrive.identity_nonce_finalized, - redrive.identity_user_abandoned, - ], - ) - .map_err(|e| FileShieldedStoreError::Storage(format!("persist redrive: {e}")))?; - } - self.subwallets.entry(id).or_default().arm_redrive(redrive); - Ok(()) + self.arm_redrive_paying_for(id, redrive, None) + } + + fn arm_private_contact_redrive( + &mut self, + id: SubwalletId, + redrive: PendingRedrive, + payment: PrivateContactPayment, + ) -> Result<(), Self::Error> { + self.arm_redrive_paying_for(id, redrive, Some(payment)) + } + + fn private_contact_redrives_for_wallet( + &self, + wallet_id: WalletId, + ) -> Result, Self::Error> { + Ok(self + .pending_redrives_for_wallet(wallet_id)? + .into_iter() + .filter_map(|(id, redrive)| { + self.private_contact_payments + .get(&(id, redrive.activity_id)) + .map(|payment| (redrive, *payment)) + }) + .collect()) } fn pending_redrives(&self, id: SubwalletId) -> Result, Self::Error> { @@ -1083,6 +1160,67 @@ mod tests { let _ = std::fs::remove_file(&path); } + /// The private contact request a record pays for is saved with it: it + /// survives a reopen and an attempt bump, is listed only joined with its + /// live record, and goes with the record. A record armed without one is + /// not listed. + #[test] + fn should_keep_a_private_contact_payment_with_its_record_across_reopen() { + let path = temp_tree_path("private_contact_payment"); + let wallet_id = [0x5A; 32]; + let id = SubwalletId::new(wallet_id, 0); + let record = |activity: u8, nullifier: u8| PendingRedrive { + activity_id: [activity; 32], + anchor: [2u8; 32], + nullifiers: vec![[nullifier; 32]], + st_bytes: vec![0xAB; 96], + attempts: 0, + identity_nonce_finalized: false, + identity_user_abandoned: false, + }; + let payment = PrivateContactPayment { + sender: [0xB1; 32], + recipient: [0xA1; 32], + account_reference: 0x1000_0005, + }; + { + let mut store = FileBackedShieldedStore::open_path(&path, 100).expect("open"); + store + .arm_private_contact_redrive(id, record(1, 3), payment) + .expect("arm the payment"); + store.arm_redrive(id, record(4, 6)).expect("arm a spend"); + store.bump_redrive_attempts(id, &[1u8; 32]).expect("bump"); + } + { + let mut store = FileBackedShieldedStore::open_path(&path, 100).expect("reopen"); + let listed = store + .private_contact_redrives_for_wallet(wallet_id) + .expect("listed"); + assert_eq!(listed.len(), 1, "only the payment: {listed:?}"); + assert_eq!(listed[0].0.activity_id, [1u8; 32]); + assert_eq!(listed[0].0.attempts, 1); + assert_eq!(listed[0].1, payment); + assert!(store + .private_contact_redrives_for_wallet([0x5B; 32]) + .expect("another wallet") + .is_empty()); + assert!(store.clear_pending(id, &[3u8; 32]).expect("clear")); + assert!(store + .private_contact_redrives_for_wallet(wallet_id) + .expect("listed") + .is_empty()); + } + { + let store = FileBackedShieldedStore::open_path(&path, 100).expect("reopen 2"); + assert!(store + .private_contact_redrives_for_wallet(wallet_id) + .expect("listed") + .is_empty()); + assert_eq!(store.pending_redrives(id).expect("redrives").len(), 1); + } + let _ = std::fs::remove_file(&path); + } + #[test] fn identity_debit_guard_with_no_nullifiers_survives_reopen() { let path = temp_tree_path("identity_debit_guard"); diff --git a/packages/rs-platform-wallet/src/wallet/shielded/mod.rs b/packages/rs-platform-wallet/src/wallet/shielded/mod.rs index e5c92c28be6..e73271b4aa2 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/mod.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/mod.rs @@ -70,8 +70,8 @@ pub use keys::{AccountViewingKeys, OrchardKeySet}; pub use prover::CachedOrchardProver; pub use seed_pool::{SeedPoolOutcome, SeedPoolProgress, DEFAULT_SEED_POOL_TARGET_NOTES}; pub use store::{ - IdentityDebitRecoveryRecord, IdentityDebitRecoveryStatus, InMemoryShieldedStore, ShieldedNote, - ShieldedOutgoingNote, ShieldedStore, SubwalletId, + IdentityDebitRecoveryRecord, IdentityDebitRecoveryStatus, InMemoryShieldedStore, + PrivateContactPayment, ShieldedNote, ShieldedOutgoingNote, ShieldedStore, SubwalletId, }; pub use sync::{ShieldedSyncSummary, SyncNotesResult}; diff --git a/packages/rs-platform-wallet/src/wallet/shielded/operations.rs b/packages/rs-platform-wallet/src/wallet/shielded/operations.rs index 6a12ae77ab0..943b3406a03 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/operations.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/operations.rs @@ -28,7 +28,9 @@ use super::note_selection::{ ensure_allowed_denomination, ensure_positive_amount, select_notes_for_denomination, select_notes_with_fee, ShieldedFeeKind, }; -use super::store::{PendingRedrive, ShieldedNote, ShieldedStore, SubwalletId}; +use super::store::{ + PendingRedrive, PrivateContactPayment, ShieldedNote, ShieldedStore, SubwalletId, +}; use crate::broadcast_outcome::{broadcast_definitely_failed, carries_consensus_rejection}; use crate::changeset::{PlatformWalletChangeSet, ShieldedChangeSet}; use crate::error::{preserve_signer_key_unavailable_or, PlatformWalletError}; @@ -1588,6 +1590,7 @@ pub(in crate::wallet) async fn private_document_create_from_pool_with_metadata< data_contract_id: Identifier, document_type_name: &str, entries: Vec, + private_contact: Option, payment_started: &PaymentStarted, prover: &P, ) -> Result { @@ -1675,6 +1678,7 @@ pub(in crate::wallet) async fn private_document_create_from_pool_with_metadata< selected_notes.iter().map(|note| note.nullifier).collect(), &state_transition, "private document create from shielded pool", + private_contact, ) .await?; // Type 29 proves its spent nullifiers as a snapshot, which a competing @@ -3189,9 +3193,11 @@ async fn broadcast_shielded_spend_with_redrive_with_metadata( /// Save the re-drivable record of a spend durably before it is broadcast, /// as identity-funded shields arm theirs: an exit after the node admitted /// the transition, before its result returns, leaves the record. The record -/// is keyed by the spend's `activity_id` and carries its `nullifiers`. A -/// spend whose record cannot be saved is refused before anything is -/// broadcast, as the unknown outcome could then not be recovered. +/// is keyed by the spend's `activity_id` and carries its `nullifiers`, and +/// the private contact request the spend pays for when it does. A spend +/// whose record cannot be saved is refused before anything is broadcast, as +/// the unknown outcome could then not be recovered. +#[allow(clippy::too_many_arguments)] pub(crate) async fn arm_redrive_before_broadcast( store: &Arc>, id: SubwalletId, @@ -3200,6 +3206,7 @@ pub(crate) async fn arm_redrive_before_broadcast( nullifiers: Vec<[u8; 32]>, state_transition: &StateTransition, operation: &'static str, + private_contact: Option, ) -> Result<(), PlatformWalletError> { use dpp::serialization::PlatformSerializable; @@ -3224,7 +3231,12 @@ pub(crate) async fn arm_redrive_before_broadcast( identity_nonce_finalized: false, identity_user_abandoned: false, }; - store.write().await.arm_redrive(id, redrive).map_err(|e| { + let mut store = store.write().await; + match private_contact { + Some(payment) => store.arm_private_contact_redrive(id, redrive, payment), + None => store.arm_redrive(id, redrive), + } + .map_err(|e| { PlatformWalletError::ShieldedStoreError(format!( "{operation}: the record of this spend could not be saved, so it is not broadcast: \ {e}" @@ -4242,6 +4254,7 @@ mod redrive_tests { vec![[1; 32]], &transition, "test spend", + None, ) .await; assert!( @@ -4263,6 +4276,7 @@ mod redrive_tests { vec![[1; 32]], &transition, "test spend", + None, ) .await .expect("saved"); diff --git a/packages/rs-platform-wallet/src/wallet/shielded/store.rs b/packages/rs-platform-wallet/src/wallet/shielded/store.rs index 8efd0acfdcb..524d7098c05 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/store.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/store.rs @@ -173,6 +173,45 @@ pub struct PendingRedrive { pub identity_user_abandoned: bool, } +/// The DashPay private contact request a saved spend pays for: which identity +/// sends it to which, and its account reference. Kept with the spend's +/// record, so a restart restores the pair's reservation from the record +/// without the keys that open its entry. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct PrivateContactPayment { + /// The identity sending the request. + pub sender: [u8; 32], + /// The identity it is to. + pub recipient: [u8; 32], + /// The request's account reference, whose version tells a rotation from + /// the request it rotates. + pub account_reference: u32, +} + +impl PrivateContactPayment { + /// The bytes the file store keeps it as: sender, recipient, then the + /// account reference little-endian. + pub(super) fn to_bytes(self) -> [u8; 68] { + let mut bytes = [0u8; 68]; + bytes[..32].copy_from_slice(&self.sender); + bytes[32..64].copy_from_slice(&self.recipient); + bytes[64..].copy_from_slice(&self.account_reference.to_le_bytes()); + bytes + } + + /// The payment [`Self::to_bytes`] wrote, or `None` for other bytes. + pub(super) fn from_bytes(bytes: &[u8]) -> Option { + if bytes.len() != 68 { + return None; + } + Some(Self { + sender: bytes.get(..32)?.try_into().ok()?, + recipient: bytes.get(32..64)?.try_into().ok()?, + account_reference: u32::from_le_bytes(bytes.get(64..)?.try_into().ok()?), + }) + } +} + /// What the wallet knows about an identity debit's local recovery state. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum IdentityDebitRecoveryStatus { @@ -304,6 +343,27 @@ pub trait ShieldedStore: Send + Sync { /// continue across relaunches. fn arm_redrive(&mut self, id: SubwalletId, redrive: PendingRedrive) -> Result<(), Self::Error>; + /// [`Self::arm_redrive`] for a spend paying for the DashPay private + /// contact request `payment`, kept with the record and dropped with it. + /// A store that keeps nothing across restarts keeps no association. + fn arm_private_contact_redrive( + &mut self, + id: SubwalletId, + redrive: PendingRedrive, + _payment: PrivateContactPayment, + ) -> Result<(), Self::Error> { + self.arm_redrive(id, redrive) + } + + /// The armed records of `wallet_id` that pay for a private contact + /// request, each with the request ([`Self::arm_private_contact_redrive`]). + fn private_contact_redrives_for_wallet( + &self, + _wallet_id: WalletId, + ) -> Result, Self::Error> { + Ok(Vec::new()) + } + /// Every armed redrive record for `id`. fn pending_redrives(&self, id: SubwalletId) -> Result, Self::Error>; From d91c1c6df3922b48ce740cb2f21485512946cf25 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 9 Nov 2026 06:56:43 +0700 Subject: [PATCH 041/117] fix(sdk): name the SDK's protocol version when building a private document send below 15 Below protocol version 15 the limits admit no entries, so the synchronous builder refused every entry as one too many. It cannot ask the network, so it now refuses first, naming the SDK's protocol version and the refresh (`Sdk::refresh_protocol_version`) that lifts the refusal for an unpinned SDK. The async send already learns the network's version before building. Co-Authored-By: Claude Opus 5.5 --- ...vate_document_create_from_shielded_pool.rs | 47 ++++++++++++++++++- 1 file changed, 46 insertions(+), 1 deletion(-) diff --git a/packages/rs-sdk/src/platform/transition/private_document_create_from_shielded_pool.rs b/packages/rs-sdk/src/platform/transition/private_document_create_from_shielded_pool.rs index b8b488d8113..b471d0f2efd 100644 --- a/packages/rs-sdk/src/platform/transition/private_document_create_from_shielded_pool.rs +++ b/packages/rs-sdk/src/platform/transition/private_document_create_from_shielded_pool.rs @@ -23,7 +23,10 @@ use dpp::version::feature_initial_protocol_versions::PRIVATE_DOCUMENT_CREATE_FRO /// `entrySize`, as `platform_encryption::seal_private_document_entry` builds them. #[async_trait::async_trait] pub trait PrivateDocumentCreateFromShieldedPool { - /// Build and structure-check the transition without broadcasting it. + /// Build and structure-check the transition without broadcasting it, at the SDK's protocol + /// version. Below protocol version 15 it is refused, naming the SDK's version: this cannot + /// ask the network, so an unpinned SDK still at an earlier version is refreshed first + /// ([`Sdk::refresh_protocol_version`]), as the send does. #[allow(clippy::too_many_arguments)] fn private_document_create_from_shielded_pool_transition( &self, @@ -61,6 +64,19 @@ impl PrivateDocumentCreateFromShieldedPool for Sdk { fee: u64, bundle: OrchardBundleParams, ) -> Result { + // Below protocol version 15 the limits admit no entries, so every entry would be + // refused as one too many. The send learns the network's version first; this builder + // cannot, so it says which version the SDK runs. + let protocol_version = self.version().protocol_version; + if protocol_version < PRIVATE_DOCUMENT_CREATE_FROM_SHIELDED_POOL_INITIAL_PROTOCOL_VERSION { + return Err(Error::Generic(format!( + "private documents exist from protocol version \ + {PRIVATE_DOCUMENT_CREATE_FROM_SHIELDED_POOL_INITIAL_PROTOCOL_VERSION}; the SDK \ + runs protocol version {protocol_version}: refresh its protocol version \ + (Sdk::refresh_protocol_version) before building the transition, unless it is \ + pinned below that" + ))); + } let OrchardBundleParams { actions, anchor, @@ -189,6 +205,35 @@ mod tests { assert!(error.contains("exists from protocol version 15"), "{error}"); } + /// The builder, which cannot ask the network, refuses below protocol version 15 naming + /// the SDK's version and the refresh that lifts it, whether the SDK is seeded or pinned + /// there. + #[test] + fn should_name_the_sdk_s_version_when_building_below_protocol_version_15() { + let version_14 = PlatformVersion::get(14).expect("protocol version 14"); + for sdk in [ + SdkBuilder::new_mock().with_initial_version(version_14), + SdkBuilder::new_mock().with_version(version_14), + ] { + let sdk: Sdk = sdk.build().expect("expected a mock sdk"); + let error = sdk + .private_document_create_from_shielded_pool_transition( + Identifier::new([4; 32]), + "privateContactRequest".to_string(), + vec![BinaryData::new(vec![1; 589])], + 114_140_000, + bundle(), + ) + .expect_err("protocol version 14") + .to_string(); + assert!( + error.contains("the SDK runs protocol version 14") + && error.contains("refresh_protocol_version"), + "{error}" + ); + } + } + /// An unpinned SDK seeded below protocol version 15 learns the network's version before it /// builds the send, and builds it against that version. #[tokio::test] From 0e3f521a986c90c09826465e64a1fcf5fbf0f7c1 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 9 Nov 2026 06:56:43 +0700 Subject: [PATCH 042/117] fix(wasm-dpp2): refuse tokens set on a data contract that holds none The `tokens` setter dropped a nonempty map on a contract of structure version 0, which holds no tokens. It now refuses it, as the constructor does; an empty map still leaves such a contract as it is. Co-Authored-By: Claude Opus 5.5 --- packages/wasm-dpp2/src/data_contract/model.rs | 11 +++++-- .../tests/unit/TokenConfiguration.spec.ts | 31 +++++++++++++++++++ 2 files changed, 40 insertions(+), 2 deletions(-) diff --git a/packages/wasm-dpp2/src/data_contract/model.rs b/packages/wasm-dpp2/src/data_contract/model.rs index d713e0946c6..f1a60433b38 100644 --- a/packages/wasm-dpp2/src/data_contract/model.rs +++ b/packages/wasm-dpp2/src/data_contract/model.rs @@ -693,8 +693,15 @@ impl DataContractWasm { #[wasm_bindgen(unchecked_param_type = "Record")] tokens: &JsValue, ) -> WasmDppResult<()> { - self.0 - .set_tokens(tokens_configuration_from_js_value(tokens)?); + let tokens = tokens_configuration_from_js_value(tokens)?; + // A contract of structure version 0 holds no tokens: refuse them rather than drop them, + // as the constructor does. An empty map leaves such a contract as it is. + if !tokens.is_empty() && matches!(self.0, DataContract::V0(_)) { + return Err(WasmDppError::invalid_argument( + "a data contract of structure version 0 holds no tokens", + )); + } + self.0.set_tokens(tokens); Ok(()) } diff --git a/packages/wasm-dpp2/tests/unit/TokenConfiguration.spec.ts b/packages/wasm-dpp2/tests/unit/TokenConfiguration.spec.ts index 3a54f6397ab..b04d29fa6dd 100644 --- a/packages/wasm-dpp2/tests/unit/TokenConfiguration.spec.ts +++ b/packages/wasm-dpp2/tests/unit/TokenConfiguration.spec.ts @@ -329,6 +329,37 @@ describe('TokenConfiguration', () => { })).to.throw(/structure version 0/); }); + it('should refuse tokens set on a contract that holds none', () => { + // Protocol version 8 builds data contracts of structure version 0, which have no tokens. + const contract = new wasm.DataContract({ + ownerId, + identityNonce: BigInt(6), + schemas: object.documentSchemas, + definitions: null, + fullValidation: false, + platformVersion: new wasm.PlatformVersion(8), + }); + + expect(() => { + contract.tokens = { 0: createConfiguration() }; + }).to.throw(/structure version 0/); + contract.tokens = {}; + expect(Object.keys(contract.tokens as Record)).to.deep.equal([]); + }); + + it('should set tokens on a contract that holds them', () => { + const contract = new wasm.DataContract({ + ownerId, + identityNonce: BigInt(7), + schemas: object.documentSchemas, + definitions: null, + fullValidation: false, + }); + + contract.tokens = { 0: createConfiguration() }; + expect(Object.keys(contract.tokens as Record)).to.deep.equal(['0']); + }); + it('should refuse a cost in a token the contract does not declare', () => { expect(() => new wasm.DataContract({ ownerId, From 3f546c02a5d8e20f432928a26e0e1b824af59216 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 9 Nov 2026 06:56:44 +0700 Subject: [PATCH 043/117] docs(dpp): say at the batch signing levels why the in-place change keeps every output `combined_security_level_requirement` is a shipped generation edited in place: the comment at the edited line records that `document_signing_levels` returns what the replaced code did for every requirement, which its tests and the batch tests pin. Co-Authored-By: Claude Opus 5.5 --- .../document/batch_transition/methods/v0/mod.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/methods/v0/mod.rs b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/methods/v0/mod.rs index a1175d47da0..8a2848898ff 100644 --- a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/methods/v0/mod.rs +++ b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/methods/v0/mod.rs @@ -164,6 +164,11 @@ pub trait DocumentsBatchTransitionMethodsV0: DocumentsBatchTransitionAccessorsV0 } }; + // Edited in place in a shipped generation: `document_signing_levels` returns what the + // code it replaced did for every requirement (MASTER alone for MASTER, otherwise + // CRITICAL through the requirement, strongest first), without its `unwrap`. Its tests + // and the batch tests pin each output, so no protocol version that signs or checks a + // batch through this method can see another result. Ok(highest_security_level.document_signing_levels()) } From e710dd72ca7f2ef4d92d14d0ec131fe91229a9e0 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 9 Nov 2026 06:56:44 +0700 Subject: [PATCH 044/117] docs(sdk): say what the private contact request parser and signing levels leave to others - `PrivateContactRequest::from_properties` checks the fixed-size fields only; the schema's other bounds (label length, proof size) are checked by dpp's document validation when a request is sealed and read. The doc no longer promises every size check. - `private_contact_request_signing_security_levels` gives `[MASTER]` for a `privateContactRequest` type that is not private and requires MASTER, in a contract parsed without full validation; a private type cannot declare a requirement, so it has HIGH. Co-Authored-By: Claude Opus 5.5 --- .../platform/dashpay/private_contact_request.rs | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/packages/rs-sdk/src/platform/dashpay/private_contact_request.rs b/packages/rs-sdk/src/platform/dashpay/private_contact_request.rs index 0c0a725736f..b7c9364d4d0 100644 --- a/packages/rs-sdk/src/platform/dashpay/private_contact_request.rs +++ b/packages/rs-sdk/src/platform/dashpay/private_contact_request.rs @@ -123,8 +123,12 @@ impl PrivateContactRequest { } /// The request a private contact request's properties hold, refused when one is missing, - /// unknown, of the wrong type or of the wrong size for its field. The schema's other bounds - /// are checked with dpp's document validation, which reading does before this. + /// unknown or of the wrong type, or when the extended public key or the shielded address, + /// both fixed-size fields, is not its size. The schema's other bounds, such as the account + /// label's length and the auto-accept proof's size, are not checked here: dpp's document + /// validation of the type's schema checks them when a request is sealed and when one is + /// read, before this. A caller parsing properties itself validates them the same way first, + /// or gets a request holding what it passed. pub fn from_properties(mut properties: BTreeMap) -> Result { let invalid = |message: String| Error::Generic(format!("private contact request: {message}")); @@ -233,7 +237,11 @@ pub const PRIVATE_CONTACT_REQUEST_SIGNING_KEY_TYPES: [KeyType; 2] = /// The security levels an `AUTHENTICATION` key may sign a private contact request at: the ones /// Platform lets a document of the type be signed at /// ([`SecurityLevel::document_signing_levels`] of the type's requirement), from `CRITICAL` to -/// the requirement (`HIGH` unless the contract sets another). `MASTER` keys sign no documents. +/// the requirement. A private type cannot declare a requirement, so it has the default, `HIGH`, +/// and this is `[CRITICAL, HIGH]`. A `privateContactRequest` type that is not private, in a +/// contract parsed without full validation, may require `MASTER`: then this is `[MASTER]`, as +/// Platform signs such a type's documents with master keys only. Sealing and reading refuse a +/// type that is not private. pub fn private_contact_request_signing_security_levels( dashpay_contract: &DataContract, ) -> Result, Error> { From 454f25c43b3e6ec7b0539cbea08bf52696105527 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 9 Nov 2026 06:56:58 +0700 Subject: [PATCH 045/117] test(sdk): check a recipient key holding another point is refused by the key comparison The mismatched-key case named the recipient's AUTHENTICATION key, which the purpose check refuses first. A case now names an enabled ECDSA ENCRYPTION key, which sealing could encrypt to, holding another point than the one the entry opened with, and asserts the comparison's refusal. Co-Authored-By: Claude Opus 5.5 --- .../dashpay/private_contact_request.rs | 34 +++++++++++++++++-- 1 file changed, 32 insertions(+), 2 deletions(-) diff --git a/packages/rs-sdk/src/platform/dashpay/private_contact_request.rs b/packages/rs-sdk/src/platform/dashpay/private_contact_request.rs index b7c9364d4d0..c2eceeeaa07 100644 --- a/packages/rs-sdk/src/platform/dashpay/private_contact_request.rs +++ b/packages/rs-sdk/src/platform/dashpay/private_contact_request.rs @@ -3020,8 +3020,9 @@ mod tests { ); } - /// The recipient key an entry names must be the one it is encrypted to, and an identity - /// must be given under its own id. + /// The recipient key an entry names must be the one it is encrypted to, whether it is a + /// key sealing could not encrypt to or an eligible one holding another point, and an + /// identity must be given under its own id. #[test] fn should_refuse_a_recipient_key_or_identity_given_under_another_id() { let contract = dashpay(); @@ -3041,6 +3042,35 @@ mod tests { [PrivateContactRequestRejection::RecipientKeyNotUsable(_)] )); + // Registered under an enabled ECDSA ENCRYPTION key, which sealing could encrypt to, + // holding another point than the one it opened with: refused by the comparison. + let recipient = with_key( + recipient_identity(), + key( + 4, + Purpose::ENCRYPTION, + KeyType::ECDSA_SECP256K1, + &secret_key(6), + None, + ), + ); + let misregistered = PrivateDocumentScanKeys::new() + .with_recipient(Identifier::from(RECIPIENT_ID), &[(4, secret_key(2))]); + let rejections = reasons(read( + &contract, + vec![entry.clone()], + &misregistered, + &[sender.clone(), recipient], + )); + assert!( + matches!( + rejections.as_slice(), + [PrivateContactRequestRejection::RecipientKeyNotUsable(problem)] + if problem.contains("is not the key the entry is encrypted to") + ), + "{rejections:?}" + ); + let target = PrivateDocumentTarget { contract_id: contract.id().to_buffer(), document_type_name: PRIVATE_CONTACT_REQUEST_DOCUMENT_TYPE_NAME, From 3b68903e63b785898f3a6ab5241573e564425ece Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 9 Nov 2026 06:56:58 +0700 Subject: [PATCH 046/117] test(sdk): pin a private page stream's order and its cursor under a growing store - Pages fetched at once that complete in reverse order are yielded in store order. - A later page's failure completing before an earlier page's success is yielded after it, and ends the stream. - A store of 30 entries in chunks of 4 that grows to 35 during the walk is read through the last chunk the first page's count reaches (position 31), each position once; a walk from the cursor left reads the rest. Co-Authored-By: Claude Opus 5.5 --- .../src/platform/private_document_scan.rs | 137 ++++++++++++++++++ 1 file changed, 137 insertions(+) diff --git a/packages/rs-sdk/src/platform/private_document_scan.rs b/packages/rs-sdk/src/platform/private_document_scan.rs index acefcde1d4f..58d2495ee40 100644 --- a/packages/rs-sdk/src/platform/private_document_scan.rs +++ b/packages/rs-sdk/src/platform/private_document_scan.rs @@ -1071,6 +1071,143 @@ mod tests { assert!(pages[5].is_err()); } + /// A fetch of Drive's pages of a store of `total` entries in chunks of 4 where the pages + /// from 8, 12 and 16, fetched at once, complete in the reverse order: 16 first, which lets + /// 12 complete, which lets 8. The page from `failing` fails instead of reading. The + /// positions are noted as the fetches complete. + fn completing_backwards( + total: u64, + failing: Option, + completed: &std::sync::Arc>>, + ) -> impl Fn(u64) -> PageFuture + Clone + Send + Sync + 'static { + use std::sync::Arc; + use tokio::sync::Notify; + + let releases = Arc::new([Notify::new(), Notify::new()]); + let completed = Arc::clone(completed); + move |start| { + let releases = Arc::clone(&releases); + let completed = Arc::clone(&completed); + Box::pin(async move { + match start { + 8 => releases[0].notified().await, + 12 => releases[1].notified().await, + _ => {} + } + completed.lock().expect("completed").push(start); + match start { + 16 => releases[1].notify_one(), + 12 => releases[0].notify_one(), + _ => {} + } + if failing == Some(start) { + return Err(Error::Generic(format!("no page from {start}"))); + } + Ok(drive_page(4, total, start)) + }) + } + } + + /// Pages fetched at once that complete out of order are yielded in store order. + #[tokio::test] + async fn should_yield_pages_in_store_order_when_later_ones_complete_first() { + let completed = std::sync::Arc::new(std::sync::Mutex::new(Vec::new())); + let pages = walk(completing_backwards(24, None, &completed), 0, 3).await; + assert_eq!( + completed.lock().expect("completed")[..5], + [0, 4, 16, 12, 8], + "the later pages complete first" + ); + assert_eq!( + pages + .into_iter() + .map(|page| page.expect("every page reads").start_position) + .collect::>(), + vec![0, 4, 8, 12, 16, 20] + ); + } + + /// A later page's failure that completes before an earlier page's success is yielded + /// after it, and ends the stream. + #[tokio::test] + async fn should_yield_a_later_failure_after_the_earlier_success_that_completes_after_it() { + let completed = std::sync::Arc::new(std::sync::Mutex::new(Vec::new())); + let pages = walk(completing_backwards(24, Some(12), &completed), 0, 3).await; + let completed = completed.lock().expect("completed").clone(); + let failed_at = completed.iter().position(|start| *start == 12); + let read_at = completed.iter().position(|start| *start == 8); + assert!( + failed_at < read_at, + "the failure completes first: {completed:?}" + ); + let starts: Vec = pages + .iter() + .take_while(|page| page.is_ok()) + .flatten() + .map(|page| page.start_position) + .collect(); + assert_eq!(starts, vec![0, 4, 8]); + assert_eq!(pages.len(), 4, "the failure, then nothing: {pages:?}"); + assert!(pages[3].is_err()); + } + + /// Entries appended during a walk, here a store of 30 entries in chunks of 4 that grows to + /// 35 once the first two pages are read, are read as far as the last chunk the first + /// page's count reaches, which now holds them to position 31: the positions come once + /// each, in order. A walk from the cursor the pages leave reads the rest. + #[tokio::test] + async fn should_leave_entries_appended_during_a_walk_to_a_walk_from_its_cursor() { + use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering}; + use std::sync::Arc; + + let total = Arc::new(AtomicU64::new(30)); + let fetched = Arc::new(AtomicUsize::new(0)); + let fetch = { + let total = Arc::clone(&total); + move |start: u64| -> PageFuture { + let total = Arc::clone(&total); + let fetched = Arc::clone(&fetched); + Box::pin(async move { + let page = drive_page(4, total.load(Ordering::SeqCst), start); + if fetched.fetch_add(1, Ordering::SeqCst) + 1 == 2 { + total.store(35, Ordering::SeqCst); + } + Ok(page) + }) + } + }; + let positions = |pages: &[Result]| -> Vec { + pages + .iter() + .flat_map(|page| { + page.as_ref() + .expect("every page reads") + .entries + .entries + .iter() + .map(|entry| entry.position) + }) + .collect() + }; + + let first = walk( + fetch.clone(), + 0, + MAX_CONCURRENT_PRIVATE_DOCUMENT_PAGE_FETCHES, + ) + .await; + assert_eq!(positions(&first), (0..32).collect::>()); + let cursor = first + .last() + .and_then(|page| page.as_ref().ok()) + .map(|page| page.start_position + page.entries.entries.len() as u64) + .expect("a last page"); + assert_eq!(cursor, 32); + + let rest = walk(fetch, cursor, MAX_CONCURRENT_PRIVATE_DOCUMENT_PAGE_FETCHES).await; + assert_eq!(positions(&rest), (32..35).collect::>()); + } + /// A page short of its chunk, before the store's end, is an error rather than a gap. #[tokio::test] async fn should_refuse_a_page_short_of_its_chunk() { From 898e8fce81b88fd577fd5927990bd9cf8f58c332 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 9 Nov 2026 06:56:59 +0700 Subject: [PATCH 047/117] fix(sdk): keep the store a private document scan was read from, and refuse another store's `PrivateDocumentScan` now carries the store it was read from (`contract_id`, `document_type_name`), set by `scan_page` from its target, with `is_of_store`. `private_contact_requests_from_scan` refuses a scan of any other store before reading it, even one holding no entry the wallet's keys open: its cursor is that store's, and a wallet persisting it as DashPay's would skip unread requests. Co-Authored-By: Claude Opus 5.5 --- .../dashpay/private_contact_request.rs | 56 ++++++++++++++++++- .../src/platform/private_document_scan.rs | 16 ++++++ 2 files changed, 70 insertions(+), 2 deletions(-) diff --git a/packages/rs-sdk/src/platform/dashpay/private_contact_request.rs b/packages/rs-sdk/src/platform/dashpay/private_contact_request.rs index c2eceeeaa07..c5eb420192d 100644 --- a/packages/rs-sdk/src/platform/dashpay/private_contact_request.rs +++ b/packages/rs-sdk/src/platform/dashpay/private_contact_request.rs @@ -976,8 +976,20 @@ pub fn private_contact_requests_from_scan( identities: &BTreeMap, platform_version: &PlatformVersion, ) -> Result { - // An entry opens only for the store it was sealed for, but a scan of another store's page - // would hold entries of that store: refused, not read as DashPay requests. + // A scan of another store's page is refused whatever it holds: even with no entry the + // wallet's keys open, its cursor is that store's, which must not be kept as DashPay's. + if !scan.is_of_store( + &dashpay_contract.id(), + PRIVATE_CONTACT_REQUEST_DOCUMENT_TYPE_NAME, + ) { + return Err(Error::Generic(format!( + "the scan is of the store {} {}, not DashPay's \ + {PRIVATE_CONTACT_REQUEST_DOCUMENT_TYPE_NAME}", + scan.contract_id, scan.document_type_name + ))); + } + // An entry opens only for the store it was sealed for: one of another store in a DashPay + // scan is refused, not read as a DashPay request. let dashpay_id = dashpay_contract.id().to_buffer(); if let Some(entry) = scan .received @@ -2750,6 +2762,8 @@ mod tests { /// A scan of another store's page is refused, received and sent entries alike, rather /// than read as DashPay requests: here a store of another contract with DashPay's types. + /// So is one holding no entry the wallet's keys open, whose cursor is that store's, of + /// another contract or of another of DashPay's types; DashPay's own empty page reads. #[test] fn should_refuse_a_scan_of_another_store() { let dashpay_contract = dashpay(); @@ -2793,6 +2807,44 @@ mod tests { .to_string(); assert!(error.contains("not DashPay's"), "{case}: {error}"); } + + let dashpay_id = dashpay_contract.id().to_buffer(); + for (contract_id, document_type_name) in [ + ( + other.id().to_buffer(), + PRIVATE_CONTACT_REQUEST_DOCUMENT_TYPE_NAME, + ), + (dashpay_id, "contactInfo"), + ] { + let target = PrivateDocumentTarget { + contract_id, + document_type_name, + }; + let scan = recipient_keys().scan_page(&target, 0, &page(vec![])); + let error = private_contact_requests_from_scan( + &dashpay_contract, + scan, + &BTreeMap::new(), + PlatformVersion::latest(), + ) + .expect_err("another store's cursor") + .to_string(); + assert!( + error.contains("not DashPay's"), + "{document_type_name}: {error}" + ); + } + let own = PrivateDocumentTarget { + contract_id: dashpay_id, + document_type_name: PRIVATE_CONTACT_REQUEST_DOCUMENT_TYPE_NAME, + }; + assert!(private_contact_requests_from_scan( + &dashpay_contract, + recipient_keys().scan_page(&own, 0, &page(vec![])), + &BTreeMap::new(), + PlatformVersion::latest(), + ) + .is_ok()); } /// Two recovered sent requests to the same recipient for the same account reference are diff --git a/packages/rs-sdk/src/platform/private_document_scan.rs b/packages/rs-sdk/src/platform/private_document_scan.rs index 58d2495ee40..861a88319c4 100644 --- a/packages/rs-sdk/src/platform/private_document_scan.rs +++ b/packages/rs-sdk/src/platform/private_document_scan.rs @@ -112,6 +112,13 @@ pub struct RejectedPrivateDocumentEntry { /// What one page of a private document type's store holds for the wallet. #[derive(Clone, Debug, Default, PartialEq, Eq)] pub struct PrivateDocumentScan { + /// The contract of the store the page was read from: the target + /// [`PrivateDocumentScanKeys::scan_page`] was given. A reader of one store refuses a scan + /// of another, whatever entries it holds, so another store's cursor is never taken for its + /// own. + pub contract_id: Identifier, + /// The private document type of that store. + pub document_type_name: String, /// The entries to the wallet's identities, in store order. pub received: Vec, /// The entries the wallet's identities sealed, in store order. @@ -126,6 +133,11 @@ pub struct PrivateDocumentScan { } impl PrivateDocumentScan { + /// Whether the page was read from the store of `document_type_name` of `contract_id`. + pub fn is_of_store(&self, contract_id: &Identifier, document_type_name: &str) -> bool { + self.contract_id == *contract_id && self.document_type_name == document_type_name + } + /// Whether the scan reached the store's last entry. A later scan from /// [`Self::next_position`] reads the entries appended since. pub fn is_complete(&self) -> bool { @@ -229,6 +241,8 @@ impl PrivateDocumentScanKeys { page: &PrivateDocumentEntries, ) -> PrivateDocumentScan { let mut scan = PrivateDocumentScan { + contract_id: Identifier::from(target.contract_id), + document_type_name: target.document_type_name.to_string(), next_position: page .entries .last() @@ -902,6 +916,8 @@ mod tests { assert_eq!( scan, PrivateDocumentScan { + contract_id: Identifier::from(target().contract_id), + document_type_name: target().document_type_name.to_string(), next_position: 7, total_count: 7, ..Default::default() From 91d8782c88b2a73b11919028f7b9acc2c44d51c5 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 9 Nov 2026 07:07:50 +0700 Subject: [PATCH 048/117] fix(sdk): send a private document transition only once the network is verified at 15 `require_protocol_version` refreshes only an SDK whose own version is below 15, so one seeded or pinned at 15 sent a type 29 transition to a network a verified answer showed at 14, which rejected it at broadcast. The send now also asks `ensure_network_serves`, as a read of the store does, and is refused with `ProtocolVersionNotActive` before it is built. `Sdk::network_proven_below(protocol_version)` tells a network a verified answer shows below a version from this SDK's own limit: a refusal's `network` is the lower of the two, so a pin below the version looks the same as a network below it. It asks with the gate's probe, pinned or not, unless a verified answer or a probe hold already shows it. Mock SDKs, whose expectations are not verified answers, take a verified version from a test through `mock_verified_protocol_version` (mocks feature). Co-Authored-By: Claude Opus 5.5 --- ...vate_document_create_from_shielded_pool.rs | 22 +++++- packages/rs-sdk/src/sdk/protocol_gate.rs | 50 +++++++++++++ .../rs-sdk/src/sdk/protocol_gate_tests.rs | 75 +++++++++++++++++++ 3 files changed, 146 insertions(+), 1 deletion(-) diff --git a/packages/rs-sdk/src/platform/transition/private_document_create_from_shielded_pool.rs b/packages/rs-sdk/src/platform/transition/private_document_create_from_shielded_pool.rs index b471d0f2efd..5db22bbf791 100644 --- a/packages/rs-sdk/src/platform/transition/private_document_create_from_shielded_pool.rs +++ b/packages/rs-sdk/src/platform/transition/private_document_create_from_shielded_pool.rs @@ -3,6 +3,7 @@ use super::broadcast::BroadcastStateTransition; use super::put_settings::PutSettings; use super::validation::ensure_valid_state_transition_structure; +use crate::platform::ProtocolRequirement; use crate::{Error, Sdk}; use dpp::platform_value::{BinaryData, Identifier}; use dpp::shielded::OrchardBundleParams; @@ -21,6 +22,9 @@ use dpp::version::feature_initial_protocol_versions::PRIVATE_DOCUMENT_CREATE_FRO /// `dpp::shielded::builder::build_private_document_create_from_shielded_pool_transition`, which /// builds the whole transition from notes). Every entry must be exactly the type's /// `entrySize`, as `platform_encryption::seal_private_document_entry` builds them. +/// The gRPC method a state transition is sent with. +pub(crate) const BROADCAST_STATE_TRANSITION_METHOD: &str = "broadcast_state_transition"; + #[async_trait::async_trait] pub trait PrivateDocumentCreateFromShieldedPool { /// Build and structure-check the transition without broadcasting it, at the SDK's protocol @@ -37,7 +41,10 @@ pub trait PrivateDocumentCreateFromShieldedPool { bundle: OrchardBundleParams, ) -> Result; - /// Build, broadcast, and wait for the proof of the affected state. Returns the + /// Build, broadcast, and wait for the proof of the affected state, once a verified answer + /// shows the network runs protocol version 15: an SDK seeded or pinned there may face a + /// network that does not, which would reject the transition, so the send is refused with + /// [`Error::ProtocolVersionNotActive`] instead, before it is built. Returns the /// `VerifiedShieldedNullifiers` proof result: it proves the bundle's nullifiers spent, which /// is when a wallet marks its notes spent. It does not prove that this transition was the /// spend, nor that its entries were appended: another transition spending the same notes @@ -116,6 +123,19 @@ impl PrivateDocumentCreateFromShieldedPool for Sdk { "private documents", ) .await?; + // The SDK's own version does not show the network's: one seeded or pinned at 15 passes + // the check above on a network still at 14. The transition is sent only once a verified + // answer shows the network serves it, as a read of the store is. + self.ensure_network_serves( + Some(ProtocolRequirement { + protocol_version: + PRIVATE_DOCUMENT_CREATE_FROM_SHIELDED_POOL_INITIAL_PROTOCOL_VERSION, + method: BROADCAST_STATE_TRANSITION_METHOD, + feature: Some("private document create from shielded pool transitions"), + }), + settings.map(|settings| settings.request_settings), + ) + .await?; let state_transition = self.private_document_create_from_shielded_pool_transition( data_contract_id, document_type_name, diff --git a/packages/rs-sdk/src/sdk/protocol_gate.rs b/packages/rs-sdk/src/sdk/protocol_gate.rs index a75597cbc67..bcce03a2fa7 100644 --- a/packages/rs-sdk/src/sdk/protocol_gate.rs +++ b/packages/rs-sdk/src/sdk/protocol_gate.rs @@ -273,6 +273,56 @@ impl Sdk { } } + /// Whether a verified answer shows the network below `protocol_version`. A refusal + /// ([`Error::ProtocolVersionNotActive`]) does not tell on its own: its `network` is the + /// lower of this SDK's version and the network's, so an SDK pinned below the version + /// refuses what the network serves. A caller that takes a refusal as "not there yet" + /// rather than as this SDK's limit checks this first. + /// + /// A version shown below by the last probe's epoch hold ([`ProbeHold`]) is taken as it + /// is. Otherwise the network is asked with the gate's probe, one proved query, pinned SDK + /// or not, and the call fails when the probe does. `false` when the network is shown at + /// or above `protocol_version`, and when nothing can show it: an SDK without a context + /// provider, or a mock SDK, whose expectations are not verified answers, unless a test set + /// one ([`Self::mock_verified_protocol_version`]). + pub async fn network_proven_below(&self, protocol_version: u32) -> Result { + let state = self.gate_state(); + let verified = state.settings.verified_protocol_version; + if verified >= protocol_version { + return Ok(false); + } + if state + .hold + .is_some_and(|hold| hold.covers(protocol_version, verified, state.now_ms)) + { + return Ok(true); + } + if !state.enforced || !state.has_context_provider { + return Ok(verified != 0); + } + self.probe_network_protocol_version(protocol_version, None) + .await?; + let verified = self.query_settings().verified_protocol_version; + Ok(verified != 0 && verified < protocol_version) + } + + /// Take `protocol_version` as the network's verified version, as if a proved answer had + /// shown it, for a mock SDK in a test that needs the network's version known: a mock's + /// expectations are not verified answers. It never lowers the version. A network SDK + /// refuses it with [`Error::Config`]. + #[cfg(feature = "mocks")] + pub fn mock_verified_protocol_version(&self, protocol_version: u32) -> Result<(), Error> { + match self.inner { + SdkInstance::Mock { .. } => { + self.record_verified_protocol_version(protocol_version); + Ok(()) + } + SdkInstance::Dapi { .. } => Err(Error::Config( + "only a mock SDK takes a verified protocol version from a test".to_string(), + )), + } + } + /// Refuse a request that needs `requirement` (see [`Query::protocol_requirement`]), /// unless a verified answer shows the network runs its protocol version and this SDK /// speaks it. When the network may have reached it since, ask it first with one proved diff --git a/packages/rs-sdk/src/sdk/protocol_gate_tests.rs b/packages/rs-sdk/src/sdk/protocol_gate_tests.rs index 09246968eec..b9de81869ea 100644 --- a/packages/rs-sdk/src/sdk/protocol_gate_tests.rs +++ b/packages/rs-sdk/src/sdk/protocol_gate_tests.rs @@ -27,6 +27,7 @@ use crate::platform::data_contracts_latest_versions::{ use crate::platform::documents::document_query::RankingDirection; use crate::platform::identity_keys_remaining_budgets::IdentityKeysRemainingBudgets; use crate::platform::private_document_entries::PrivateDocumentEntries; +use crate::platform::transition::private_document_create_from_shielded_pool::PrivateDocumentCreateFromShieldedPool; use crate::platform::types::epoch::current_epoch_probe_query; use crate::platform::types::identity::{ IdentityRequest, IdentityResponse, NonUniquePublicKeyHashQuery, @@ -77,8 +78,10 @@ use dpp::data_contract::accessors::v0::DataContractV0Getters; use dpp::data_contract::TokenConfiguration; use dpp::document::Document; use dpp::identity::accessors::IdentityGettersV0; +use dpp::platform_value::BinaryData; use dpp::prelude::{CoreBlockHeight, DataContract, Identifier, Identity}; use dpp::serialization::PlatformSerializableWithPlatformVersion; +use dpp::shielded::OrchardBundleParams; use dpp::system_data_contracts::{load_system_data_contract, SystemDataContract}; use dpp::version::v13::PROTOCOL_VERSION_13; use dpp::version::v14::PROTOCOL_VERSION_14; @@ -2384,3 +2387,75 @@ async fn should_ask_the_network_before_sending_or_refusing_yes_no_queries_given_ assert_eq!(sent(&sdk, request).await, 1, "{request:?}"); } } + +/// A refusal does not tell a network below a version from this SDK's own limit, such as a pin +/// below it: `network_proven_below` does. With no verified answer, or one below the version +/// that no probe hold covers, it asks the network, pinned or not, and takes the answer; a +/// version verified at or above is taken without asking; a probe that fails fails the call. +#[tokio::test] +async fn should_tell_a_network_proven_below_a_version_from_a_pin_below_it() { + for (verified, signed, below) in [ + (0, "pv13", true), + (0, "pv14", false), + // Verified below, but no hold shows the network still there: asked again. + (V13, "pv14", false), + ] { + let sdk = mock_sdk(V13, true, verified); + answer_probe(&sdk, signed).await; + let context = format!("verified {verified}, network {signed}"); + assert_eq!( + sdk.network_proven_below(V14).await.expect(&context), + below, + "{context}" + ); + assert_eq!(probes(&sdk).await, 1, "{context}"); + } + + let sdk = mock_sdk(V13, true, V14); + assert!(!sdk.network_proven_below(V14).await.expect("verified at 14")); + assert_eq!(probes(&sdk).await, 0); + + let sdk = mock_sdk(V13, true, 0); + assert!( + sdk.network_proven_below(V14).await.is_err(), + "the probe has no answer" + ); +} + +/// A private document send from an SDK at protocol version 15, seeded or pinned, on a network +/// a verified answer shows at 14 is refused before it is built, rather than broadcast to be +/// rejected. +#[tokio::test] +async fn should_refuse_a_private_document_send_until_the_network_is_verified_at_15() { + for pinned in [false, true] { + let sdk = mock_sdk(PROTOCOL_VERSION_15, pinned, V14); + answer_probe(&sdk, "pv14").await; + let result = sdk + .private_document_create_from_shielded_pool( + Identifier::new([4; 32]), + "privateContactRequest".to_string(), + vec![BinaryData::new(vec![1; 589])], + 114_140_000, + OrchardBundleParams { + actions: vec![], + anchor: [7; 32], + proof: vec![], + binding_signature: [9; 64], + }, + None, + ) + .await; + match result { + Err(Error::ProtocolVersionNotActive { + method: "broadcast_state_transition", + required: PROTOCOL_VERSION_15, + network: V14, + pinned: false, + cause: None, + .. + }) => {} + other => panic!("pinned {pinned}: expected a local refusal, got {other:?}"), + } + assert_eq!(probes(&sdk).await, 1, "pinned {pinned}"); + } +} From 19ab4175d0d8b7187c9f6b24c092adca51b075c5 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 9 Nov 2026 07:07:50 +0700 Subject: [PATCH 049/117] fix(sdk): keep each outgoing viewing key in place as scan senders are added `ScanSender` held its outgoing viewing key inline in the senders' vector, so growing the vector moved the key's bytes and freed the old buffer unwiped. Each key now lives in its own `Box>`, wiped on drop, so growth moves only the pointer; the copy `with_sender` receives is wiped too. A recipient's secret keys were already in one allocation sized once and wiped on drop, which the type's doc now says. Co-Authored-By: Claude Opus 5.5 --- .../src/platform/private_document_scan.rs | 42 ++++++++++++++----- 1 file changed, 31 insertions(+), 11 deletions(-) diff --git a/packages/rs-sdk/src/platform/private_document_scan.rs b/packages/rs-sdk/src/platform/private_document_scan.rs index 861a88319c4..f81f173f1f9 100644 --- a/packages/rs-sdk/src/platform/private_document_scan.rs +++ b/packages/rs-sdk/src/platform/private_document_scan.rs @@ -30,7 +30,7 @@ use platform_encryption::{ OpenedPrivateDocumentEntry, PrivateDocumentEnvelopeError, PrivateDocumentRecipientKeys, PrivateDocumentTarget, }; -use zeroize::Zeroize; +use zeroize::{Zeroize, Zeroizing}; /// One identity of the wallet and the keys entries to it may be encrypted to. struct ScanRecipient { @@ -39,22 +39,23 @@ struct ScanRecipient { keys: PrivateDocumentRecipientKeys, } -/// One identity of the wallet and an outgoing viewing key it seals entries with. +/// One identity of the wallet and an outgoing viewing key it seals entries with. The key lives +/// in an allocation of its own, wiped when dropped: as senders are added, their vector moves +/// the pointer to it, never its bytes, so no copy of the key is left in a freed buffer. struct ScanSender { identity_id: Identifier, key_id: KeyID, - outgoing_viewing_key: [u8; 32], -} - -impl Drop for ScanSender { - fn drop(&mut self) { - self.outgoing_viewing_key.zeroize(); - } + outgoing_viewing_key: Box>, } /// The keys a wallet scans a private document type's store with: for each of its identities, /// the secret keys entries to it may be encrypted to, and the outgoing viewing key it sealed its /// own entries with. +/// +/// No secret moves as identities are added. A recipient's secret keys are in one allocation +/// sized once ([`PrivateDocumentRecipientKeys::new`] collects them from a slice), which the +/// recipients' vector moves by pointer and wipes when dropped; a sender's outgoing viewing key +/// is boxed ([`ScanSender`]). #[derive(Default)] pub struct PrivateDocumentScanKeys { recipients: Vec, @@ -176,13 +177,14 @@ impl PrivateDocumentScanKeys { mut self, identity_id: Identifier, key_id: KeyID, - outgoing_viewing_key: [u8; 32], + mut outgoing_viewing_key: [u8; 32], ) -> Self { self.senders.push(ScanSender { identity_id, key_id, - outgoing_viewing_key, + outgoing_viewing_key: Box::new(Zeroizing::new(outgoing_viewing_key)), }); + outgoing_viewing_key.zeroize(); self } @@ -841,6 +843,24 @@ mod tests { .expect("the second key set opened it and checks it"); } + /// Adding senders, which grows their vector, moves no outgoing viewing key: each stays at + /// the address it was given, so growth leaves no copy behind in a freed buffer. + #[test] + fn should_keep_each_outgoing_viewing_key_in_place_as_senders_are_added() { + let mut keys = + PrivateDocumentScanKeys::new().with_sender(Identifier::from([0xA0; 32]), 1, [0x11; 32]); + let first: *const [u8; 32] = &**keys.senders[0].outgoing_viewing_key; + for index in 0..64u8 { + keys = keys.with_sender(Identifier::from([index; 32]), 1, [index; 32]); + } + assert!(keys.senders.capacity() > 1, "the vector grew"); + assert_eq!( + first, + &**keys.senders[0].outgoing_viewing_key as *const [u8; 32] + ); + assert_eq!(**keys.senders[0].outgoing_viewing_key, [0x11; 32]); + } + /// Two senders added with the same outgoing viewing key: an entry recovers as the sender /// it names, not the first added. #[test] From 59e047ac6208a684381056638bf8a3abf5c6369d Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 9 Nov 2026 07:35:12 +0700 Subject: [PATCH 050/117] fix(sdk): learn a private store's chunk size only from a page that ends at a chunk boundary The page walk took a page from a chunk boundary that stopped short of the first page's total as one whole chunk. A page read from an older snapshot (a node behind, within the SDK's tolerance) may stop at that snapshot's end, partway through a chunk: the walk then set a wrong chunk size and refused a later valid page, ending the stream. Whether a page stopped at a chunk boundary is now decided with its own `total_count`: a page that ended its snapshot sets no size, and the walk reads on one page at a time from where it stopped. Co-Authored-By: Claude Opus 5.5 --- .../src/platform/private_document_scan.rs | 45 +++++++++++++++++-- 1 file changed, 41 insertions(+), 4 deletions(-) diff --git a/packages/rs-sdk/src/platform/private_document_scan.rs b/packages/rs-sdk/src/platform/private_document_scan.rs index f81f173f1f9..c3b3e4435ab 100644 --- a/packages/rs-sdk/src/platform/private_document_scan.rs +++ b/packages/rs-sdk/src/platform/private_document_scan.rs @@ -462,6 +462,11 @@ where let total = total.unwrap_or(entries.total_count); let len = entries.entries.len() as u64; let end = start.saturating_add(len); + // A page read from an older snapshot than the first page's (a node + // behind, within the SDK's tolerance) may end at that snapshot's end, + // partway through a chunk: it sets no chunk size, and the next page + // is read from where it stopped. + let ended_its_snapshot = end >= entries.total_count; let page = PrivateDocumentPage { start_position: start, entries, @@ -478,9 +483,14 @@ where PageWalk::Done, )); } - // A page from a chunk boundary that stops short of the store's end is - // one whole chunk, a power of two, and so is every page after it. - let next = if aligned && len.is_power_of_two() && start % len == 0 { + // A page from a chunk boundary that stops short of the end of the store + // it was read from is one whole chunk, a power of two, and so is every + // page after it. + let next = if aligned + && !ended_its_snapshot + && len.is_power_of_two() + && start % len == 0 + { PageWalk::Concurrent(Box::pin(whole_chunk_pages( fetch, end, @@ -492,7 +502,7 @@ where PageWalk::Sequential { start: end, total: Some(total), - aligned: true, + aligned: !ended_its_snapshot, } }; Some((Ok(page), next)) @@ -555,6 +565,8 @@ impl Sdk { /// learn from the node: so the first page, and the next one from the chunk boundary it /// ends at, are read one at a time. That second page, short of the store's end, is a whole /// chunk, which sets the size of every page after it; those are then fetched at once. A + /// page that ends at the end of the store it was read from, an older snapshot than the + /// first page's, sets no size: the pages after it are read one at a time until one does. A /// store with two chunks or fewer past `start_position` is read one page at a time. /// /// The context provider is given the contract with the type first when it lacks it @@ -1244,6 +1256,31 @@ mod tests { assert_eq!(positions(&rest), (32..35).collect::>()); } + /// A page from an older snapshot than the first page's, here the second page from a node + /// whose store ended partway through its chunk, is no whole chunk: the walk reads on from + /// where it stopped, learns the chunk size from a later page, and yields every page in + /// order. + #[tokio::test] + async fn should_read_on_past_a_page_from_an_older_snapshot_that_ends_mid_chunk() { + let fetch = |start: u64| -> PageFuture { + Box::pin(async move { + // The node answering the page from 4 is behind: its store held 6 entries. + let total = if start == 4 { 6 } else { 30 }; + Ok(drive_page(4, total, start)) + }) + }; + let pages = walk(fetch, 0, MAX_CONCURRENT_PRIVATE_DOCUMENT_PAGE_FETCHES).await; + let mut expected_start = 0; + let mut positions = Vec::new(); + for page in pages { + let page = page.expect("every page reads"); + assert_eq!(page.start_position, expected_start); + expected_start += page.entries.entries.len() as u64; + positions.extend(page.entries.entries.iter().map(|entry| entry.position)); + } + assert_eq!(positions, (0..30).collect::>()); + } + /// A page short of its chunk, before the store's end, is an error rather than a gap. #[tokio::test] async fn should_refuse_a_page_short_of_its_chunk() { From 314d957c78fc402fdbc2e2825604aa80e4a1f337 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 9 Nov 2026 07:35:12 +0700 Subject: [PATCH 051/117] fix(sdk): refuse a private contact request scan of a contract whose type is not private `private_contact_requests_from_scan` read a scan of a contract declaring `privateContactRequest` as an ordinary public type, and an empty one came back with its cursor. It now refuses such a contract first, as sealing does. Co-Authored-By: Claude Opus 5.5 --- .../dashpay/private_contact_request.rs | 57 +++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/packages/rs-sdk/src/platform/dashpay/private_contact_request.rs b/packages/rs-sdk/src/platform/dashpay/private_contact_request.rs index c5eb420192d..06ce9dfe3ee 100644 --- a/packages/rs-sdk/src/platform/dashpay/private_contact_request.rs +++ b/packages/rs-sdk/src/platform/dashpay/private_contact_request.rs @@ -976,6 +976,16 @@ pub fn private_contact_requests_from_scan( identities: &BTreeMap, platform_version: &PlatformVersion, ) -> Result { + // A contract whose type is not private holds no store, as sealing refuses it: an empty + // scan of it is refused too, rather than handing back its cursor. + if private_contact_request_type(dashpay_contract)? + .private_document() + .is_none() + { + return Err(Error::Generic(format!( + "DashPay {PRIVATE_CONTACT_REQUEST_DOCUMENT_TYPE_NAME} is not a private type" + ))); + } // A scan of another store's page is refused whatever it holds: even with no entry the // wallet's keys open, its cursor is that store's, which must not be kept as DashPay's. if !scan.is_of_store( @@ -2847,6 +2857,53 @@ mod tests { .is_ok()); } + /// A contract whose `privateContactRequest` type is an ordinary public type holds no + /// private store: a scan of it is refused, even an empty one, rather than handing back its + /// cursor. + #[test] + fn should_refuse_a_scan_of_a_contract_whose_type_is_not_private() { + use dpp::data_contract::conversion::value::v0::DataContractValueConversionMethodsV0; + use dpp::platform_value::platform_value; + use dpp::platform_value::string_encoding::Encoding; + + let id = Identifier::from([0x5E; 32]); + let public = DataContract::from_value( + platform_value!({ + "$formatVersion": "1", + "id": id.to_string(Encoding::Base58), + "ownerId": Identifier::from([8; 32]).to_string(Encoding::Base58), + "version": 1, + "documentSchemas": { + PRIVATE_CONTACT_REQUEST_DOCUMENT_TYPE_NAME: { + "type": "object", + "properties": { + "accountReference": { + "type": "integer", "minimum": 0, "position": 0 + } + }, + "additionalProperties": false + } + } + }), + true, + PlatformVersion::latest(), + ) + .expect("the contract registers"); + let target = PrivateDocumentTarget { + contract_id: id.to_buffer(), + document_type_name: PRIVATE_CONTACT_REQUEST_DOCUMENT_TYPE_NAME, + }; + let error = private_contact_requests_from_scan( + &public, + recipient_keys().scan_page(&target, 0, &page(vec![])), + &BTreeMap::new(), + PlatformVersion::latest(), + ) + .expect_err("a public type") + .to_string(); + assert!(error.contains("is not a private type"), "{error}"); + } + /// Two recovered sent requests to the same recipient for the same account reference are /// one request: the first is kept. A request the wallet both sent and received is kept on /// both sides. From dcc0b154365091dcdf7095ba2fcaad3218a2161b Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 9 Nov 2026 07:40:35 +0700 Subject: [PATCH 052/117] fix(platform-wallet): mark a private payment started only once its record is saved The type 29 payment marked its `PaymentStarted` before awaiting the store lock that saves its record. A send dropped while that lock was contended then left no record and no broadcast, yet the marked token kept its contact request reserved, with nothing a sync or a redrive could settle. `arm_redrive_before_broadcast` now takes the token and marks it right after the record is saved, under the same lock, with nothing awaited in between. Co-Authored-By: Claude Opus 5.5 --- .../network/private_contact_requests.rs | 10 +-- .../src/wallet/shielded/operations.rs | 90 +++++++++++++++++-- 2 files changed, 90 insertions(+), 10 deletions(-) diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/private_contact_requests.rs b/packages/rs-platform-wallet/src/wallet/identity/network/private_contact_requests.rs index 7a7ebdff431..3910f2b6be6 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/private_contact_requests.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/private_contact_requests.rs @@ -4504,10 +4504,9 @@ mod tests { let pay = { let store = Arc::clone(before.store()); move |entry: BinaryData, started: PaymentStarted| async move { - // As the shielded payment does: marked, the record saved, + // As the shielded payment does: the record saved, marked, // broadcast and admitted; then the result never comes. let transition = private_contact_request_payment(entry); - started.mark(); arm_redrive_before_broadcast( &store, subwallet, @@ -4517,6 +4516,7 @@ mod tests { &transition, "private document create from shielded pool", None, + Some(&started), ) .await?; std::future::pending::<()>().await; @@ -4964,10 +4964,9 @@ mod tests { let store = Arc::clone(before_coordinator.store()); let (sender, recipient) = (bob.id(), alice.id()); move |entry: BinaryData, started: PaymentStarted| async move { - // As the shielded payment does: marked, the record saved with - // the request it pays for, broadcast; its result unknown. + // As the shielded payment does: the record saved with the + // request it pays for, marked, broadcast; its result unknown. let transition = private_contact_request_payment(entry); - started.mark(); let payment = PrivateContactPayment { sender: sender.to_buffer(), recipient: recipient.to_buffer(), @@ -4984,6 +4983,7 @@ mod tests { &transition, "private document create from shielded pool", Some(payment), + Some(&started), ) .await?; Err::(PlatformWalletError::ShieldedSpendUnconfirmed { diff --git a/packages/rs-platform-wallet/src/wallet/shielded/operations.rs b/packages/rs-platform-wallet/src/wallet/shielded/operations.rs index 943b3406a03..1abb47a03dd 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/operations.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/operations.rs @@ -1663,13 +1663,15 @@ pub(in crate::wallet) async fn private_document_create_from_pool_with_metadata< arm_pending_release(store, id, anchor_bytes, &pending_entry, &selected_notes).await; trace!("PrivateDocumentCreateFromShieldedPool: state transition built, broadcasting..."); - // From here the payment may reach the network. - payment_started.mark(); // Saved before the broadcast, not after an unconfirmed wait: a // process that exits once the node admitted the transition, before // the wait returns, still leaves the record a restart restores the // request's reservation from, and the redrive resolves. The record - // is dropped with the notes' reservation on either outcome. + // is dropped with the notes' reservation on either outcome. The + // payment is marked started once the record is saved, with nothing + // awaited in between: a send dropped while the save waits for the + // store has saved and broadcast nothing, and releases its + // reservation. arm_redrive_before_broadcast( store, id, @@ -1679,6 +1681,7 @@ pub(in crate::wallet) async fn private_document_create_from_pool_with_metadata< &state_transition, "private document create from shielded pool", private_contact, + Some(payment_started), ) .await?; // Type 29 proves its spent nullifiers as a snapshot, which a competing @@ -3196,7 +3199,10 @@ async fn broadcast_shielded_spend_with_redrive_with_metadata( /// is keyed by the spend's `activity_id` and carries its `nullifiers`, and /// the private contact request the spend pays for when it does. A spend /// whose record cannot be saved is refused before anything is broadcast, as -/// the unknown outcome could then not be recovered. +/// the unknown outcome could then not be recovered. `payment_started`, when +/// given, is marked right after the record is saved, under the same store +/// lock: dropped while it waits for the lock, the spend leaves neither a +/// record nor a mark. #[allow(clippy::too_many_arguments)] pub(crate) async fn arm_redrive_before_broadcast( store: &Arc>, @@ -3207,6 +3213,7 @@ pub(crate) async fn arm_redrive_before_broadcast( state_transition: &StateTransition, operation: &'static str, private_contact: Option, + payment_started: Option<&PaymentStarted>, ) -> Result<(), PlatformWalletError> { use dpp::serialization::PlatformSerializable; @@ -3241,7 +3248,11 @@ pub(crate) async fn arm_redrive_before_broadcast( "{operation}: the record of this spend could not be saved, so it is not broadcast: \ {e}" )) - }) + })?; + if let Some(payment_started) = payment_started { + payment_started.mark(); + } + Ok(()) } /// Persist the re-drivable record for an ambiguous spend. Best-effort: @@ -4237,6 +4248,73 @@ mod redrive_tests { ); } + /// A private payment is marked started only once its record is saved: + /// dropped while the save waits for the store, the spend has saved + /// nothing and is not marked, so its contact request's reservation is + /// released rather than kept for a payment that never left. Once the + /// lock frees, a save marks it. + #[tokio::test] + async fn should_mark_a_payment_started_only_once_its_record_is_saved() { + use crate::wallet::identity::network::PaymentStarted; + use futures::FutureExt; + + let store = Arc::new(RwLock::new(InMemoryShieldedStore::new())); + let id = SubwalletId::new([0x62; 32], 0); + let transition = identity_shield_transition([0xBB; 32], 4); + let started = PaymentStarted::default(); + + let held = store.write().await; + let save = arm_redrive_before_broadcast( + &store, + id, + Some([0x78; 32]), + [7; 32], + vec![[2; 32]], + &transition, + "test spend", + None, + Some(&started), + ); + let mut save = Box::pin(save); + assert!( + (&mut save).now_or_never().is_none(), + "the save waits for the store" + ); + drop(save); + drop(held); + assert!(!started.is_marked(), "dropped before its record was saved"); + assert!(store + .read() + .await + .pending_redrives(id) + .expect("redrives") + .is_empty()); + + arm_redrive_before_broadcast( + &store, + id, + Some([0x78; 32]), + [7; 32], + vec![[2; 32]], + &transition, + "test spend", + None, + Some(&started), + ) + .await + .expect("saved"); + assert!(started.is_marked()); + assert_eq!( + store + .read() + .await + .pending_redrives(id) + .expect("redrives") + .len(), + 1 + ); + } + /// A spend's record is saved before its broadcast, keyed by its /// activity entry; one with no entry to key it by is refused, nothing /// saved. @@ -4255,6 +4333,7 @@ mod redrive_tests { &transition, "test spend", None, + None, ) .await; assert!( @@ -4277,6 +4356,7 @@ mod redrive_tests { &transition, "test spend", None, + None, ) .await .expect("saved"); From d15c5c924521706875df8fb2b075a71883c50f38 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 9 Nov 2026 07:52:58 +0700 Subject: [PATCH 053/117] fix(platform-wallet): release a dropped spend's notes until the spend owns them Every shielded spend marked its notes pending, then awaited the anchor fetch before anything recorded who frees them: a spend future dropped there skipped the outcome's `cancel_pending`, and the notes stayed excluded from the balance and from selection for the rest of the session, their reservations carrying no anchor for the pruned-anchor release to find. The shared reservation layer (`reserve_notes_covering`, `reserve_unspent_notes`, `reserve_unspent_notes_for_denomination`) now returns a `NoteReservation` guard that releases the notes on drop, at once when the store is free and on a spawned task otherwise. Each spend hands it over once its reservations carry their anchor (`arm_pending_release` now says whether they all do), or once the outcome handling owns them. `ShieldedStore` is `'static` so that release can be spawned; both stores already are. Co-Authored-By: Claude Opus 5.5 --- .../src/wallet/shielded/operations.rs | 366 ++++++++++++++++-- .../src/wallet/shielded/store.rs | 5 +- 2 files changed, 335 insertions(+), 36 deletions(-) diff --git a/packages/rs-platform-wallet/src/wallet/shielded/operations.rs b/packages/rs-platform-wallet/src/wallet/shielded/operations.rs index 1abb47a03dd..8fb9048eeb3 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/operations.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/operations.rs @@ -1407,7 +1407,7 @@ pub(in crate::wallet) async fn identity_top_up_from_pool_with_metadata< let change_addr = default_orchard_address(&views)?; let id = SubwalletId::new(wallet_id, account); - let (selected_notes, total_input, exact_fee) = + let (selected_notes, total_input, exact_fee, mut reservation) = reserve_unspent_notes(sdk, store, id, amount, 2, ShieldedFeeKind::IdentityTopUp).await?; info!( @@ -1463,7 +1463,10 @@ pub(in crate::wallet) async fn identity_top_up_from_pool_with_metadata< }, ) .await; - arm_pending_release(store, id, anchor_bytes, &pending_entry, &selected_notes).await; + if arm_pending_release(store, id, anchor_bytes, &pending_entry, &selected_notes).await { + // Each reservation carries its anchor: a pruned anchor releases it. + reservation.hand_over(); + } trace!("IdentityTopUpFromShieldedPool: state transition built, broadcasting..."); // Type 22 is classified affected state: its nullifiers are shared with any @@ -1486,6 +1489,9 @@ pub(in crate::wallet) async fn identity_top_up_from_pool_with_metadata< .await } .await; + // The outcome below owns the notes from here: it finalizes, keeps or + // cancels their reservation. + reservation.hand_over(); match result { Ok((proof, metadata)) => { @@ -1602,7 +1608,7 @@ pub(in crate::wallet) async fn private_document_create_from_pool_with_metadata< entry_size: entries.first().map_or(0, |entry| entry.len() as u32), }; - let (selected_notes, total_input, exact_fee) = + let (selected_notes, total_input, exact_fee, mut reservation) = reserve_notes_covering(sdk, store, id, 0, 2, fee_kind).await?; info!( @@ -1660,7 +1666,10 @@ pub(in crate::wallet) async fn private_document_create_from_pool_with_metadata< }, ) .await; - arm_pending_release(store, id, anchor_bytes, &pending_entry, &selected_notes).await; + if arm_pending_release(store, id, anchor_bytes, &pending_entry, &selected_notes).await { + // Each reservation carries its anchor: a pruned anchor releases it. + reservation.hand_over(); + } trace!("PrivateDocumentCreateFromShieldedPool: state transition built, broadcasting..."); // Saved before the broadcast, not after an unconfirmed wait: a @@ -1696,6 +1705,9 @@ pub(in crate::wallet) async fn private_document_create_from_pool_with_metadata< .await } .await; + // The outcome below owns the notes from here: it finalizes, keeps or + // cancels their reservation. + reservation.hand_over(); match result { Ok((_proof, metadata)) => { @@ -1778,7 +1790,7 @@ pub async fn unshield( // reserve against `ShieldedFeeKind::Unshield` — reserving the base fee here would under-fund the // address-write cost and the builder would reject the spend (and the `fee_used == exact_fee` // debug assert below would fire). - let (selected_notes, total_input, exact_fee) = + let (selected_notes, total_input, exact_fee, mut reservation) = reserve_unspent_notes(sdk, store, id, amount, 2, ShieldedFeeKind::Unshield).await?; info!( @@ -1850,7 +1862,10 @@ pub async fn unshield( }, ) .await; - arm_pending_release(store, id, anchor_bytes, &pending_entry, &selected_notes).await; + if arm_pending_release(store, id, anchor_bytes, &pending_entry, &selected_notes).await { + // Each reservation carries its anchor: a pruned anchor releases it. + reservation.hand_over(); + } trace!("Unshield: state transition built, broadcasting..."); broadcast_shielded_spend_with_redrive( @@ -1867,6 +1882,9 @@ pub async fn unshield( .await } .await; + // The outcome below owns the notes from here: it finalizes, keeps or + // cancels their reservation. + reservation.hand_over(); match result { Ok(_) => { @@ -1966,7 +1984,7 @@ pub async fn transfer( // ShieldedTransfer is carved with the base `compute_minimum_shielded_fee`, so reserve // against `ShieldedFeeKind::Base`. - let (selected_notes, total_input, exact_fee) = + let (selected_notes, total_input, exact_fee, mut reservation) = reserve_unspent_notes(sdk, store, id, amount, 2, ShieldedFeeKind::Base).await?; info!( @@ -2029,7 +2047,10 @@ pub async fn transfer( }, ) .await; - arm_pending_release(store, id, anchor_bytes, &pending_entry, &selected_notes).await; + if arm_pending_release(store, id, anchor_bytes, &pending_entry, &selected_notes).await { + // Each reservation carries its anchor: a pruned anchor releases it. + reservation.hand_over(); + } trace!("Shielded transfer: state transition built, broadcasting..."); broadcast_shielded_spend_with_redrive( @@ -2046,6 +2067,9 @@ pub async fn transfer( .await } .await; + // The outcome below owns the notes from here: it finalizes, keeps or + // cancels their reservation. + reservation.hand_over(); match result { Ok(_) => { @@ -2128,7 +2152,7 @@ pub async fn withdraw( // `ShieldedFeeKind::Withdrawal` — reserving the base fee here would under-fund the document // cost and the builder would reject the spend (and the `fee_used == exact_fee` debug assert // below would fire). - let (selected_notes, total_input, exact_fee) = + let (selected_notes, total_input, exact_fee, mut reservation) = reserve_unspent_notes(sdk, store, id, amount, 2, ShieldedFeeKind::Withdrawal).await?; info!( @@ -2198,7 +2222,10 @@ pub async fn withdraw( }, ) .await; - arm_pending_release(store, id, anchor_bytes, &pending_entry, &selected_notes).await; + if arm_pending_release(store, id, anchor_bytes, &pending_entry, &selected_notes).await { + // Each reservation carries its anchor: a pruned anchor releases it. + reservation.hand_over(); + } trace!("Shielded withdrawal: state transition built, broadcasting..."); broadcast_shielded_spend_with_redrive( @@ -2215,6 +2242,9 @@ pub async fn withdraw( .await } .await; + // The outcome below owns the notes from here: it finalizes, keeps or + // cancels their reservation. + reservation.hand_over(); match result { Ok(_) => { @@ -2321,7 +2351,7 @@ where // — the fee is metered FROM the denomination at execution). The reservation also gates on // `denomination > predicted_fee` so the new identity can't be created with a non-positive // balance. Orchard's BundleType::DEFAULT pads single-spend bundles to a 2-action floor. - let (selected_notes, total_input, predicted_fee) = + let (selected_notes, total_input, predicted_fee, mut reservation) = reserve_unspent_notes_for_denomination(sdk, store, id, denomination, 2, num_keys).await?; info!( @@ -2404,7 +2434,10 @@ where }, ) .await; - arm_pending_release(store, id, anchor_bytes, &pending_entry, &selected_notes).await; + if arm_pending_release(store, id, anchor_bytes, &pending_entry, &selected_notes).await { + // Each reservation carries its anchor: a pruned anchor releases it. + reservation.hand_over(); + } trace!("IdentityCreateFromShieldedPool: built, broadcasting via SDK..."); // Stage the broadcast and the result-wait SEPARATELY (instead of one `broadcast_and_wait`) @@ -2577,6 +2610,9 @@ where Ok::<(Identifier, Identity), PlatformWalletError>((identity.id(), identity)) } .await; + // The outcome below owns the notes from here: it finalizes, keeps or + // cancels their reservation. + reservation.hand_over(); match result { Ok((identity_id, identity)) => { @@ -2975,7 +3011,9 @@ async fn mark_notes_spent( /// [`ShieldedStore::mark_pending`] for the crash-recovery note. /// Callers must pair this with [`finalize_pending`] (on /// broadcast success) or [`cancel_pending`] (on failure) so the -/// reservation is always released. +/// reservation is always released; until they hand it over +/// ([`NoteReservation::hand_over`]), the returned guard releases it when +/// the spend is dropped. async fn reserve_unspent_notes( sdk: &Arc, store: &Arc>, @@ -2983,7 +3021,7 @@ async fn reserve_unspent_notes( amount: u64, outputs: usize, fee_kind: ShieldedFeeKind, -) -> Result<(Vec, u64, u64), PlatformWalletError> { +) -> Result<(Vec, u64, u64, NoteReservation), PlatformWalletError> { // All amount-based spends reject zero before waiting on a concurrent sync's store lock. ensure_positive_amount(amount)?; reserve_notes_covering(sdk, store, id, amount, outputs, fee_kind).await @@ -2993,7 +3031,8 @@ async fn reserve_unspent_notes( /// `amount` plus the fee of `fee_kind`, which [`reserve_unspent_notes`] does /// for a positive amount. A spend paying only its fee (an /// `PrivateDocumentCreateFromShieldedPool`) reserves with an `amount` of 0. -/// Callers must pair this with [`finalize_pending`] / [`cancel_pending`]. +/// Callers must pair this with [`finalize_pending`] / [`cancel_pending`], and +/// hand the guard over once they own the reservation. async fn reserve_notes_covering( sdk: &Arc, store: &Arc>, @@ -3001,19 +3040,104 @@ async fn reserve_notes_covering( amount: u64, outputs: usize, fee_kind: ShieldedFeeKind, -) -> Result<(Vec, u64, u64), PlatformWalletError> { - let mut store = store.write().await; - let unspent = store +) -> Result<(Vec, u64, u64, NoteReservation), PlatformWalletError> { + let mut guard = store.write().await; + let unspent = guard .get_unspent_notes(id) .map_err(|e| PlatformWalletError::ShieldedStoreError(e.to_string()))?; let (selected, total_input, exact_fee) = select_notes_with_fee(&unspent, amount, outputs, fee_kind, sdk.version())?.into_owned(); - for note in &selected { - store - .mark_pending(id, ¬e.nullifier) - .map_err(|e| PlatformWalletError::ShieldedStoreError(e.to_string()))?; + let reservation = mark_notes_pending(&mut *guard, store, id, &selected)?; + Ok((selected, total_input, exact_fee, reservation)) +} + +/// Mark `notes` pending under the held store lock, returning the guard that +/// releases them unless handed over. A failure part way releases those +/// already marked. +fn mark_notes_pending( + locked: &mut S, + store: &Arc>, + id: SubwalletId, + notes: &[ShieldedNote], +) -> Result, PlatformWalletError> { + let mut marked = Vec::with_capacity(notes.len()); + for note in notes { + if let Err(e) = locked.mark_pending(id, ¬e.nullifier) { + release_reserved_notes(locked, id, &marked); + return Err(PlatformWalletError::ShieldedStoreError(e.to_string())); + } + marked.push(note.nullifier); + } + Ok(NoteReservation { + store: Arc::clone(store), + id, + nullifiers: marked, + handed_over: false, + }) +} + +/// Notes one spend reserved ([`ShieldedStore::mark_pending`]), released when +/// dropped before the spend hands them over ([`Self::hand_over`]): a spend +/// future dropped before its notes have an owner that frees them (an anchor +/// on each reservation, which a pruned anchor releases, or the outcome +/// handling that finalizes or cancels them) leaves them spendable again, as +/// [`cancel_pending`] would, rather than excluded for the rest of the +/// session. The release takes the store lock when it is free, and otherwise +/// runs on a spawned task once it frees; outside a runtime the notes stay +/// reserved until restart. +pub(super) struct NoteReservation { + store: Arc>, + id: SubwalletId, + nullifiers: Vec<[u8; 32]>, + handed_over: bool, +} + +impl NoteReservation { + /// The spend owns the reservation from now on. + pub(super) fn hand_over(&mut self) { + self.handed_over = true; + } +} + +impl Drop for NoteReservation { + fn drop(&mut self) { + if self.handed_over || self.nullifiers.is_empty() { + return; + } + let id = self.id; + let nullifiers = std::mem::take(&mut self.nullifiers); + if let Ok(mut store) = self.store.try_write() { + release_reserved_notes(&mut *store, id, &nullifiers); + return; + } + let Ok(runtime) = tokio::runtime::Handle::try_current() else { + warn!( + "a dropped spend's notes were reserved outside a runtime; they stay until restart" + ); + return; + }; + let store = Arc::clone(&self.store); + runtime.spawn(async move { + let mut store = store.write().await; + release_reserved_notes(&mut *store, id, &nullifiers); + }); + } +} + +/// Clear the reservations of `nullifiers`, as [`cancel_pending`] does. +fn release_reserved_notes( + store: &mut S, + id: SubwalletId, + nullifiers: &[[u8; 32]], +) { + for nullifier in nullifiers { + if let Err(e) = store.clear_pending(id, nullifier) { + warn!( + error = %e, + "releasing a dropped spend's note failed; the next note scan will reconcile" + ); + } } - Ok((selected, total_input, exact_fee)) } /// Exact-equality sibling of [`reserve_unspent_notes`] for @@ -3021,7 +3145,8 @@ async fn reserve_notes_covering( /// (the fee is metered FROM the denomination, not added to the target) in one write-locked /// critical section, gating on `denomination > predicted_fee` via /// [`select_notes_for_denomination`]. Returns the selected notes, total input value, and the -/// predicted fee. Callers must pair this with [`finalize_pending`] / [`cancel_pending`]. +/// predicted fee. Callers must pair this with [`finalize_pending`] / [`cancel_pending`], and +/// hand the guard over once they own the reservation. async fn reserve_unspent_notes_for_denomination( sdk: &Arc, store: &Arc>, @@ -3029,12 +3154,12 @@ async fn reserve_unspent_notes_for_denomination( denomination: u64, min_actions: usize, num_keys: usize, -) -> Result<(Vec, u64, u64), PlatformWalletError> { +) -> Result<(Vec, u64, u64, NoteReservation), PlatformWalletError> { // Reject zero or any other non-member before waiting on a concurrent sync's store lock. ensure_allowed_denomination(denomination, sdk.version())?; - let mut store = store.write().await; - let unspent = store + let mut guard = store.write().await; + let unspent = guard .get_unspent_notes(id) .map_err(|e| PlatformWalletError::ShieldedStoreError(e.to_string()))?; let (selected, total_input, predicted_fee) = select_notes_for_denomination( @@ -3045,12 +3170,8 @@ async fn reserve_unspent_notes_for_denomination( sdk.version(), )? .into_owned(); - for note in &selected { - store - .mark_pending(id, ¬e.nullifier) - .map_err(|e| PlatformWalletError::ShieldedStoreError(e.to_string()))?; - } - Ok((selected, total_input, predicted_fee)) + let reservation = mark_notes_pending(&mut *guard, store, id, &selected)?; + Ok((selected, total_input, predicted_fee, reservation)) } /// Promote a successful broadcast: mark the notes spent (which @@ -3100,19 +3221,24 @@ async fn cancel_pending( /// broadcast. A success (`finalize_pending`) or a definite failure /// (`cancel_pending`) removes the entry, so only an ambiguous /// unconfirmed outcome leaves it carrying the anchor. +/// +/// Returns whether every reservation now carries the anchor, so a pruned +/// anchor releases each. async fn arm_pending_release( store: &Arc>, id: SubwalletId, anchor: [u8; 32], pending_entry: &Option, notes: &[ShieldedNote], -) { +) -> bool { let Some(entry) = pending_entry else { - return; + return false; }; let mut store = store.write().await; + let mut armed = true; for note in notes { if let Err(e) = store.set_pending_spend(id, ¬e.nullifier, anchor, entry.id) { + armed = false; warn!( error = %e, "set_pending_spend failed; this reservation won't self-release on a pruned \ @@ -3120,6 +3246,7 @@ async fn arm_pending_release( ); } } + armed } /// Maximum sync-time re-broadcast attempts for a @@ -4248,6 +4375,175 @@ mod redrive_tests { ); } + /// A reservation dropped while the store is locked is released once the + /// lock frees, on a spawned task; one handed over stays. + #[tokio::test] + async fn should_release_a_dropped_reservation_once_the_store_frees() { + let sdk = Arc::new(dash_sdk::Sdk::new_mock()); + let store = Arc::new(RwLock::new(InMemoryShieldedStore::new())); + let id = SubwalletId::new([0x64; 32], 0); + let note = ShieldedNote { + position: 0, + cmx: [1; 32], + nullifier: [0x5B; 32], + block_height: 1, + is_spent: false, + value: 1_000_000_000_000, + note_data: vec![], + }; + store.write().await.save_note(id, ¬e).expect("a note"); + let unspent = || async { + store + .read() + .await + .get_unspent_notes(id) + .expect("notes") + .len() + }; + + let (_, _, _, reservation) = + reserve_notes_covering(&sdk, &store, id, 1, 2, ShieldedFeeKind::Base) + .await + .expect("reserved"); + assert_eq!(unspent().await, 0); + let held = store.write().await; + drop(reservation); + drop(held); + for _ in 0..100 { + if unspent().await == 1 { + break; + } + tokio::task::yield_now().await; + } + assert_eq!(unspent().await, 1, "released once the lock freed"); + + let (_, _, _, mut kept) = + reserve_notes_covering(&sdk, &store, id, 1, 2, ShieldedFeeKind::Base) + .await + .expect("reserved"); + kept.hand_over(); + drop(kept); + assert_eq!(unspent().await, 0, "the spend owns it"); + } + + /// A spend dropped while it fetches the anchors, before its notes have an + /// owner that frees them, leaves them spendable again: here a private + /// document payment whose anchor fetch goes to a node that accepts the + /// connection and never answers. + #[tokio::test] + async fn should_release_the_notes_of_a_spend_dropped_during_its_anchor_fetch() { + use crate::wallet::identity::network::PaymentStarted; + use dash_sdk::dpp::dashcore::Network; + use dash_sdk::dpp::prelude::CoreBlockHeight; + use dash_sdk::error::ContextProviderError; + use dash_sdk::platform::ContextProvider; + use dash_sdk::sdk::AddressList; + use dpp::data_contract::{DataContract, TokenConfiguration}; + use dpp::version::PlatformVersion; + use grovedb_commitment_tree::ProvingKey; + use std::str::FromStr; + + struct NoKeys; + impl ContextProvider for NoKeys { + fn get_data_contract( + &self, + _id: &Identifier, + _platform_version: &PlatformVersion, + ) -> Result>, ContextProviderError> { + Ok(None) + } + fn get_token_configuration( + &self, + _token_id: &Identifier, + ) -> Result, ContextProviderError> { + Ok(None) + } + fn get_quorum_public_key( + &self, + _quorum_type: u32, + _quorum_hash: [u8; 32], + _core_chain_locked_height: u32, + ) -> Result<[u8; 48], ContextProviderError> { + Err(ContextProviderError::Generic("no quorum keys".to_string())) + } + fn get_platform_activation_height( + &self, + ) -> Result { + Ok(1) + } + } + struct UnreachableProver; + impl OrchardProver for UnreachableProver { + fn proving_key(&self) -> &ProvingKey { + panic!("nothing is proved") + } + } + + // A node that accepts connections and never answers. + let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("a loopback port"); + let address = listener.local_addr().expect("its address"); + std::thread::spawn(move || { + let held: Vec<_> = listener.incoming().collect(); + drop(held); + }); + let sdk = Arc::new( + dash_sdk::SdkBuilder::new( + AddressList::from_str(&format!("http://{address}")).expect("an address"), + ) + .with_network(Network::Testnet) + .with_version(PlatformVersion::latest()) + .with_context_provider(NoKeys) + .build() + .expect("a network SDK"), + ); + + let wallet_id = [0x63; 32]; + let id = SubwalletId::new(wallet_id, 0); + let store = Arc::new(RwLock::new(InMemoryShieldedStore::new())); + let note = ShieldedNote { + position: 0, + cmx: [1; 32], + nullifier: [0x5A; 32], + block_height: 1, + is_spent: false, + value: 1_000_000_000_000, + note_data: vec![], + }; + store.write().await.save_note(id, ¬e).expect("a note"); + let keys = OrchardKeySet::from_seed(&[0x42; 64], Network::Testnet, 0).expect("keys"); + let started = PaymentStarted::default(); + + let paid = tokio::time::timeout( + std::time::Duration::from_millis(500), + private_document_create_from_pool_with_metadata( + &sdk, + &store, + None, + wallet_id, + &keys, + 0, + Identifier::from([0x44; 32]), + "privateContactRequest", + vec![BinaryData::new(vec![1; 589])], + None, + &started, + &UnreachableProver, + ), + ) + .await; + assert!(paid.is_err(), "dropped during the anchor fetch: {paid:?}"); + assert!(!started.is_marked()); + let unspent = store.read().await.get_unspent_notes(id).expect("notes"); + assert_eq!( + unspent + .iter() + .map(|note| note.nullifier) + .collect::>(), + vec![note.nullifier], + "the note is spendable again" + ); + } + /// A private payment is marked started only once its record is saved: /// dropped while the save waits for the store, the spend has saved /// nothing and is not marked, so its contact request's reservation is diff --git a/packages/rs-platform-wallet/src/wallet/shielded/store.rs b/packages/rs-platform-wallet/src/wallet/shielded/store.rs index 524d7098c05..e2a625cc4d5 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/store.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/store.rs @@ -258,7 +258,10 @@ pub(super) struct MarkSpentOutcome { /// /// All mutating methods take `&mut self` so implementations can /// batch writes without interior mutability. -pub trait ShieldedStore: Send + Sync { +/// +/// A store is `'static`: a spend dropped before it hands its notes over +/// releases their reservation on a spawned task when the store is locked. +pub trait ShieldedStore: Send + Sync + 'static { /// The error type returned by storage operations. type Error: StdError + Send + Sync + 'static; From 0dc72a04dd19cbc2f384283eb5429c9ab248dab0 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 9 Nov 2026 07:56:53 +0700 Subject: [PATCH 054/117] fix(platform-wallet): take private payment rejection notices only under the wallet lock `release_rejected_private_requests` took the rejected commitments the coordinator noted, then awaited the wallet-manager lock to apply them. A send or accept dropped while that lock was contended discarded the only set, and the pairs it held stayed reserved until restart. The notices are now taken once the wallet is locked and found, and applied with nothing awaited in between; a release dropped while it waits leaves them for the next call. Co-Authored-By: Claude Opus 5.5 --- .../network/private_contact_requests.rs | 25 ++++++++++++++++--- .../src/wallet/persister.rs | 13 +++++++++- 2 files changed, 34 insertions(+), 4 deletions(-) diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/private_contact_requests.rs b/packages/rs-platform-wallet/src/wallet/identity/network/private_contact_requests.rs index 3910f2b6be6..2c314613e2c 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/private_contact_requests.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/private_contact_requests.rs @@ -772,14 +772,17 @@ impl DashPayView<'_, B> { pub(crate) async fn release_rejected_private_requests( &self, ) -> Result<(), PlatformWalletError> { - let rejected = self.persister.take_rejected_private_entries(); - if rejected.is_empty() { + if !self.persister.has_rejected_private_entries() { return Ok(()); } + // Taken only once the wallet is locked and found, and applied with + // nothing awaited in between: a call dropped while it waits for the + // lock leaves the notices for the next one. let mut wm = self.wallet_manager.write().await; let info = wm .get_wallet_info_mut(&self.wallet_id) .ok_or_else(|| PlatformWalletError::WalletNotFound(hex::encode(self.wallet_id)))?; + let rejected = self.persister.take_rejected_private_entries(); let identity_ids: Vec = info .identity_manager .all_identities() @@ -4360,7 +4363,8 @@ mod tests { /// A private request whose payment was unconfirmed, and which the /// shielded coordinator then settled as never executing (a definitive /// consensus rejection of its saved transition), frees its pair without - /// a restart: the retry pays. + /// a restart: the retry pays, even after a release was dropped while it + /// waited for the wallet lock. #[cfg(feature = "shielded")] #[tokio::test] async fn should_free_a_pair_once_its_unconfirmed_payment_is_rejected_for_good() { @@ -4414,6 +4418,21 @@ mod tests { Some(&alice_wallet.identity().persister), ); + // A release dropped while it waits for the wallet lock keeps the + // notice for the next call. + { + let wm = alice_wallet.identity().wallet_manager.write().await; + let release = view.release_rejected_private_requests(); + assert!( + tokio::time::timeout(Duration::from_millis(50), release) + .await + .is_err(), + "the release waits for the wallet lock" + ); + drop(wm); + } + assert!(pending_to(&alice_wallet, ALICE, &bob).await); + let retry = Arc::new(Mutex::new(Vec::new())); let retried = view .send_private_contact_request( diff --git a/packages/rs-platform-wallet/src/wallet/persister.rs b/packages/rs-platform-wallet/src/wallet/persister.rs index 90025ca7af2..1cb743a34f0 100644 --- a/packages/rs-platform-wallet/src/wallet/persister.rs +++ b/packages/rs-platform-wallet/src/wallet/persister.rs @@ -90,8 +90,19 @@ impl WalletPersister { .extend(commitments); } - /// Take the commitments [`Self::note_rejected_private_entries`] noted + /// Whether [`Self::note_rejected_private_entries`] noted any commitment /// since the last take. + pub(crate) fn has_rejected_private_entries(&self) -> bool { + !self + .rejected_private_entries + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .is_empty() + } + + /// Take the commitments [`Self::note_rejected_private_entries`] noted + /// since the last take. Take them only where they are applied with + /// nothing awaited in between, as nothing keeps a set taken and dropped. pub(crate) fn take_rejected_private_entries(&self) -> BTreeSet<[u8; 32]> { std::mem::take( &mut *self From 757474ba3fac824b20bbd501c98bd71597c55312 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 9 Nov 2026 08:02:31 +0700 Subject: [PATCH 055/117] fix(platform-wallet): skip the private store read only on a network verified below 15 Before a public send or accept, an SDK refusal of the private store read (`ProtocolVersionNotActive`) was taken as "no store to read" whenever it was not transient. An SDK pinned to protocol 14 gives that refusal even when a verified answer shows the network at 15, so a restored wallet could publish a request without finding the private one it had sent. The fallback now asks `Sdk::network_proven_below(required)`: only a verified answer showing the network below the required version reads as nothing to read. Otherwise, or when that cannot be learnt, the refusal comes back as it is and nothing is sent. Co-Authored-By: Claude Opus 5.5 --- .../network/private_contact_requests.rs | 98 +++++++++++++++---- 1 file changed, 79 insertions(+), 19 deletions(-) diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/private_contact_requests.rs b/packages/rs-platform-wallet/src/wallet/identity/network/private_contact_requests.rs index 2c314613e2c..5ee76d1bfcf 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/private_contact_requests.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/private_contact_requests.rs @@ -198,15 +198,6 @@ impl ScannedIdentity { } } -/// Whether `error` is the SDK's refusal of a private document request on a -/// network whose verified protocol version, or this SDK's pinned one, is -/// below the one that serves them: not a refusal that may lift soon. The -/// private sync sends no other request a protocol version gates. -fn serves_no_private_documents(error: &dash_sdk::Error) -> bool { - matches!(error, dash_sdk::Error::ProtocolVersionNotActive { .. }) - && !error.is_transient_protocol_version_refusal() -} - /// Handed to a private request's `pay`, which marks it right before anything /// it does may reach the network. Until it is marked, a send dropped (a /// cancelled accept, a timeout) releases its reservation, as nothing was paid @@ -709,10 +700,12 @@ impl DashPayView<'_, B> { /// or reply from `identity_id` to `recipient` is chosen or sent, so a /// private request paid for in an earlier process holds the pair here /// too. A network that serves no private documents yet holds none, so - /// its refusal (a verified protocol version, or this SDK's pinned one, - /// below the one that serves them) reads as nothing to read. A refusal - /// that may lift soon, the network's version not being learnt, still - /// refuses: the store may hold one. + /// the SDK's refusal of the read reads as nothing to read, but only once + /// a verified answer shows the network below the protocol version that + /// serves them (`Sdk::network_proven_below`): a refusal alone does not + /// say whether the network or this SDK is below it, and an SDK pinned + /// below it refuses a store the network has. Without that evidence the + /// refusal comes back as it is, and nothing is sent. pub(super) async fn read_private_store_before_deciding( &self, identity_id: &Identifier, @@ -726,7 +719,20 @@ impl DashPayView<'_, B> { .read_private_store_before_sending(identity_id, recipient, crypto) .await { - Err(PlatformWalletError::Sdk(error)) if serves_no_private_documents(&error) => Ok(()), + Err(PlatformWalletError::Sdk( + refusal @ dash_sdk::Error::ProtocolVersionNotActive { required, .. }, + )) => match self.sdk.network_proven_below(required).await { + Ok(true) => Ok(()), + Ok(false) => Err(PlatformWalletError::Sdk(refusal)), + Err(error) => { + tracing::warn!( + %error, + "the network's protocol version could not be verified; the private \ + store is not taken as empty" + ); + Err(PlatformWalletError::Sdk(refusal)) + } + }, read => read, } } @@ -2057,13 +2063,15 @@ mod tests { known, entries, PlatformVersion::latest(), + None, ) .await } /// [`wallet_on`], with its SDK pinned to `platform_version` and holding /// DashPay as that version bundles it, which a DashPay fetch also - /// answers. + /// answers; `verified_network` is the network's version a verified + /// answer showed, when one did. async fn wallet_at( persistence: Arc

, phrase: &str, @@ -2071,6 +2079,7 @@ mod tests { known: &[&Identity], entries: Vec>, platform_version: &'static PlatformVersion, + verified_network: Option, ) -> (PlatformWalletManager

, Arc) { let contract = Arc::new( dpp::system_data_contracts::load_system_data_contract( @@ -2085,6 +2094,10 @@ mod tests { .with_context_provider(DashpayContractProvider(Arc::clone(&contract))) .build() .expect("mock sdk"); + if let Some(verified) = verified_network { + sdk.mock_verified_protocol_version(verified) + .expect("a mock SDK"); + } sdk.mock() .expect_fetch(contract.id(), Some((*contract).clone())) .await @@ -5133,10 +5146,10 @@ mod tests { assert!(pending_to(&bob_wallet, BOB, &alice).await); } - /// On a network below protocol version 15 there is no private store to - /// read: a public send goes on to its broadcast, as it did before private - /// contact requests, rather than being refused for a read that cannot - /// run. + /// On a network a verified answer shows below protocol version 15 there + /// is no private store to read: a public send goes on to its broadcast, + /// as it did before private contact requests, rather than being refused + /// for a read that cannot run. #[tokio::test] async fn should_send_publicly_on_a_network_below_protocol_version_15() { let alice = identity(ALICE_PHRASE, ALICE); @@ -5150,6 +5163,7 @@ mod tests { &[&alice, &bob], vec![], PlatformVersion::get(14).expect("protocol version 14"), + Some(14), ) .await; let sent = bob_wallet @@ -5174,6 +5188,52 @@ mod tests { assert!(!store_read(&bob_wallet, BOB).await, "nothing was read"); } + /// An SDK pinned below protocol version 15 refuses to read the private + /// store whatever the network runs. On a network a verified answer shows + /// at 15, or one whose version is not verified, the store may hold a + /// private request, so a public send is refused with that refusal and + /// sends nothing. + #[tokio::test] + async fn should_refuse_a_public_send_from_an_sdk_pinned_below_a_network_at_15() { + let alice = identity(ALICE_PHRASE, ALICE); + let bob = identity(BOB_PHRASE, BOB); + let bob_crypto = SeedCryptoProvider::from_seed(seed(BOB_PHRASE), Network::Testnet); + let bob_signer = signer(BOB_PHRASE, &bob); + for verified_network in [Some(15), None] { + let (_manager, bob_wallet) = wallet_at( + Arc::new(NoPersistence), + BOB_PHRASE, + &bob, + &[&alice, &bob], + vec![], + PlatformVersion::get(14).expect("protocol version 14"), + verified_network, + ) + .await; + let sent = bob_wallet + .identity() + .dashpay() + .send_contact_request_with_external_signer( + &bob.id(), + &alice.id(), + None, + AutoAcceptProofSource::None, + &bob_signer, + &bob_crypto, + ) + .await; + assert!( + matches!( + &sent, + Err(PlatformWalletError::Sdk( + dash_sdk::Error::ProtocolVersionNotActive { .. } + )) + ), + "verified {verified_network:?}: {sent:?}" + ); + } + } + /// The first private send since load reads the store first. While that /// read cannot finish, here because the recipient of a request the store /// holds from Alice cannot be read, it is refused with nothing sealed or From ea2a66b447646f89673884b769d8caf2e9950447 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 9 Nov 2026 08:14:19 +0700 Subject: [PATCH 056/117] feat(platform-wallet-ffi): scan the private contact request store through the resolver signer Public sends and accepts read DashPay's private store first, but the production FFI provider, `ResolverContactCryptoProvider`, implemented no scanning and inherited the unsupported default, so on protocol 15 they failed for every HD identity with DashPay keys. - `MnemonicResolverCoreSigner` gains the private document operations the FFI branch added (67355754ad, 29adfcae9c, 5358ce1bf9), taken verbatim so that branch merges cleanly: `scan_private_document_page` and `seal_private_contact_request`, over a `resolve_master` that resolves the mnemonic once for every key an operation derives. - New `scan_private_document_pages`: a batch of pages with one mnemonic resolve and one scan-key derivation for the batch. - `ResolverContactCryptoProvider` implements `scan_private_document_page`, the batch `scan_private_document_pages`, and `seal_private_contact_request` over them, with the FFI branch's error mapping: a missing mnemonic is the key-unavailable error, never an empty store. Co-Authored-By: Claude Opus 5.5 --- .../rs-platform-wallet-ffi/src/dashpay.rs | 245 ++++- .../src/mnemonic_resolver_core_signer.rs | 897 +++++++++++++++++- 2 files changed, 1118 insertions(+), 24 deletions(-) diff --git a/packages/rs-platform-wallet-ffi/src/dashpay.rs b/packages/rs-platform-wallet-ffi/src/dashpay.rs index 85dde7ca6ea..026002c979d 100644 --- a/packages/rs-platform-wallet-ffi/src/dashpay.rs +++ b/packages/rs-platform-wallet-ffi/src/dashpay.rs @@ -35,8 +35,19 @@ use std::ffi::CStr; use std::os::raw::c_char; -use platform_wallet::ContactRequest; -use rs_sdk_ffi::{MnemonicResolverCoreSigner, MnemonicResolverHandle, SignerHandle, VTableSigner}; +use dash_sdk::platform::dashpay::PrivateContactRequest; +use dash_sdk::platform::private_document_entries::PrivateDocumentEntries; +use dash_sdk::platform::private_document_scan::PrivateDocumentScan; +use dpp::identity::{Identity, KeyID}; +use dpp::platform_value::BinaryData; +use dpp::prelude::{DataContract, Identifier}; +use dpp::version::PlatformVersion; +use dpp::ProtocolError; +use platform_wallet::{ContactRequest, PlatformWalletError, PrivateDocumentKeyPaths}; +use rs_sdk_ffi::{ + MnemonicResolverCoreSigner, MnemonicResolverHandle, MnemonicResolverSignerError, SignerHandle, + VTableSigner, +}; use crate::contact_request::CONTACT_REQUEST_STORAGE; use crate::core_wallet::classify_broadcast_result; @@ -1222,6 +1233,94 @@ impl platform_wallet::ContactCryptoProvider for ResolverContactCryptoProvider { private_data: opened.private_data, }) } + + async fn scan_private_document_page( + &self, + keys: &PrivateDocumentKeyPaths, + data_contract_id: Identifier, + document_type_name: &str, + start_position: u64, + page: &PrivateDocumentEntries, + ) -> Result { + self.signer + .scan_private_document_page( + &keys.recipients, + &keys.senders, + data_contract_id, + document_type_name, + start_position, + page, + ) + .map_err(private_document_signer_error) + } + + async fn scan_private_document_pages( + &self, + keys: &PrivateDocumentKeyPaths, + data_contract_id: Identifier, + document_type_name: &str, + pages: &[(u64, &PrivateDocumentEntries)], + ) -> Result, PlatformWalletError> { + // One Keychain read for the batch, not one per page. + self.signer + .scan_private_document_pages( + &keys.recipients, + &keys.senders, + data_contract_id, + document_type_name, + pages, + ) + .map_err(private_document_signer_error) + } + + async fn seal_private_contact_request( + &self, + sender_encryption_key_path: &key_wallet::bip32::DerivationPath, + dashpay_contract: &DataContract, + sender: &Identity, + sender_encryption_key_id: KeyID, + signing_key_id: KeyID, + signature: &[u8; 65], + recipient: &Identity, + recipient_key_id: KeyID, + request: &PrivateContactRequest, + esk_seed: &[u8; 32], + platform_version: &PlatformVersion, + ) -> Result { + self.signer + .seal_private_contact_request( + sender_encryption_key_path, + dashpay_contract, + sender, + sender_encryption_key_id, + signing_key_id, + signature, + recipient, + recipient_key_id, + request, + esk_seed, + platform_version, + ) + .map_err(private_document_signer_error) + } +} + +/// A signer failure of a private document operation as a wallet error: the +/// SDK's refusal stays typed; a missing mnemonic is the key-unavailable +/// outcome (FFI code 31), carried as the signer's typed completion is, with +/// the reserved marker at position 0 of a `ProtocolError::Generic` payload +/// (`NotFound` renders it there), so the structural discriminator restores +/// it; the rest are reported as the other provider methods report theirs. +fn private_document_signer_error(error: MnemonicResolverSignerError) -> PlatformWalletError { + match error { + MnemonicResolverSignerError::PrivateContactRequest(error) => { + PlatformWalletError::Sdk(error) + } + error @ MnemonicResolverSignerError::NotFound => PlatformWalletError::Sdk( + dash_sdk::Error::Protocol(ProtocolError::Generic(error.to_string())), + ), + error => PlatformWalletError::InvalidIdentityData(error.to_string()), + } } /// Drain the persisted deferred-crypto queue using the Keychain signer for the @@ -1653,6 +1752,148 @@ pub unsafe extern "C" fn platform_wallet_drainable_contact_crypto_count( mod tests { use super::*; + /// A test wallet's mnemonic, counting how often the resolver reads it. + unsafe extern "C" fn counting_resolve( + ctx: *const std::ffi::c_void, + _wallet_id_bytes: *const u8, + out_buf: *mut c_char, + out_capacity: usize, + out_len: *mut usize, + ) -> i32 { + const PHRASE: &str = + "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon \ + abandon about"; + (*(ctx as *const std::sync::atomic::AtomicUsize)) + .fetch_add(1, std::sync::atomic::Ordering::SeqCst); + let phrase = PHRASE.as_bytes(); + if phrase.len() + 1 > out_capacity { + return rs_sdk_ffi::mnemonic_resolver_result::BUFFER_TOO_SMALL; + } + std::ptr::copy_nonoverlapping(phrase.as_ptr() as *const c_char, out_buf, phrase.len()); + *out_buf.add(phrase.len()) = 0; + *out_len = phrase.len(); + rs_sdk_ffi::mnemonic_resolver_result::SUCCESS + } + + unsafe extern "C" fn missing_resolve( + _ctx: *const std::ffi::c_void, + _wallet_id_bytes: *const u8, + _out_buf: *mut c_char, + _out_capacity: usize, + _out_len: *mut usize, + ) -> i32 { + rs_sdk_ffi::mnemonic_resolver_result::NOT_FOUND + } + + unsafe extern "C" fn noop_destroy(_ctx: *mut std::ffi::c_void) {} + + /// A page of entries no key of the wallet opens. + fn store_page(start: u64, len: u64, total_count: u64) -> PrivateDocumentEntries { + PrivateDocumentEntries { + entries: (start..start + len) + .map(|position| { + dash_sdk::platform::private_document_entries::PrivateDocumentEntry { + position, + block_height: position, + block_time_ms: position, + data: vec![position as u8; 589], + } + }) + .collect(), + total_count, + } + } + + /// The resolver-backed provider scans DashPay's private store, which a + /// public send or accept reads first: a page, and a batch of pages with + /// one Keychain read for the batch. A missing mnemonic is the + /// key-unavailable error, never an empty page. + #[test] + fn should_scan_a_page_and_a_batch_with_one_resolve_each() { + use platform_wallet::ContactCryptoProvider; + use std::str::FromStr; + + let path = + |path: &str| key_wallet::bip32::DerivationPath::from_str(path).expect("a valid path"); + let identity_id = Identifier::from([0xB0; 32]); + let keys = PrivateDocumentKeyPaths { + recipients: vec![(identity_id, vec![(2, path("m/9'/1'/5'/0'/0'/1'/2'"))])], + senders: vec![(identity_id, 1, path("m/9'/1'/5'/0'/0'/0'/1'"))], + }; + let contract_id = Identifier::from([0x44; 32]); + let resolves = std::sync::atomic::AtomicUsize::new(0); + let resolver = unsafe { + rs_sdk_ffi::dash_sdk_mnemonic_resolver_create( + &resolves as *const std::sync::atomic::AtomicUsize as *mut std::ffi::c_void, + counting_resolve, + noop_destroy, + ) + }; + let provider = unsafe { + resolver_contact_crypto_provider(resolver, [0u8; 32], key_wallet::Network::Testnet) + }; + let reads = || resolves.load(std::sync::atomic::Ordering::SeqCst); + let runtime = tokio::runtime::Builder::new_current_thread() + .build() + .expect("a runtime"); + + let first = store_page(0, 4, 6); + let page = runtime + .block_on(provider.scan_private_document_page( + &keys, + contract_id, + "privateContactRequest", + 0, + &first, + )) + .expect("the provider scans a page"); + assert_eq!(reads(), 1); + assert!(page.is_of_store(&contract_id, "privateContactRequest")); + assert_eq!(page.next_position, 4); + assert!(page.received.is_empty() && page.sent.is_empty() && page.rejected.is_empty()); + + let second = store_page(4, 2, 6); + let batch = runtime + .block_on(provider.scan_private_document_pages( + &keys, + contract_id, + "privateContactRequest", + &[(0, &first), (4, &second)], + )) + .expect("the provider scans a batch"); + assert_eq!(reads(), 2, "one Keychain read for the batch"); + assert_eq!( + batch + .iter() + .map(|scan| (scan.next_position, scan.is_complete())) + .collect::>(), + vec![(4, false), (6, true)] + ); + unsafe { rs_sdk_ffi::dash_sdk_mnemonic_resolver_destroy(resolver) }; + + let missing = unsafe { + rs_sdk_ffi::dash_sdk_mnemonic_resolver_create( + std::ptr::null_mut(), + missing_resolve, + noop_destroy, + ) + }; + let provider = unsafe { + resolver_contact_crypto_provider(missing, [0u8; 32], key_wallet::Network::Testnet) + }; + let refused = runtime.block_on(provider.scan_private_document_pages( + &keys, + contract_id, + "privateContactRequest", + &[(0, &first)], + )); + assert!( + matches!(refused, Err(PlatformWalletError::Sdk(_))), + "the key is unavailable: {refused:?}" + ); + unsafe { rs_sdk_ffi::dash_sdk_mnemonic_resolver_destroy(missing) }; + } + // Marshalling-boundary coverage for the verify entry point, replacing what // the removed attach FFI's input-validation tests upheld. The crypto // semantics (matching seed binds, wrong seed rejected) are pinned library- diff --git a/packages/rs-sdk-ffi/src/mnemonic_resolver_core_signer.rs b/packages/rs-sdk-ffi/src/mnemonic_resolver_core_signer.rs index 5418123ad43..1a1c7461ce9 100644 --- a/packages/rs-sdk-ffi/src/mnemonic_resolver_core_signer.rs +++ b/packages/rs-sdk-ffi/src/mnemonic_resolver_core_signer.rs @@ -60,7 +60,17 @@ use std::ffi::c_void; use std::os::raw::c_char; use async_trait::async_trait; +use dash_sdk::dpp::data_contract::DataContract; +use dash_sdk::dpp::identity::{Identity, KeyID}; +use dash_sdk::dpp::platform_value::BinaryData; +use dash_sdk::dpp::prelude::Identifier; use dash_sdk::dpp::secp256k1; +use dash_sdk::dpp::version::PlatformVersion; +use dash_sdk::platform::dashpay::{ + seal_private_contact_request, PrivateContactRequest, PrivateContactRequestSender, +}; +use dash_sdk::platform::private_document_entries::PrivateDocumentEntries; +use dash_sdk::platform::private_document_scan::{PrivateDocumentScan, PrivateDocumentScanKeys}; use key_wallet::bip32::{ChildNumber, DerivationPath, ExtendedPrivKey, ExtendedPubKey}; use key_wallet::dashcore::ecdsa::{EcdsaError, EcdsaPublicKey, EcdsaSecretKey, EcdsaSignature}; use key_wallet::dip9::{ @@ -70,6 +80,7 @@ use key_wallet::dip9::{ }; use key_wallet::signer::{ExtendedPubKeySigner, Signer, SignerMethod}; use key_wallet::Network; +use platform_encryption::PrivateDocumentTarget; use thiserror::Error; use zeroize::Zeroizing; @@ -166,6 +177,13 @@ pub enum MnemonicResolverSignerError { /// Decoding the generated recoverable signature failed. #[error("ECDSA signature conversion failed: {0}")] SignatureConversion(#[source] EcdsaError), + + /// The SDK refused to seal a private contact request with the derived + /// key: a malformed request, a key the identity does not hold, a + /// signature that is not the signing key's, or a contract without the + /// private type. + #[error("private contact request: {0}")] + PrivateContactRequest(dash_sdk::Error), } /// `key_wallet::signer::Signer` implementation that derives ECDSA @@ -242,36 +260,36 @@ impl MnemonicResolverCoreSigner { } } - /// Resolve the mnemonic from the Swift-side callback, derive the BIP-32 - /// extended private key at `path`, and hand it *by reference* to - /// `extract`, returning whatever `extract` produces. + /// Resolve the mnemonic from the Swift-side callback, build the BIP-32 + /// master `ExtendedPrivKey`, and hand it *by reference* to `use_master`, + /// returning whatever `use_master` produces. An operation that needs keys + /// at several paths derives them all from this one resolve. /// /// This is the single entry-point for all private-key material in this /// signer. It handles the full stack: resolver FFI call → result-code /// mapping → UTF-8 + word-list validation → BIP-39 seed → master - /// `ExtendedPrivKey` → child `ExtendedPrivKey` at `path`. + /// `ExtendedPrivKey`. /// /// # Zeroization contract /// - /// Both the `master` and `derived` extended keys wipe their secret - /// material when they leave this scope — [`ExtendedPrivKey`] zeroizes on - /// `Drop` and is not `Copy`, so each move is a real move that leaves no - /// bitwise duplicate behind. The - /// key never crosses the call boundary — `extract` only borrows it — so it - /// cannot outlive the derivation. `extract` returns public material - /// (`ExtendedPubKey`) or a `Zeroizing` scalar copy; the caller wipes the - /// latter on its own drop. The mnemonic and seed buffers are plain arrays - /// and ride [`Zeroizing`] wrappers for the same guarantee. + /// The `master` extended key wipes its secret material when it leaves + /// this scope, as does every child `use_master` derives from it: + /// [`ExtendedPrivKey`] zeroizes on `Drop` and is not `Copy`, so each move + /// is a real move that leaves no bitwise duplicate behind. The master key + /// never crosses the call boundary (`use_master` only borrows it), so it + /// cannot outlive the operation. `use_master` returns public material, a + /// product of the keys, or a `Zeroizing` scalar copy the caller wipes on + /// its own drop. The mnemonic and seed buffers are plain arrays and ride + /// [`Zeroizing`] wrappers for the same guarantee. /// /// # Errors /// /// Propagates [`MnemonicResolverSignerError`] for every failure mode: - /// null handle, resolver FFI errors, encoding/parse failures, and BIP-32 - /// derivation errors. - fn resolve_and_derive( + /// null handle, resolver FFI errors, encoding/parse failures, BIP-32 + /// derivation errors, and whatever `use_master` returns. + fn resolve_master( &self, - path: &DerivationPath, - extract: impl FnOnce(&ExtendedPrivKey) -> T, + use_master: impl FnOnce(&ExtendedPrivKey) -> Result, ) -> Result { if self.resolver_addr == 0 { return Err(MnemonicResolverSignerError::NullHandle); @@ -331,11 +349,25 @@ impl MnemonicResolverCoreSigner { let master = ExtendedPrivKey::new_master(self.network, seed.as_ref()) .map_err(|e| MnemonicResolverSignerError::DerivationFailed(format!("master: {e}")))?; - let derived = master - .derive_priv(path) - .map_err(|e| MnemonicResolverSignerError::DerivationFailed(format!("path: {e}")))?; - Ok(extract(&derived)) + use_master(&master) + } + + /// Resolve the mnemonic, derive the BIP-32 extended private key at + /// `path`, and hand it *by reference* to `extract`, returning whatever + /// `extract` produces. The derived key self-wipes on `Drop` when this + /// returns; see [`Self::resolve_master`] for the full contract. + fn resolve_and_derive( + &self, + path: &DerivationPath, + extract: impl FnOnce(&ExtendedPrivKey) -> T, + ) -> Result { + self.resolve_master(|master| { + let derived = master + .derive_priv(path) + .map_err(|e| MnemonicResolverSignerError::DerivationFailed(format!("path: {e}")))?; + Ok(extract(&derived)) + }) } /// Resolve the mnemonic and derive the raw 32-byte scalar at `path`. @@ -583,6 +615,127 @@ impl MnemonicResolverCoreSigner { private_data, }) } + + /// Sort `page`, read from `start_position` of the store of the private + /// document type `document_type_name` of `data_contract_id`, with the + /// wallet's keys ([`PrivateDocumentScanKeys::scan_page`]). `recipients` + /// are the identities entries may be sealed to, each with the paths of + /// its keys by key id; `senders` the identities that may have sealed + /// entries, each with the id and path of an encryption key whose + /// outgoing viewing key recovers what it sent. The mnemonic is resolved + /// once and every key is derived from it here; the SDK builds the scan + /// keys from them ([`PrivateDocumentScanKeys::derive`]) and wipes them. + /// Only the sorted entries return. + pub fn scan_private_document_page( + &self, + recipients: &[(Identifier, Vec<(KeyID, DerivationPath)>)], + senders: &[(Identifier, KeyID, DerivationPath)], + data_contract_id: Identifier, + document_type_name: &str, + start_position: u64, + page: &PrivateDocumentEntries, + ) -> Result { + let target = PrivateDocumentTarget { + contract_id: data_contract_id.to_buffer(), + document_type_name, + }; + self.resolve_master(|master| { + // `secret_key_at` hands back a wiping guard; the copy it yields + // is wiped by `derive` once used. + let keys = PrivateDocumentScanKeys::derive(recipients, senders, |path| { + secret_key_at(master, path).map(|secret| secret.0) + })?; + Ok(keys.scan_page(&target, start_position, page)) + }) + } + + /// [`Self::scan_private_document_page`] for several pages of one store, + /// each with the position it was read from: the mnemonic is resolved once + /// for the batch and the scan keys are derived once, then each page is + /// sorted with them, in order. A wallet scanning many pages at once reads + /// the Keychain once per batch, not once per page. + pub fn scan_private_document_pages( + &self, + recipients: &[(Identifier, Vec<(KeyID, DerivationPath)>)], + senders: &[(Identifier, KeyID, DerivationPath)], + data_contract_id: Identifier, + document_type_name: &str, + pages: &[(u64, &PrivateDocumentEntries)], + ) -> Result, MnemonicResolverSignerError> { + let target = PrivateDocumentTarget { + contract_id: data_contract_id.to_buffer(), + document_type_name, + }; + self.resolve_master(|master| { + // As for one page: `derive` wipes each copy `secret_key_at` + // yields once used. + let keys = PrivateDocumentScanKeys::derive(recipients, senders, |path| { + secret_key_at(master, path).map(|secret| secret.0) + })?; + Ok(pages + .iter() + .map(|(start_position, page)| keys.scan_page(&target, *start_position, page)) + .collect()) + }) + } + + /// Seal `request` from `sender`, whose encryption key + /// `sender_encryption_key_id` is the one at `sender_encryption_key_path`, + /// to the key `recipient_key_id` of `recipient`, with `signature`, the + /// signature of the request by the sender's key `signing_key_id` + /// ([`seal_private_contact_request`]). The signature comes from the + /// identity signer, as a transition's does; this signs nothing. + /// `esk_seed` is 32 fresh random bytes, never reused. The encryption key, + /// and the outgoing viewing key the SDK derives from it, stay here; only + /// the sealed entry returns. + #[allow(clippy::too_many_arguments)] + pub fn seal_private_contact_request( + &self, + sender_encryption_key_path: &DerivationPath, + dashpay_contract: &DataContract, + sender: &Identity, + sender_encryption_key_id: KeyID, + signing_key_id: KeyID, + signature: &[u8; 65], + recipient: &Identity, + recipient_key_id: KeyID, + request: &PrivateContactRequest, + esk_seed: &[u8; 32], + platform_version: &PlatformVersion, + ) -> Result { + let secret = + self.resolve_master(|master| secret_key_at(master, sender_encryption_key_path))?; + seal_private_contact_request( + dashpay_contract, + &PrivateContactRequestSender { + identity: sender, + encryption_key_id: sender_encryption_key_id, + encryption_secret_key: &secret.0, + signing_key_id, + signature, + }, + recipient, + recipient_key_id, + request, + esk_seed, + platform_version, + ) + .map_err(MnemonicResolverSignerError::PrivateContactRequest) + } +} + +/// The secp256k1 secret key at `path` below `master`, wiped on drop. The +/// child `ExtendedPrivKey` self-wipes when this returns. +fn secret_key_at( + master: &ExtendedPrivKey, + path: &DerivationPath, +) -> Result { + let derived = master + .derive_priv(path) + .map_err(|e| MnemonicResolverSignerError::DerivationFailed(format!("path: {e}")))?; + let secret = secp256k1::SecretKey::from_secret_bytes(*derived.private_key.to_bytes()) + .map_err(|e| MnemonicResolverSignerError::InvalidScalar(e.to_string()))?; + Ok(WipingSecretKey(secret)) } /// Result of [`MnemonicResolverCoreSigner::contact_info_seal`]. @@ -1226,4 +1379,704 @@ mod tests { assert_eq!(methods.len(), 1); assert!(matches!(methods[0], SignerMethod::Digest)); } + + mod private_contact_requests { + use super::*; + use dash_sdk::dpp::data_contract::accessors::v0::DataContractV0Getters; + use dash_sdk::dpp::identity::accessors::IdentityGettersV0; + use dash_sdk::dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; + use dash_sdk::dpp::identity::identity_public_key::v0::IdentityPublicKeyV0; + use dash_sdk::dpp::identity::{ + IdentityPublicKey, IdentityV0, KeyType, Purpose, SecurityLevel, + }; + use dash_sdk::dpp::system_data_contracts::{load_system_data_contract, SystemDataContract}; + use dash_sdk::dpp::version::PlatformVersion; + use dash_sdk::platform::dashpay::{ + private_contact_request_identities, private_contact_request_outgoing_viewing_key, + private_contact_request_signature, private_contact_request_signing_data, + private_contact_requests_from_scan, ProvedIdentity, + PRIVATE_CONTACT_REQUEST_DOCUMENT_TYPE_NAME, + }; + use dash_sdk::platform::private_document_entries::PrivateDocumentEntry; + use key_wallet::mnemonic::Mnemonic; + use key_wallet::wallet::initialization::WalletAccountCreationOptions; + use key_wallet::wallet::Wallet; + use simple_signer::SingleKeySigner; + use std::collections::BTreeMap; + use std::sync::atomic::{AtomicUsize, Ordering}; + + const SENDER_ID: [u8; 32] = [0xA0; 32]; + const RECIPIENT_ID: [u8; 32] = [0xB0; 32]; + /// Two identities of another wallet, whose entry the scan passes over. + const OUTSIDER_ID: [u8; 32] = [0xC0; 32]; + const CAROL_ID: [u8; 32] = [0xD0; 32]; + /// The authentication key a sender signs its requests with. + const SIGNING_KEY_ID: KeyID = 0; + const SENDER_KEY_ID: KeyID = 1; + const RECIPIENT_KEY_ID: KeyID = 2; + const BLOCK_TIME_MS: u64 = 1_700_000_000_000; + + /// The sender's DashPay encryption key, in the test wallet. + fn sender_key_path() -> DerivationPath { + DerivationPath::from_str("m/9'/1'/5'/0'/0'/0'/1'").expect("valid path") + } + + /// The recipient's DashPay decryption key, in the test wallet. + fn recipient_key_path() -> DerivationPath { + DerivationPath::from_str("m/9'/1'/5'/0'/0'/1'/2'").expect("valid path") + } + + /// The secret key a resident wallet of `ENGLISH_PHRASE` holds at `path`. + fn seed_secret_key(path: &DerivationPath) -> secp256k1::SecretKey { + let seed = Mnemonic::from_phrase(ENGLISH_PHRASE) + .expect("valid mnemonic") + .to_seed(""); + let wallet = Wallet::from_seed_bytes( + *seed, + Network::Testnet, + WalletAccountCreationOptions::None, + ) + .expect("seeded wallet"); + let xprv = wallet + .derive_extended_private_key(path) + .expect("wallet derives the private key at path"); + secp256k1::SecretKey::from_secret_bytes(*xprv.private_key.to_bytes()) + .expect("a valid scalar") + } + + fn fixed_secret_key(byte: u8) -> secp256k1::SecretKey { + secp256k1::SecretKey::from_secret_bytes([byte; 32]).expect("a valid scalar") + } + + /// The sender's signing key, which the identity signer holds, not the + /// resolver. + fn sender_signing_secret_key() -> secp256k1::SecretKey { + fixed_secret_key(0x51) + } + + fn outsider_secret_key() -> secp256k1::SecretKey { + fixed_secret_key(0x41) + } + + fn outsider_signing_secret_key() -> secp256k1::SecretKey { + fixed_secret_key(0x52) + } + + fn key( + id: KeyID, + purpose: Purpose, + security_level: SecurityLevel, + secret_key: &secp256k1::SecretKey, + ) -> IdentityPublicKey { + IdentityPublicKey::V0(IdentityPublicKeyV0 { + id, + purpose, + security_level, + contract_bounds: None, + key_type: KeyType::ECDSA_SECP256K1, + read_only: false, + data: BinaryData::new( + secp256k1::PublicKey::from_secret_key(secret_key) + .serialize() + .to_vec(), + ), + disabled_at: None, + }) + } + + /// A `HIGH` authentication key, which may sign DashPay's documents. + fn signing_key(secret_key: &secp256k1::SecretKey) -> IdentityPublicKey { + key( + SIGNING_KEY_ID, + Purpose::AUTHENTICATION, + SecurityLevel::HIGH, + secret_key, + ) + } + + fn identity(id: [u8; 32], keys: Vec) -> Identity { + Identity::V0(IdentityV0 { + id: Identifier::from(id), + public_keys: keys.into_iter().map(|key| (key.id(), key)).collect(), + balance: 0, + revision: 0, + }) + } + + fn sender() -> Identity { + identity( + SENDER_ID, + vec![ + signing_key(&sender_signing_secret_key()), + key( + SENDER_KEY_ID, + Purpose::ENCRYPTION, + SecurityLevel::MEDIUM, + &seed_secret_key(&sender_key_path()), + ), + ], + ) + } + + fn recipient() -> Identity { + identity( + RECIPIENT_ID, + vec![key( + RECIPIENT_KEY_ID, + Purpose::DECRYPTION, + SecurityLevel::MEDIUM, + &seed_secret_key(&recipient_key_path()), + )], + ) + } + + fn outsider() -> Identity { + identity( + OUTSIDER_ID, + vec![ + signing_key(&outsider_signing_secret_key()), + key( + SENDER_KEY_ID, + Purpose::ENCRYPTION, + SecurityLevel::MEDIUM, + &outsider_secret_key(), + ), + ], + ) + } + + fn carol() -> Identity { + identity( + CAROL_ID, + vec![key( + RECIPIENT_KEY_ID, + Purpose::DECRYPTION, + SecurityLevel::MEDIUM, + &fixed_secret_key(0x42), + )], + ) + } + + fn dashpay() -> DataContract { + load_system_data_contract(SystemDataContract::Dashpay, PlatformVersion::latest()) + .expect("DashPay loads at the latest version") + } + + fn target(contract: &DataContract) -> PrivateDocumentTarget<'static> { + PrivateDocumentTarget { + contract_id: contract.id().to_buffer(), + document_type_name: PRIVATE_CONTACT_REQUEST_DOCUMENT_TYPE_NAME, + } + } + + fn request() -> PrivateContactRequest { + PrivateContactRequest { + account_reference: 7, + extended_public_key: [0x33; platform_encryption::COMPACT_XPUB_LEN], + account_label: Some("Alice".to_string()), + auto_accept_proof: None, + shielded_address: Some([0x55; 43]), + } + } + + /// The signature of `request()` from `sender`'s encryption key to the + /// decryption key of `recipient`, as the wallet gets it: an identity + /// signer holding `signing_secret_key` signs the request's signing + /// data with `sender`'s signing key, as it signs a transition. + async fn signature( + contract: &DataContract, + sender: &Identity, + recipient: &Identity, + signing_secret_key: &secp256k1::SecretKey, + ) -> [u8; 65] { + let signing_key = sender + .get_public_key_by_id(SIGNING_KEY_ID) + .expect("the sender has a signing key") + .clone(); + let signer = SingleKeySigner::new_from_slice( + &signing_secret_key.to_secret_bytes(), + Network::Testnet, + ) + .expect("a valid signing key"); + let signing_data = private_contact_request_signing_data( + contract, + sender, + SENDER_KEY_ID, + recipient, + RECIPIENT_KEY_ID, + &request(), + PlatformVersion::latest(), + ) + .expect("the keys check"); + private_contact_request_signature(&signer, &signing_key, &signing_data) + .await + .expect("the identity signer signs") + } + + /// `request` sealed by `sender` with `secret_key`, the encryption key + /// a resident wallet holds, and `signature`, to the decryption key of + /// `recipient`. + fn seed_sealed( + contract: &DataContract, + sender: &Identity, + secret_key: &secp256k1::SecretKey, + signature: &[u8; 65], + recipient: &Identity, + esk_seed: u8, + ) -> BinaryData { + seal_private_contact_request( + contract, + &PrivateContactRequestSender { + identity: sender, + encryption_key_id: SENDER_KEY_ID, + encryption_secret_key: secret_key, + signing_key_id: SIGNING_KEY_ID, + signature, + }, + recipient, + RECIPIENT_KEY_ID, + &request(), + &[esk_seed; 32], + PlatformVersion::latest(), + ) + .expect("the request seals") + } + + /// The test entry: the sender's request to the recipient. + async fn sent_to_recipient(contract: &DataContract, esk_seed: u8) -> BinaryData { + let signature = signature( + contract, + &sender(), + &recipient(), + &sender_signing_secret_key(), + ) + .await; + seed_sealed( + contract, + &sender(), + &seed_secret_key(&sender_key_path()), + &signature, + &recipient(), + esk_seed, + ) + } + + /// Another wallet's entry: the outsider's request to carol. + async fn sent_to_carol(contract: &DataContract, esk_seed: u8) -> BinaryData { + let signature = signature( + contract, + &outsider(), + &carol(), + &outsider_signing_secret_key(), + ) + .await; + seed_sealed( + contract, + &outsider(), + &outsider_secret_key(), + &signature, + &carol(), + esk_seed, + ) + } + + fn page(entries: Vec) -> PrivateDocumentEntries { + PrivateDocumentEntries { + total_count: entries.len() as u64, + entries: entries + .into_iter() + .zip(0u64..) + .map(|(data, position)| PrivateDocumentEntry { + position, + block_height: 10 + position, + block_time_ms: BLOCK_TIME_MS, + data: data.to_vec(), + }) + .collect(), + } + } + + /// The wallet's recipient key paths: the recipient's decryption key. + fn recipient_paths() -> Vec<(Identifier, Vec<(KeyID, DerivationPath)>)> { + vec![( + Identifier::from(RECIPIENT_ID), + vec![(RECIPIENT_KEY_ID, recipient_key_path())], + )] + } + + /// The wallet's sender key paths: the sender's encryption key. + fn sender_paths() -> Vec<(Identifier, KeyID, DerivationPath)> { + vec![( + Identifier::from(SENDER_ID), + SENDER_KEY_ID, + sender_key_path(), + )] + } + + /// The scan keys a resident wallet of the same seed builds. + fn seed_scan_keys() -> PrivateDocumentScanKeys { + PrivateDocumentScanKeys::new() + .with_recipient( + Identifier::from(RECIPIENT_ID), + &[(RECIPIENT_KEY_ID, seed_secret_key(&recipient_key_path()))], + ) + .with_sender( + Identifier::from(SENDER_ID), + SENDER_KEY_ID, + private_contact_request_outgoing_viewing_key(&seed_secret_key( + &sender_key_path(), + )), + ) + } + + unsafe extern "C" fn counting_resolve( + ctx: *const c_void, + wallet_id_bytes: *const u8, + out_buf: *mut c_char, + out_capacity: usize, + out_len: *mut usize, + ) -> i32 { + (*(ctx as *const AtomicUsize)).fetch_add(1, Ordering::SeqCst); + english_resolve(ctx, wallet_id_bytes, out_buf, out_capacity, out_len) + } + + /// The entry the signer seals with the identity signer's signature is + /// byte for byte the one a resident wallet seals with the encryption + /// key at the same path, so a request sent through the Keychain opens + /// and authenticates for every client. + #[tokio::test] + async fn should_seal_the_entry_a_seed_derived_sender_key_seals() { + let contract = dashpay(); + let signature = signature( + &contract, + &sender(), + &recipient(), + &sender_signing_secret_key(), + ) + .await; + let resolver = make_resolver(english_resolve); + let signer = + unsafe { MnemonicResolverCoreSigner::new(resolver, [0u8; 32], Network::Testnet) }; + + let sealed = signer + .seal_private_contact_request( + &sender_key_path(), + &contract, + &sender(), + SENDER_KEY_ID, + SIGNING_KEY_ID, + &signature, + &recipient(), + RECIPIENT_KEY_ID, + &request(), + &[7; 32], + PlatformVersion::latest(), + ) + .expect("the signer seals the request"); + assert_eq!(sealed, sent_to_recipient(&contract, 7).await); + + unsafe { dash_sdk_mnemonic_resolver_destroy(resolver) }; + } + + /// A page scanned through the signer sorts as with a resident wallet's + /// scan keys: the request to the recipient, and the same request + /// recovered as sent by the sender's outgoing viewing key, while + /// another wallet's entry is passed over. Both read as the sender's + /// signed request, which needs no secret key. + #[tokio::test] + async fn should_scan_a_page_as_seed_derived_scan_keys_do() { + let contract = dashpay(); + let target = target(&contract); + let page = page(vec![ + sent_to_carol(&contract, 1).await, + sent_to_recipient(&contract, 2).await, + ]); + let resolver = make_resolver(english_resolve); + let signer = + unsafe { MnemonicResolverCoreSigner::new(resolver, [0u8; 32], Network::Testnet) }; + + let scan = signer + .scan_private_document_page( + &recipient_paths(), + &sender_paths(), + contract.id(), + PRIVATE_CONTACT_REQUEST_DOCUMENT_TYPE_NAME, + 0, + &page, + ) + .expect("the signer scans the page"); + assert_eq!(scan, seed_scan_keys().scan_page(&target, 0, &page)); + assert_eq!(scan.received.len(), 1, "the request to the recipient"); + assert_eq!(scan.sent.len(), 1, "the request the sender sent"); + assert!(scan.rejected.is_empty()); + assert!(scan.is_complete()); + + let needed = private_contact_request_identities(&scan); + assert_eq!( + needed.keys().copied().collect::>(), + vec![sender().id(), recipient().id()], + "the read checks the sender and the recipient" + ); + // Each identity as a proof at the height the read needs shows it. + let identities: BTreeMap<_, _> = [sender(), recipient()] + .into_iter() + .map(|identity| { + let proved_at_height = needed[&identity.id()]; + ( + identity.id(), + ProvedIdentity { + identity: Some(identity), + proved_at_height, + }, + ) + }) + .collect(); + let read = private_contact_requests_from_scan( + &contract, + scan, + &identities, + PlatformVersion::latest(), + ) + .expect("the requests read"); + assert!(read.rejected.is_empty(), "rejected: {:?}", read.rejected); + assert_eq!(read.received.len(), 1); + assert_eq!(read.received[0].sender_id, sender().id()); + assert_eq!(read.received[0].sender_key_id, SENDER_KEY_ID); + assert_eq!(read.received[0].recipient_key_id, RECIPIENT_KEY_ID); + assert_eq!(read.received[0].request, request()); + assert_eq!(read.sent.len(), 1); + assert_eq!(read.sent[0].sender_key_id, SENDER_KEY_ID); + assert_eq!(read.sent[0].recipient_id, recipient().id()); + + unsafe { dash_sdk_mnemonic_resolver_destroy(resolver) }; + } + + /// Every key a scan needs comes from one Keychain read, not one per + /// key, and a seal reads the Keychain once. + #[tokio::test] + async fn should_resolve_the_mnemonic_once_per_scan_and_once_per_seal() { + let contract = dashpay(); + let page = page(vec![sent_to_recipient(&contract, 3).await]); + let signature = signature( + &contract, + &sender(), + &recipient(), + &sender_signing_secret_key(), + ) + .await; + let resolves = AtomicUsize::new(0); + let resolver = unsafe { + dash_sdk_mnemonic_resolver_create( + &resolves as *const AtomicUsize as *mut c_void, + counting_resolve, + noop_destroy, + ) + }; + let signer = + unsafe { MnemonicResolverCoreSigner::new(resolver, [0u8; 32], Network::Testnet) }; + // Two keys for the recipient and one sender: three derivations. + let recipients = vec![( + Identifier::from(RECIPIENT_ID), + vec![ + (RECIPIENT_KEY_ID, recipient_key_path()), + ( + 3, + DerivationPath::from_str("m/9'/1'/5'/0'/0'/1'/3'").expect("valid path"), + ), + ], + )]; + + signer + .scan_private_document_page( + &recipients, + &sender_paths(), + contract.id(), + PRIVATE_CONTACT_REQUEST_DOCUMENT_TYPE_NAME, + 0, + &page, + ) + .expect("the signer scans the page"); + assert_eq!(resolves.load(Ordering::SeqCst), 1); + + signer + .seal_private_contact_request( + &sender_key_path(), + &contract, + &sender(), + SENDER_KEY_ID, + SIGNING_KEY_ID, + &signature, + &recipient(), + RECIPIENT_KEY_ID, + &request(), + &[4; 32], + PlatformVersion::latest(), + ) + .expect("the signer seals the request"); + assert_eq!(resolves.load(Ordering::SeqCst), 2); + + unsafe { dash_sdk_mnemonic_resolver_destroy(resolver) }; + } + + /// A path that is not the sender's encryption key derives a key the + /// SDK refuses to seal with, and the refusal reaches the caller typed. + #[tokio::test] + async fn should_refuse_to_seal_with_a_key_the_sender_does_not_hold() { + let contract = dashpay(); + let signature = signature( + &contract, + &sender(), + &recipient(), + &sender_signing_secret_key(), + ) + .await; + let resolver = make_resolver(english_resolve); + let signer = + unsafe { MnemonicResolverCoreSigner::new(resolver, [0u8; 32], Network::Testnet) }; + + let err = signer + .seal_private_contact_request( + &recipient_key_path(), + &contract, + &sender(), + SENDER_KEY_ID, + SIGNING_KEY_ID, + &signature, + &recipient(), + RECIPIENT_KEY_ID, + &request(), + &[7; 32], + PlatformVersion::latest(), + ) + .expect_err("the key at that path is not the sender's"); + assert!( + matches!(err, MnemonicResolverSignerError::PrivateContactRequest(_)), + "expected the SDK's refusal, got: {err:?}" + ); + assert!( + err.to_string().contains("secret key"), + "refused for the encryption key, got: {err}" + ); + + unsafe { dash_sdk_mnemonic_resolver_destroy(resolver) }; + } + + /// The signer seals only what the identity signer signed: a + /// signature by a key other than the signing key it names is the + /// SDK's refusal, not an entry. + #[tokio::test] + async fn should_refuse_to_seal_a_signature_by_another_key() { + let contract = dashpay(); + let forged = signature( + &contract, + &sender(), + &recipient(), + &outsider_signing_secret_key(), + ) + .await; + let resolver = make_resolver(english_resolve); + let signer = + unsafe { MnemonicResolverCoreSigner::new(resolver, [0u8; 32], Network::Testnet) }; + + let err = signer + .seal_private_contact_request( + &sender_key_path(), + &contract, + &sender(), + SENDER_KEY_ID, + SIGNING_KEY_ID, + &forged, + &recipient(), + RECIPIENT_KEY_ID, + &request(), + &[7; 32], + PlatformVersion::latest(), + ) + .expect_err("the signature is not the signing key's"); + assert!( + matches!(err, MnemonicResolverSignerError::PrivateContactRequest(_)), + "expected the SDK's refusal, got: {err:?}" + ); + assert!( + err.to_string().contains("signature"), + "refused for the signature, got: {err}" + ); + + unsafe { dash_sdk_mnemonic_resolver_destroy(resolver) }; + } + + /// A batch of pages scanned through the signer reads the Keychain once + /// and sorts each page as scanning it alone does. + #[tokio::test] + async fn should_resolve_the_mnemonic_once_per_batch_of_pages() { + let contract = dashpay(); + let first = page(vec![ + sent_to_carol(&contract, 5).await, + sent_to_recipient(&contract, 6).await, + ]); + let mut second = page(vec![sent_to_recipient(&contract, 8).await]); + for entry in &mut second.entries { + entry.position += 2; + } + second.total_count = 3; + let resolves = AtomicUsize::new(0); + let resolver = unsafe { + dash_sdk_mnemonic_resolver_create( + &resolves as *const AtomicUsize as *mut c_void, + counting_resolve, + noop_destroy, + ) + }; + let signer = + unsafe { MnemonicResolverCoreSigner::new(resolver, [0u8; 32], Network::Testnet) }; + + let scans = signer + .scan_private_document_pages( + &recipient_paths(), + &sender_paths(), + contract.id(), + PRIVATE_CONTACT_REQUEST_DOCUMENT_TYPE_NAME, + &[(0, &first), (2, &second)], + ) + .expect("the signer scans the batch"); + assert_eq!(resolves.load(Ordering::SeqCst), 1, "one Keychain read"); + let target = target(&contract); + assert_eq!( + scans, + vec![ + seed_scan_keys().scan_page(&target, 0, &first), + seed_scan_keys().scan_page(&target, 2, &second), + ] + ); + assert_eq!(scans[1].received.len(), 1); + assert_eq!(scans[1].next_position, 3); + + unsafe { dash_sdk_mnemonic_resolver_destroy(resolver) }; + } + + #[test] + fn should_report_a_missing_mnemonic_from_a_scan() { + let contract = dashpay(); + let resolver = make_resolver(missing_resolve); + let signer = + unsafe { MnemonicResolverCoreSigner::new(resolver, [0u8; 32], Network::Testnet) }; + + let err = signer + .scan_private_document_page( + &recipient_paths(), + &sender_paths(), + contract.id(), + PRIVATE_CONTACT_REQUEST_DOCUMENT_TYPE_NAME, + 0, + &page(Vec::new()), + ) + .expect_err("no mnemonic, no keys"); + assert!( + matches!(err, MnemonicResolverSignerError::NotFound), + "error should be NotFound, got: {err:?}" + ); + + unsafe { dash_sdk_mnemonic_resolver_destroy(resolver) }; + } + } } From df8cba558a936d38e4e16bfaa7c543f1ce12e29f Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 9 Nov 2026 08:22:50 +0700 Subject: [PATCH 057/117] fix(platform-wallet): release a public request's claim only by the attempt that took it A public send's claim on its pair was a count, and its release found the claim by wallet id and pair. After a wallet was removed and re-created under the same id, an old send's release, its drop included, freed the new send's claim, letting a private request to the pair be reserved while the new public one was still in flight. Claims now carry process-wide attempt ids, from the counter private reservations use (`NEXT_CONTACT_REQUEST_ATTEMPT`): `claim_public_contact_request` returns its attempt, `PublicContactClaim` keeps it, and `release_public_contact_request` releases that attempt only. Co-Authored-By: Claude Opus 5.5 --- .../identity/network/contact_requests.rs | 23 +++++-- .../network/private_contact_requests.rs | 63 +++++++++++++++++++ .../managed_identity/contact_requests.rs | 41 +++++++----- .../state/managed_identity/dashpay.rs | 13 ++-- 4 files changed, 113 insertions(+), 27 deletions(-) diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs b/packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs index 96567caf00e..d73612013e2 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs @@ -583,6 +583,8 @@ pub(super) struct PublicContactClaim { wallet_id: WalletId, sender: Identifier, recipient: Identifier, + /// The attempt the claim was taken under: only it releases the claim. + attempt: u64, held: bool, } @@ -591,7 +593,13 @@ impl PublicContactClaim { /// the guard releases it. pub(super) async fn release(mut self) { let mut wm = self.wallet_manager.write().await; - release_public_claim(&mut wm, &self.wallet_id, &self.sender, &self.recipient); + release_public_claim( + &mut wm, + &self.wallet_id, + &self.sender, + &self.recipient, + self.attempt, + ); self.held = false; } } @@ -601,27 +609,29 @@ impl Drop for PublicContactClaim { if !self.held { return; } - let (wallet_id, sender, recipient) = (self.wallet_id, self.sender, self.recipient); + let (wallet_id, sender, recipient, attempt) = + (self.wallet_id, self.sender, self.recipient, self.attempt); apply_detached( &self.wallet_manager, "a dropped public contact request's claim", - move |wm| release_public_claim(wm, &wallet_id, &sender, &recipient), + move |wm| release_public_claim(wm, &wallet_id, &sender, &recipient, attempt), ); } } -/// Release one claim `sender` holds on its pair with `recipient`. +/// Release the claim `attempt` holds on `sender`'s pair with `recipient`. fn release_public_claim( wm: &mut WalletManager, wallet_id: &WalletId, sender: &Identifier, recipient: &Identifier, + attempt: u64, ) { if let Some(managed) = wm .get_wallet_info_mut(wallet_id) .and_then(|info| info.identity_manager.managed_identity_mut(sender)) { - managed.release_public_contact_request(recipient); + managed.release_public_contact_request(recipient, attempt); } } @@ -809,12 +819,13 @@ impl DashPayView<'_, B> { *recipient, )); } - managed.claim_public_contact_request(*recipient); + let attempt = managed.claim_public_contact_request(*recipient); Ok(Some(PublicContactClaim { wallet_manager: Arc::clone(&self.wallet_manager), wallet_id: self.wallet_id, sender: *sender, recipient: *recipient, + attempt, held: true, })) } diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/private_contact_requests.rs b/packages/rs-platform-wallet/src/wallet/identity/network/private_contact_requests.rs index 5ee76d1bfcf..baa3ac3ad09 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/private_contact_requests.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/private_contact_requests.rs @@ -5234,6 +5234,69 @@ mod tests { } } + /// A public request's claim is released only by its own attempt: a send + /// that outlived its wallet's removal and re-creation under the same id, + /// dropped once the new wallet's send claimed the same pair, leaves that + /// claim in place, and the new send's own release frees the pair. + #[tokio::test] + async fn should_release_only_its_own_claim_across_a_wallet_re_creation() { + let alice = identity(ALICE_PHRASE, ALICE); + let bob = identity(BOB_PHRASE, BOB); + let (manager, old_wallet) = wallet(ALICE_PHRASE, &alice, &[&alice, &bob], vec![]).await; + let old_claim = old_wallet + .identity() + .dashpay() + .claim_public_pair(&alice.id(), &bob.id(), false) + .await + .expect("claimed") + .expect("a claim"); + + manager + .remove_wallet(&old_wallet.wallet_id()) + .await + .expect("removed"); + let new_wallet = manager + .create_wallet_from_seed_bytes( + Network::Testnet, + &seed(ALICE_PHRASE), + WalletAccountCreationOptions::Default, + Some(0), + ) + .await + .expect("re-created"); + assert_eq!(new_wallet.wallet_id(), old_wallet.wallet_id()); + { + let iw = new_wallet.identity(); + let mut wm = iw.wallet_manager.write().await; + wm.get_wallet_info_mut(&new_wallet.wallet_id()) + .expect("wallet info") + .identity_manager + .add_identity(alice.clone(), 0, new_wallet.wallet_id(), &iw.persister) + .expect("own identity"); + } + let new_claim = new_wallet + .identity() + .dashpay() + .claim_public_pair(&alice.id(), &bob.id(), false) + .await + .expect("claimed") + .expect("a claim"); + let claimed = || async { + managed(&new_wallet, ALICE, |managed| { + managed.dashpay().public_contact_request_claimed(&bob.id()) + }) + .await + }; + + drop(old_claim); + assert!( + claimed().await, + "the old send's release leaves the new claim" + ); + drop(new_claim); + assert!(!claimed().await, "the new send's release frees the pair"); + } + /// The first private send since load reads the store first. While that /// read cannot finish, here because the recipient of a request the store /// holds from Alice cannot be read, it is refused with nothing sealed or diff --git a/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/contact_requests.rs b/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/contact_requests.rs index a39ffa78ad2..e1c67027591 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/contact_requests.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/contact_requests.rs @@ -22,10 +22,16 @@ use platform_encryption::account_reference_version; use sha2::{Digest, Sha256}; use std::sync::atomic::{AtomicU64, Ordering}; -/// The attempt id the next private request reservation takes. Process-wide, -/// so a handle that outlived its wallet's removal and re-creation under the -/// same id cannot release a reservation the new wallet made. -static NEXT_PRIVATE_REPLY_ATTEMPT: AtomicU64 = AtomicU64::new(0); +/// The attempt id the next private request reservation or public request +/// claim takes. Process-wide, so a handle that outlived its wallet's removal +/// and re-creation under the same id cannot release a reservation or a claim +/// the new wallet made. +static NEXT_CONTACT_REQUEST_ATTEMPT: AtomicU64 = AtomicU64::new(0); + +/// A new attempt id ([`NEXT_CONTACT_REQUEST_ATTEMPT`]). +fn next_contact_request_attempt() -> u64 { + NEXT_CONTACT_REQUEST_ATTEMPT.fetch_add(1, Ordering::Relaxed) +} impl ManagedIdentity { /// The masked `accountReference` of the most recent request WE sent @@ -983,26 +989,29 @@ impl ManagedIdentity { } /// Claim the pair with `recipient` for a public request about to be - /// sent: until [`Self::release_public_contact_request`], no private - /// request to it is reserved. Claims count, so concurrent public sends - /// each hold one. - pub(crate) fn claim_public_contact_request(&mut self, recipient: Identifier) { - *self - .dashpay + /// sent, under a new attempt it returns: until that attempt releases it + /// ([`Self::release_public_contact_request`]), no private request to the + /// recipient is reserved. Concurrent public sends each hold their own. + pub(crate) fn claim_public_contact_request(&mut self, recipient: Identifier) -> u64 { + let attempt = next_contact_request_attempt(); + self.dashpay .public_contact_request_claims .entry(recipient) - .or_default() += 1; + .or_default() + .insert(attempt); + attempt } - /// Release one claim [`Self::claim_public_contact_request`] took. - pub(crate) fn release_public_contact_request(&mut self, recipient: &Identifier) { + /// Release the claim `attempt` took ([`Self::claim_public_contact_request`]); + /// another attempt's claim on the pair stays. + pub(crate) fn release_public_contact_request(&mut self, recipient: &Identifier, attempt: u64) { if let Some(claims) = self .dashpay .public_contact_request_claims .get_mut(recipient) { - *claims = claims.saturating_sub(1); - if *claims == 0 { + claims.remove(&attempt); + if claims.is_empty() { self.dashpay.public_contact_request_claims.remove(recipient); } } @@ -1010,7 +1019,7 @@ impl ManagedIdentity { /// Reserve `recipient` under a new attempt, `commitment` its entry's. fn insert_private_reply(&mut self, recipient: Identifier, commitment: Option<[u8; 32]>) -> u64 { - let attempt = NEXT_PRIVATE_REPLY_ATTEMPT.fetch_add(1, Ordering::Relaxed); + let attempt = next_contact_request_attempt(); self.dashpay.pending_private_replies.insert( recipient, PendingPrivateReply { diff --git a/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/dashpay.rs b/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/dashpay.rs index dafa2d7fe2c..b01f6c87c2d 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/dashpay.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/dashpay.rs @@ -149,11 +149,14 @@ pub struct DashPayState { /// restored from the shielded store's unconfirmed spends. pub(super) pending_private_replies: BTreeMap, - /// The public requests to each recipient being sent, counted: each claims - /// the pair from before its channel is decided until it is submitted and - /// recorded, and while one does no private request to the recipient is - /// reserved, as a reserved one refuses a public send. In memory only. - pub(super) public_contact_request_claims: BTreeMap, + /// The public requests to each recipient being sent, by attempt: each + /// claims the pair from before its channel is decided until it is + /// submitted and recorded, and while one does no private request to the + /// recipient is reserved, as a reserved one refuses a public send. A + /// claim is released only by its own attempt, whose id is process-wide, + /// so a send that outlived its wallet's removal and re-creation under the + /// same id cannot release a claim of the new wallet. In memory only. + pub(super) public_contact_request_claims: BTreeMap>, /// Lowest Platform-assigned `$createdAtCoreBlockHeight` among OUR sent /// `contactRequest` docs to each recipient seen by a sweep this process. From 6c3abe0591fe9850ca227609d8308c68bc67deac Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 9 Nov 2026 08:26:15 +0700 Subject: [PATCH 058/117] fix(platform-wallet): drop a private contact request association with its record The file store's `private_contact_payments` gained an entry whenever a private contact request's record was armed or rehydrated, but kept it when the record went: `mark_spent`, `clear_pending`, `clear_redrive` and the purges removed records and left their associations in memory until the store was reopened. Each now removes the association with its record: by activity id where the dropped record is known, by sweeping the subwallet's associations against its live records otherwise. Co-Authored-By: Claude Opus 5.5 --- .../src/wallet/shielded/file_store.rs | 100 ++++++++++++++++++ 1 file changed, 100 insertions(+) diff --git a/packages/rs-platform-wallet/src/wallet/shielded/file_store.rs b/packages/rs-platform-wallet/src/wallet/shielded/file_store.rs index 3468d007992..4068eb3dea4 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/file_store.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/file_store.rs @@ -117,6 +117,18 @@ pub struct FileBackedShieldedStore { } impl FileBackedShieldedStore { + /// Drop the private contact request associations of `id` whose record is + /// gone, as its redrives are removed. The listing joins with the live + /// records, so a left one restores nothing, but would stay in memory + /// until the store is reopened. + fn evict_private_contact_payments(&mut self, id: SubwalletId) { + let live = self.subwallets.get(&id); + self.private_contact_payments + .retain(|(owner, activity_id), _| { + *owner != id || live.is_some_and(|sw| sw.redrives.contains_key(activity_id)) + }); + } + /// Persist `redrive`, with the private contact request it pays for when /// it does, in one row write. fn arm_redrive_paying_for( @@ -640,6 +652,7 @@ impl ShieldedStore for FileBackedShieldedStore { rehydrate on the next open (self-heals via reconcile/prune)" ); } + self.private_contact_payments.remove(&(id, *activity_id)); } Ok(outcome.newly_spent) } @@ -670,6 +683,7 @@ impl ShieldedStore for FileBackedShieldedStore { rehydrate on the next open (self-heals via reconcile/prune)" ); } + self.evict_private_contact_payments(id); } Ok(removed) } @@ -797,6 +811,7 @@ impl ShieldedStore for FileBackedShieldedStore { if let Some(sw) = self.subwallets.get_mut(&id) { sw.clear_redrive(activity_id); } + self.private_contact_payments.remove(&(id, *activity_id)); Ok(()) } @@ -976,6 +991,8 @@ impl ShieldedStore for FileBackedShieldedStore { // in-memory only (`subwallets`); the commitment tree in // SQLite is chain-wide and intentionally left intact. self.subwallets.retain(|id, _| id.wallet_id != wallet_id); + self.private_contact_payments + .retain(|(id, _), _| id.wallet_id != wallet_id); Ok(()) } @@ -1008,6 +1025,7 @@ impl ShieldedStore for FileBackedShieldedStore { retained.arm_redrive(redrive); } } + self.evict_private_contact_payments(id); Ok(()) } @@ -1023,6 +1041,7 @@ impl ShieldedStore for FileBackedShieldedStore { })?; } self.subwallets.clear(); + self.private_contact_payments.clear(); Ok(()) } @@ -1209,6 +1228,10 @@ mod tests { .private_contact_redrives_for_wallet(wallet_id) .expect("listed") .is_empty()); + assert!( + store.private_contact_payments.is_empty(), + "the association goes with its record" + ); } { let store = FileBackedShieldedStore::open_path(&path, 100).expect("reopen 2"); @@ -1221,6 +1244,83 @@ mod tests { let _ = std::fs::remove_file(&path); } + /// A private contact request association goes with its record on every + /// path that drops the record: a resolved spend, a cleared redrive, and + /// the purges. + #[test] + fn should_drop_a_private_contact_payment_with_its_record() { + let path = temp_tree_path("private_contact_payment_eviction"); + let wallet_id = [0x5C; 32]; + let id = SubwalletId::new(wallet_id, 0); + let other = SubwalletId::new([0x5D; 32], 0); + let record = |activity: u8, nullifier: u8| PendingRedrive { + activity_id: [activity; 32], + anchor: [2u8; 32], + nullifiers: vec![[nullifier; 32]], + st_bytes: vec![0xAB; 96], + attempts: 0, + identity_nonce_finalized: false, + identity_user_abandoned: false, + }; + let payment = PrivateContactPayment { + sender: [0xB1; 32], + recipient: [0xA1; 32], + account_reference: 5, + }; + let mut store = FileBackedShieldedStore::open_path(&path, 100).expect("open"); + let arm = |store: &mut FileBackedShieldedStore, id, activity, nullifier| { + store + .arm_private_contact_redrive(id, record(activity, nullifier), payment) + .expect("armed"); + }; + + let note = ShieldedNote { + position: 0, + cmx: [1; 32], + nullifier: [11; 32], + block_height: 1, + is_spent: false, + value: 1, + note_data: vec![], + }; + store.save_note(id, ¬e).expect("a note"); + arm(&mut store, id, 1, 11); + store.mark_spent(id, &[11; 32]).expect("spent"); + assert!(store.private_contact_payments.is_empty(), "resolved spend"); + + arm(&mut store, id, 2, 12); + store.clear_redrive(id, &[2; 32]).expect("cleared"); + assert!(store.private_contact_payments.is_empty(), "cleared redrive"); + + arm(&mut store, id, 3, 13); + arm(&mut store, other, 4, 14); + store.purge_subwallet(id).expect("purged"); + assert_eq!( + store + .private_contact_payments + .keys() + .copied() + .collect::>(), + vec![(other, [4; 32])], + "subwallet purge" + ); + arm(&mut store, id, 5, 15); + store.purge_wallet(wallet_id).expect("purged"); + assert_eq!( + store + .private_contact_payments + .keys() + .copied() + .collect::>(), + vec![(other, [4; 32])], + "wallet purge" + ); + store.purge_all_subwallets().expect("purged"); + assert!(store.private_contact_payments.is_empty(), "every subwallet"); + drop(store); + let _ = std::fs::remove_file(&path); + } + #[test] fn identity_debit_guard_with_no_nullifiers_survives_reopen() { let path = temp_tree_path("identity_debit_guard"); From e11ef7cbc2aca2abb47c38b2151b0b73cfa18c41 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 9 Nov 2026 08:32:43 +0700 Subject: [PATCH 059/117] fix(sdk): keep a newer contract over a registered older one `fetch_private_document_contract` registers the contract it fetched with the context provider once the fetch returns, and the trusted and verified providers inserted it unconditionally. A newer contract another operation promoted meanwhile was then replaced by the older fetched one, and a page read for a private type the newer one declares failed against it. - The trusted and verified providers' `register_data_contract` keep a held contract of a higher version (wasm's provider forwards to them). - The helper reads the provider again before registering, and keeps and uses a newer held contract that declares the type, for providers whose registration replaces whatever they hold. Co-Authored-By: Claude Opus 5.5 --- .../src/provider.rs | 63 ++++++++++++++++++- .../src/verified_provider.rs | 33 +++++++++- .../dashpay/private_contact_request.rs | 56 +++++++++++++++++ .../src/platform/private_document_entries.rs | 41 ++++++++++-- 4 files changed, 187 insertions(+), 6 deletions(-) diff --git a/packages/rs-sdk-trusted-context-provider/src/provider.rs b/packages/rs-sdk-trusted-context-provider/src/provider.rs index 00a71e108a4..55e0a5f52ac 100644 --- a/packages/rs-sdk-trusted-context-provider/src/provider.rs +++ b/packages/rs-sdk-trusted-context-provider/src/provider.rs @@ -1028,9 +1028,17 @@ impl ContextProvider for TrustedHttpContextProvider { } } + /// Keeps a held contract of a higher version: a registration that waited + /// on a fetch must not replace one another operation promoted meanwhile. fn register_data_contract(&self, contract: Arc) { let id = contract.id(); let mut known = self.known_contracts.lock().unwrap(); + if known + .get(&id) + .is_some_and(|held| held.version() > contract.version()) + { + return; + } known.insert(id, contract); } @@ -1060,7 +1068,7 @@ impl ContextProvider for TrustedHttpContextProvider { } #[cfg(all(test, not(target_arch = "wasm32")))] -mod tests { +pub(crate) mod tests { use super::*; use crate::quorum_refresh::MIN_GAP; use data_contracts::SystemDataContract; @@ -2252,6 +2260,59 @@ mod tests { } } + /// A one-type contract `id` at `version`. + pub(crate) fn contract_at_version(id: [u8; 32], version: u32) -> DataContract { + use dpp::data_contract::conversion::value::v0::DataContractValueConversionMethodsV0; + use dpp::platform_value::platform_value; + use dpp::platform_value::string_encoding::Encoding; + use dpp::version::PlatformVersion; + + DataContract::from_value( + platform_value!({ + "$formatVersion": "1", + "id": Identifier::from(id).to_string(Encoding::Base58), + "ownerId": Identifier::from([8; 32]).to_string(Encoding::Base58), + "version": version, + "documentSchemas": { + "note": { + "type": "object", + "properties": { + "text": { "type": "string", "maxLength": 10, "position": 0 } + }, + "additionalProperties": false + } + } + }), + false, + PlatformVersion::latest(), + ) + .expect("the contract parses") + } + + /// A registered contract replaces a held one of a lower version, and + /// leaves one of a higher version, which another operation promoted while + /// the registration waited on its fetch. + #[test] + fn should_keep_a_newer_contract_over_a_registered_older_one() { + let provider = TrustedHttpContextProvider::new_with_url( + Network::Testnet, + "https://127.0.0.1".to_string(), + NonZeroUsize::new(100).unwrap(), + ) + .unwrap(); + let version = |version: u32| Arc::new(contract_at_version([0x71; 32], version)); + let held = |provider: &TrustedHttpContextProvider| { + provider + .get_known_contract(&Identifier::from([0x71; 32])) + .map(|contract| contract.version()) + }; + provider.register_data_contract(version(2)); + provider.register_data_contract(version(3)); + assert_eq!(held(&provider), Some(3), "a newer one replaces"); + provider.register_data_contract(version(2)); + assert_eq!(held(&provider), Some(3), "an older one does not"); + } + #[test] fn test_known_contracts() { use dpp::version::PlatformVersion; diff --git a/packages/rs-sdk-trusted-context-provider/src/verified_provider.rs b/packages/rs-sdk-trusted-context-provider/src/verified_provider.rs index 278eb5a11ad..0a8a6247916 100644 --- a/packages/rs-sdk-trusted-context-provider/src/verified_provider.rs +++ b/packages/rs-sdk-trusted-context-provider/src/verified_provider.rs @@ -669,8 +669,17 @@ impl ContextProvider for VerifiedHttpContextProvider { system_data_contract(id, platform_version) } + /// Keeps a held contract of a higher version: a registration that waited + /// on a fetch must not replace one another operation promoted meanwhile. fn register_data_contract(&self, contract: Arc) { - lock(&self.inner.known_contracts).insert(contract.id(), contract); + let mut known = lock(&self.inner.known_contracts); + if known + .get(&contract.id()) + .is_some_and(|held| held.version() > contract.version()) + { + return; + } + known.insert(contract.id(), contract); } fn get_token_configuration( @@ -1070,6 +1079,28 @@ mod tests { format!("http://{}", closed.local_addr().expect("address")) } + /// A registered contract replaces a held one of a lower version, and + /// leaves one of a higher version, which another operation promoted while + /// the registration waited on its fetch. + #[test] + fn should_keep_a_newer_contract_over_a_registered_older_one() { + use crate::provider::tests::contract_at_version; + + let provider = + VerifiedHttpContextProvider::new(Network::Testnet, cache_size()).expect("a provider"); + let id = Identifier::from([0x72; 32]); + let held = |provider: &VerifiedHttpContextProvider| { + provider + .get_known_contract(&id) + .map(|contract| contract.version()) + }; + provider.register_data_contract(Arc::new(contract_at_version([0x72; 32], 2))); + provider.register_data_contract(Arc::new(contract_at_version([0x72; 32], 3))); + assert_eq!(held(&provider), Some(3), "a newer one replaces"); + provider.register_data_contract(Arc::new(contract_at_version([0x72; 32], 2))); + assert_eq!(held(&provider), Some(3), "an older one does not"); + } + fn cache_size() -> NonZeroUsize { NonZeroUsize::new(32).expect("non-zero") } diff --git a/packages/rs-sdk/src/platform/dashpay/private_contact_request.rs b/packages/rs-sdk/src/platform/dashpay/private_contact_request.rs index 06ce9dfe3ee..57bf53125c2 100644 --- a/packages/rs-sdk/src/platform/dashpay/private_contact_request.rs +++ b/packages/rs-sdk/src/platform/dashpay/private_contact_request.rs @@ -3505,6 +3505,62 @@ mod tests { assert!(Arc::ptr_eq(&resolved, &held)); } + /// A newer contract another operation gave the provider while the reader fetched one, + /// here promoted right after the reader's first read, stays: the older fetched contract + /// does not replace it, even through a provider whose registration replaces whatever it + /// holds, and the page is read against the newer one. + #[cfg(feature = "mocks")] + #[tokio::test] + async fn should_keep_a_newer_contract_promoted_while_the_reader_fetched() { + let mut stale = dashpay_without_private_requests(); + stale.set_version(1); + let mut fetched = dashpay(); + fetched.set_version(2); + let mut promoted = dashpay(); + promoted.set_version(3); + let provider = HeldContractProvider::promoting(stale, promoted); + let mut sdk = sdk_with(provider.clone()); + sdk.mock() + .expect_fetch(fetched.id(), Some(fetched.clone())) + .await + .expect("the DashPay fetch is expected"); + sdk.mock() + .expect_fetch( + PrivateDocumentEntriesQuery::new( + fetched.id(), + PRIVATE_CONTACT_REQUEST_DOCUMENT_TYPE_NAME, + 0, + ), + Some(PrivateDocumentEntries { + entries: vec![], + total_count: 0, + }), + ) + .await + .expect("the page fetch is expected"); + + let resolved = sdk + .fetch_private_document_contract( + fetched.id(), + PRIVATE_CONTACT_REQUEST_DOCUMENT_TYPE_NAME, + ) + .await + .expect("the newer contract"); + assert_eq!(resolved.version(), 3); + assert_eq!(provider.held().expect("held").version(), 3); + let scan = sdk + .scan_private_document_page( + fetched.id(), + PRIVATE_CONTACT_REQUEST_DOCUMENT_TYPE_NAME, + 0, + &PrivateDocumentScanKeys::new(), + ) + .await + .expect("the page is read"); + assert!(scan.is_complete()); + assert_eq!(provider.held().expect("held").version(), 3); + } + /// Below protocol version 15 there is no private store: a contract that declares no /// private type, on an SDK running 14, is refused as not active, and the page stream ends /// with that refusal before any page is fetched, which the mock, answering none, would diff --git a/packages/rs-sdk/src/platform/private_document_entries.rs b/packages/rs-sdk/src/platform/private_document_entries.rs index 59ea5e126f5..ce6bcdca6f7 100644 --- a/packages/rs-sdk/src/platform/private_document_entries.rs +++ b/packages/rs-sdk/src/platform/private_document_entries.rs @@ -106,8 +106,10 @@ impl Sdk { /// A held contract that declares the type is used as it is and never replaced, so a newer /// one the provider proved stays. Otherwise the contract is fetched from the network, /// refused when it does not declare the type, given to the provider and read back; the - /// call fails when the provider then still holds none that declares it. Without a context - /// provider the fetched contract is returned. + /// call fails when the provider then still holds none that declares it. A newer contract + /// the provider was given while the fetch ran, by another operation, is kept and used + /// rather than replaced by the fetched one. Without a context provider the fetched + /// contract is returned. /// /// Private types exist from protocol version 15. Below it, as the page fetch is, the call /// is refused with [`Error::ProtocolVersionNotActive`], before anything is fetched on a @@ -157,6 +159,21 @@ impl Sdk { let Some(provider) = provider else { return Ok(contract); }; + // Another operation may have given the provider a newer contract while this one was + // fetched: it stays, as a provider whose registration replaces would put the older + // fetched one in its place. + if let Some(held) = provider.get_data_contract(&data_contract_id, self.version())? { + if held.version() > contract.version() { + return if declares_private_type(&held, document_type_name) { + Ok(held) + } else { + Err(Error::Generic(format!( + "the context provider holds a newer contract {data_contract_id} that \ + declares no private type \"{document_type_name}\"" + ))) + }; + } + } provider.register_data_contract(contract); match provider.get_data_contract(&data_contract_id, self.version())? { Some(held) if declares_private_type(&held, document_type_name) => Ok(held), @@ -181,11 +198,15 @@ pub(crate) mod held_contract_provider { /// A context provider holding `held`, or nothing, whatever version it is asked for, as a /// provider's known contracts do. A `writable` one puts a registered contract in its - /// place; a read-only one keeps what it holds. Its clones share what it holds. + /// place, whatever its version; a read-only one keeps what it holds. Its clones share + /// what it holds. #[derive(Clone)] pub(crate) struct HeldContractProvider { held: Arc>>>, writable: bool, + /// Held from the first read on, as if another operation gave it to the provider + /// while the reader's fetch ran. + promoted: Arc>>>, } impl HeldContractProvider { @@ -193,9 +214,17 @@ pub(crate) mod held_contract_provider { Self { held: Arc::new(Mutex::new(held.map(Arc::new))), writable, + promoted: Arc::new(Mutex::new(None)), } } + /// A writable provider holding `held` until it is first read, then `promoted`. + pub(crate) fn promoting(held: DataContract, promoted: DataContract) -> Self { + let provider = Self::new(Some(held), true); + *provider.promoted.lock().expect("the lock") = Some(Arc::new(promoted)); + provider + } + pub(crate) fn held(&self) -> Option> { self.held.lock().expect("the lock").clone() } @@ -207,7 +236,11 @@ pub(crate) mod held_contract_provider { _id: &Identifier, _platform_version: &PlatformVersion, ) -> Result>, ContextProviderError> { - Ok(self.held()) + let held = self.held(); + if let Some(promoted) = self.promoted.lock().expect("the lock").take() { + *self.held.lock().expect("the lock") = Some(promoted); + } + Ok(held) } fn get_token_configuration( From c33583afa7ae572b6e2b70d64323a84814c7555e Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 9 Nov 2026 08:46:20 +0700 Subject: [PATCH 060/117] fix(platform-wallet): read the private store again before a send once a sender is unignored `unignore_sender` rewound the private read position but left `private_contact_requests_read` set. A pass that read the store with the sender ignored had passed over its private request, so after the unignore a public send skipped the read on the stale flag and could publish the pair before the rewound entries were ingested. A pass the sender was unignored under also took the store as read at its end, as the end-of-store branch did not check the rewind. The unignore now clears the readiness with the rewind (the rewound position is stored as before; readiness stays in memory), and a pass whose cursor was rewound under it does not set it again. Co-Authored-By: Claude Opus 5.5 --- .../network/private_contact_requests.rs | 131 +++++++++++++++++- .../managed_identity/contact_requests.rs | 5 + 2 files changed, 135 insertions(+), 1 deletion(-) diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/private_contact_requests.rs b/packages/rs-platform-wallet/src/wallet/identity/network/private_contact_requests.rs index baa3ac3ad09..132d4ba60af 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/private_contact_requests.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/private_contact_requests.rs @@ -1544,7 +1544,14 @@ impl DashPayView<'_, B> { } // Read to the store's end with nothing held back this // pass: every entry was ingested for this identity. - if read.is_complete() && !identity.failed && identity.keys_current(managed) + // Not once its cursor was rewound under the pass (a + // sender unignored): the entries before it were read + // with the sender ignored, and the next pass reads + // them again. + if read.is_complete() + && !identity.failed + && !identity.rewound + && identity.keys_current(managed) { managed.note_private_contact_requests_read(); } @@ -5297,6 +5304,128 @@ mod tests { assert!(!claimed().await, "the new send's release frees the pair"); } + /// Unignoring a sender rewinds the private read and clears its + /// readiness: a pass that read the store with the sender ignored passed + /// over its private request, so a public send to it reads the store + /// again first, finds the request, and is refused, publishing nothing. + #[tokio::test] + async fn should_read_the_store_again_before_a_public_send_once_a_sender_is_unignored() { + let alice = identity(ALICE_PHRASE, ALICE); + let bob = identity(BOB_PHRASE, BOB); + let bob_crypto = SeedCryptoProvider::from_seed(seed(BOB_PHRASE), Network::Testnet); + let bob_signer = signer(BOB_PHRASE, &bob); + let (entry, _) = alice_to_bob([0x55; 43]).await; + let (_manager, bob_wallet) = wallet(BOB_PHRASE, &bob, &[&alice, &bob], vec![entry]).await; + let view = bob_wallet.identity().dashpay(); + view.ignore_contact_sender(&bob.id(), &alice.id()) + .await + .expect("ignored"); + view.sync_private_contact_requests(&bob_crypto, 4) + .await + .expect("the pass"); + assert!(store_read(&bob_wallet, BOB).await); + assert!( + managed(&bob_wallet, BOB, |managed| { + managed.dashpay().incoming_request(&alice.id()).is_none() + }) + .await, + "passed over while ignored" + ); + + view.unignore_contact_sender(&bob.id(), &alice.id()) + .await + .expect("unignored"); + assert!(!store_read(&bob_wallet, BOB).await, "read again first"); + let public = view + .send_contact_request_with_external_signer( + &bob.id(), + &alice.id(), + None, + AutoAcceptProofSource::None, + &bob_signer, + &bob_crypto, + ) + .await; + assert!( + matches!( + public, + Err(PlatformWalletError::PrivateContactRequestNeedsPrivateReply(contact)) + if contact == alice.id() + ), + "{public:?}" + ); + } + + /// A sender unignored while a pass runs, after the pass passed over its + /// private request: the pass reaches the store's end without taking the + /// store as read, so a public send to the sender reads it again first, + /// finds the request, and is refused, publishing nothing. + #[tokio::test] + async fn should_not_take_the_store_as_read_by_a_pass_a_sender_was_unignored_under() { + let alice = identity(ALICE_PHRASE, ALICE); + let bob = identity(BOB_PHRASE, BOB); + let bob_crypto = SeedCryptoProvider::from_seed(seed(BOB_PHRASE), Network::Testnet); + let bob_signer = signer(BOB_PHRASE, &bob); + let (entry, _) = alice_to_bob([0x55; 43]).await; + // Alice's request first, then pages enough for a second scan batch. + let mut entries = vec![entry]; + entries.extend((1..=PRIVATE_SCAN_BATCH_PAGES as u8).map(|byte| vec![byte; 589])); + let (_manager, bob_wallet) = wallet(BOB_PHRASE, &bob, &[&alice, &bob], vec![]).await; + publish_chunked(&bob_wallet, &entries, 1).await; + let view = bob_wallet.identity().dashpay(); + view.ignore_contact_sender(&bob.id(), &alice.id()) + .await + .expect("ignored"); + + // Unignored before the pass scans its second batch, once the first, + // holding Alice's request, was ingested with her ignored. + let mut unignoring = HookedCrypto::new(BOB_PHRASE); + unignoring.before_scan = Some({ + let bob_wallet = Arc::clone(&bob_wallet); + let scans = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let (bob_id, alice_id) = (bob.id(), alice.id()); + Arc::new(move || { + let (bob_wallet, scans) = (Arc::clone(&bob_wallet), Arc::clone(&scans)); + Box::pin(async move { + if scans.fetch_add(1, Ordering::SeqCst) == 1 { + bob_wallet + .identity() + .dashpay() + .unignore_contact_sender(&bob_id, &alice_id) + .await + .expect("unignored"); + } + }) + }) + }); + view.sync_private_contact_requests(&unignoring, 64) + .await + .expect("the pass"); + assert!( + !store_read(&bob_wallet, BOB).await, + "not read for a sender unignored under the pass" + ); + + let public = view + .send_contact_request_with_external_signer( + &bob.id(), + &alice.id(), + None, + AutoAcceptProofSource::None, + &bob_signer, + &bob_crypto, + ) + .await; + assert!( + matches!( + public, + Err(PlatformWalletError::PrivateContactRequestNeedsPrivateReply(contact)) + if contact == alice.id() + ), + "{public:?}" + ); + } + /// The first private send since load reads the store first. While that /// read cannot finish, here because the recipient of a request the store /// holds from Alice cannot be read, it is refused with nothing sealed or diff --git a/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/contact_requests.rs b/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/contact_requests.rs index e1c67027591..6f71edd231d 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/contact_requests.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/contact_requests.rs @@ -395,6 +395,11 @@ impl ManagedIdentity { // now-un-ignored sender's on-chain requests. self.dashpay.high_water_received_ms = None; self.dashpay.private_contact_requests_position = 0; + // A pass that read the store to its end passed over the sender's + // private requests: the store is not read for this identity until a + // pass reads it again from the rewound position, so a send reads it + // first. + self.dashpay.private_contact_requests_read = false; let mut cs = ContactChangeSet::default(); cs.unignored.insert((owner_id, *sender_id)); From efa0fc8553e7bc177fefb1d4138ceecbc4653b76 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 9 Nov 2026 08:53:15 +0700 Subject: [PATCH 061/117] fix(sdk): complete a private store chunk an older snapshot ended partway through A page fetched at once with others from a node behind (within the SDK's tolerance) may end at its snapshot's end, partway through its chunk. The walk refused it and ended the stream. It now reads the rest of the chunk from where the page stopped, into the same page, before any later chunk is yielded, so every entry up to the first page's count comes once, in order. A page larger than its chunk, or short of it for any other reason before the store's end, is still refused. Co-Authored-By: Claude Opus 5.5 --- .../src/platform/private_document_scan.rs | 73 ++++++++++++++++--- 1 file changed, 61 insertions(+), 12 deletions(-) diff --git a/packages/rs-sdk/src/platform/private_document_scan.rs b/packages/rs-sdk/src/platform/private_document_scan.rs index c3b3e4435ab..42f748849cf 100644 --- a/packages/rs-sdk/src/platform/private_document_scan.rs +++ b/packages/rs-sdk/src/platform/private_document_scan.rs @@ -514,8 +514,10 @@ where } /// The pages of `chunk_size` entries from `first`, a chunk boundary, to `total`, fetched up to -/// `max_concurrent` at once and yielded in store order. A page that does not hold its whole -/// chunk, short of the store's end, is an error: the walk would otherwise skip entries. +/// `max_concurrent` at once and yielded in store order. A page that ends at the end of an older +/// snapshot of the store, partway through its chunk, is completed with the rest of the chunk +/// read from where it stopped. A page that holds more than its chunk, or stops short of it for +/// any other reason before the store's end, is an error: the walk would otherwise skip entries. fn whole_chunk_pages( fetch: F, first: u64, @@ -532,17 +534,40 @@ where .filter(move |start| *start < total); stream::iter(starts) .map(move |start| { - let page = fetch(start); + let fetch = fetch.clone(); async move { - let entries = page.await?; - let len = entries.entries.len() as u64; - let first_position = entries.entries.first().map(|entry| entry.position); - let whole = len == chunk_size || start.saturating_add(len) >= total; - if len > chunk_size || !whole || first_position.is_some_and(|p| p != start) { - return Err(Error::Generic(format!( - "the page from position {start} holds {len} entries, not the chunk of \ - {chunk_size} the store's earlier pages set" - ))); + let mut entries = fetch(start).await?; + // A page read from an older snapshot than the first page's (a node behind, + // within the SDK's tolerance) may end at that snapshot's end, partway through + // its chunk: the rest of the chunk is read from where it stopped, into the same + // page, before any later chunk is yielded, so no entry is skipped. + loop { + let len = entries.entries.len() as u64; + let end = start.saturating_add(len); + let first_position = entries.entries.first().map(|entry| entry.position); + let refused = || { + Error::Generic(format!( + "the page from position {start} holds {len} entries, not the chunk \ + of {chunk_size} the store's earlier pages set" + )) + }; + if len > chunk_size || first_position.is_some_and(|p| p != start) { + return Err(refused()); + } + if len == chunk_size || end >= total { + break; + } + // Short of its chunk and of the walk's end: only the end of the store it + // was read from explains that. + if end < entries.total_count { + return Err(refused()); + } + let rest = fetch(end).await?; + if rest.entries.first().map(|entry| entry.position) != Some(end) { + return Err(refused()); + } + entries.entries.extend(rest.entries); + entries.total_count = rest.total_count; } Ok(PrivateDocumentPage { start_position: start, @@ -1281,6 +1306,30 @@ mod tests { assert_eq!(positions, (0..30).collect::>()); } + /// A page fetched at once with others from a node behind, whose store ended partway + /// through its chunk, is completed from where it stopped before any later chunk is + /// yielded: every entry up to the first page's count comes once, in order. + #[tokio::test] + async fn should_complete_a_chunk_an_older_snapshot_ended_partway_through() { + let fetch = |start: u64| -> PageFuture { + Box::pin(async move { + // The node answering the page from 12 is behind: its store held 14 entries. + let total = if start == 12 { 14 } else { 30 }; + Ok(drive_page(4, total, start)) + }) + }; + let pages = walk(fetch, 0, MAX_CONCURRENT_PRIVATE_DOCUMENT_PAGE_FETCHES).await; + let mut expected_start = 0; + let mut positions = Vec::new(); + for page in pages { + let page = page.expect("every page reads"); + assert_eq!(page.start_position, expected_start); + expected_start += page.entries.entries.len() as u64; + positions.extend(page.entries.entries.iter().map(|entry| entry.position)); + } + assert_eq!(positions, (0..30).collect::>()); + } + /// A page short of its chunk, before the store's end, is an error rather than a gap. #[tokio::test] async fn should_refuse_a_page_short_of_its_chunk() { From baa30926c52cf6b05796b49539aa6171d240b3b1 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 9 Nov 2026 08:53:15 +0700 Subject: [PATCH 062/117] fix(sdk): gate every type 29 broadcast and refuse it with the structured activation error - The generic `BroadcastStateTransition::broadcast`, which every broadcast path goes through, asks `ensure_network_serves` for a private document create from the shielded pool (type 29), as the dedicated send does: a transition built elsewhere, from an SDK seeded or pinned at 15 against a network verified at 14, is refused locally with `ProtocolVersionNotActive` instead of broadcast to be rejected. - The dedicated send, on an SDK still below 15 after refreshing its version, returns `ProtocolVersionNotActive` rather than `require_protocol_version`'s generic error. Co-Authored-By: Claude Opus 5.5 --- .../src/platform/transition/broadcast.rs | 34 ++++++++ ...vate_document_create_from_shielded_pool.rs | 87 +++++++++++-------- .../rs-sdk/src/sdk/protocol_gate_tests.rs | 48 +++++++++- 3 files changed, 132 insertions(+), 37 deletions(-) diff --git a/packages/rs-sdk/src/platform/transition/broadcast.rs b/packages/rs-sdk/src/platform/transition/broadcast.rs index 02afa4d4764..3949a7476d5 100644 --- a/packages/rs-sdk/src/platform/transition/broadcast.rs +++ b/packages/rs-sdk/src/platform/transition/broadcast.rs @@ -1,6 +1,7 @@ use super::broadcast_request::{checked_broadcast_request, BroadcastRequestForStateTransition}; use super::put_settings::PutSettings; use crate::error::StateTransitionBroadcastError; +use crate::platform::ProtocolRequirement; use crate::sync::retry; use crate::sync::retry_with_additional_error; use crate::{Error, Sdk}; @@ -24,6 +25,7 @@ use dpp::state_transition::proof_result::{ use dpp::state_transition::StateTransition; use dpp::system_data_contracts::SystemDataContract; use dpp::util::hash::hash_single; +use dpp::version::feature_initial_protocol_versions::PRIVATE_DOCUMENT_CREATE_FROM_SHIELDED_POOL_INITIAL_PROTOCOL_VERSION; use dpp::version::PlatformVersion; use dpp::ProtocolError; use rs_dapi_client::AddressList; @@ -38,6 +40,31 @@ use tracing::{debug, trace, warn}; const DPNS_REGISTRATION_BROADCAST_RETRIES: usize = 2; +/// The gRPC method a state transition is sent with. +pub(crate) const BROADCAST_STATE_TRANSITION_METHOD: &str = "broadcast_state_transition"; + +/// What a private document create from the shielded pool (type 29) needs: every node serves +/// it from protocol version 15. +pub(crate) fn private_document_create_requirement() -> ProtocolRequirement { + ProtocolRequirement { + protocol_version: PRIVATE_DOCUMENT_CREATE_FROM_SHIELDED_POOL_INITIAL_PROTOCOL_VERSION, + method: BROADCAST_STATE_TRANSITION_METHOD, + feature: Some("private document create from shielded pool transitions"), + } +} + +/// The protocol version `transition`'s kind needs before every node serves it, for a kind +/// newer than every node may run: nodes of an older release reject it, which the client would +/// take for the transition's own rejection. +fn transition_protocol_requirement(transition: &StateTransition) -> Option { + match transition { + StateTransition::PrivateDocumentCreateFromShieldedPool(_) => { + Some(private_document_create_requirement()) + } + _ => None, + } +} + #[async_trait::async_trait] pub trait BroadcastStateTransition { async fn broadcast(&self, sdk: &Sdk, settings: Option) -> Result<(), Error>; @@ -132,6 +159,13 @@ impl BroadcastStateTransition for StateTransition { .unwrap_or("UNKNOWN".to_string()), "broadcast: start" ); + // A kind the network may not serve yet is sent only once a verified answer shows it + // does; otherwise it is refused here with `ProtocolVersionNotActive`. + sdk.ensure_network_serves( + transition_protocol_requirement(self), + settings.map(|settings| settings.request_settings), + ) + .await?; let retry_settings = match settings { Some(s) => sdk.dapi_client_settings.override_by(s.request_settings), diff --git a/packages/rs-sdk/src/platform/transition/private_document_create_from_shielded_pool.rs b/packages/rs-sdk/src/platform/transition/private_document_create_from_shielded_pool.rs index 5db22bbf791..9f4a1c7433c 100644 --- a/packages/rs-sdk/src/platform/transition/private_document_create_from_shielded_pool.rs +++ b/packages/rs-sdk/src/platform/transition/private_document_create_from_shielded_pool.rs @@ -1,9 +1,8 @@ //! Append entries to a private document type's store, paid from the shielded pool. -use super::broadcast::BroadcastStateTransition; +use super::broadcast::{private_document_create_requirement, BroadcastStateTransition}; use super::put_settings::PutSettings; use super::validation::ensure_valid_state_transition_structure; -use crate::platform::ProtocolRequirement; use crate::{Error, Sdk}; use dpp::platform_value::{BinaryData, Identifier}; use dpp::shielded::OrchardBundleParams; @@ -22,9 +21,6 @@ use dpp::version::feature_initial_protocol_versions::PRIVATE_DOCUMENT_CREATE_FRO /// `dpp::shielded::builder::build_private_document_create_from_shielded_pool_transition`, which /// builds the whole transition from notes). Every entry must be exactly the type's /// `entrySize`, as `platform_encryption::seal_private_document_entry` builds them. -/// The gRPC method a state transition is sent with. -pub(crate) const BROADCAST_STATE_TRANSITION_METHOD: &str = "broadcast_state_transition"; - #[async_trait::async_trait] pub trait PrivateDocumentCreateFromShieldedPool { /// Build and structure-check the transition without broadcasting it, at the SDK's protocol @@ -117,22 +113,24 @@ impl PrivateDocumentCreateFromShieldedPool for Sdk { settings: Option, ) -> Result { // An unpinned SDK starts below protocol version 15, whose limits admit no entries: the - // network's version is established before the transition is built against it. - self.require_protocol_version( - PRIVATE_DOCUMENT_CREATE_FROM_SHIELDED_POOL_INITIAL_PROTOCOL_VERSION, - "private documents", - ) - .await?; + // network's version is established before the transition is built against it. One + // still below it after that refuses with the structured activation error. + let requirement = private_document_create_requirement(); + if self.protocol_version_number() < requirement.protocol_version + && self.refresh_protocol_version().await? < requirement.protocol_version + { + return Err(self.protocol_version_not_active( + requirement.protocol_version, + requirement.method, + requirement.feature, + )); + } // The SDK's own version does not show the network's: one seeded or pinned at 15 passes // the check above on a network still at 14. The transition is sent only once a verified - // answer shows the network serves it, as a read of the store is. + // answer shows the network serves it, as a read of the store is (the broadcast checks + // again, for a transition built elsewhere). self.ensure_network_serves( - Some(ProtocolRequirement { - protocol_version: - PRIVATE_DOCUMENT_CREATE_FROM_SHIELDED_POOL_INITIAL_PROTOCOL_VERSION, - method: BROADCAST_STATE_TRANSITION_METHOD, - feature: Some("private document create from shielded pool transitions"), - }), + Some(requirement), settings.map(|settings| settings.request_settings), ) .await?; @@ -153,6 +151,7 @@ impl PrivateDocumentCreateFromShieldedPool for Sdk { #[cfg(all(test, feature = "mocks"))] mod tests { use super::*; + use crate::platform::transition::broadcast::BROADCAST_STATE_TRANSITION_METHOD; use crate::sdk::test::expect_epoch_refresh; use crate::SdkBuilder; use dpp::shielded::SerializedAction; @@ -203,26 +202,42 @@ mod tests { } /// Below protocol version 15 the send is refused before the transition is built, rather - /// than built against limits that admit no entries. + /// than built against limits that admit no entries, with the structured activation error: + /// for an SDK pinned there, and for one seeded there that cannot learn a newer version. #[tokio::test] async fn should_refuse_to_send_below_protocol_version_15() { - let sdk: Sdk = SdkBuilder::new_mock() - .with_version(PlatformVersion::get(14).expect("protocol version 14")) - .build() - .expect("expected a mock sdk"); - let error = sdk - .private_document_create_from_shielded_pool( - Identifier::new([4; 32]), - "privateContactRequest".to_string(), - vec![BinaryData::new(vec![1; 589])], - 114_140_000, - bundle(), - None, - ) - .await - .expect_err("protocol version 14") - .to_string(); - assert!(error.contains("exists from protocol version 15"), "{error}"); + let version_14 = PlatformVersion::get(14).expect("protocol version 14"); + for (builder, pinned) in [ + (SdkBuilder::new_mock().with_version(version_14), true), + ( + SdkBuilder::new_mock().with_initial_version(version_14), + false, + ), + ] { + let sdk: Sdk = builder.build().expect("expected a mock sdk"); + let error = sdk + .private_document_create_from_shielded_pool( + Identifier::new([4; 32]), + "privateContactRequest".to_string(), + vec![BinaryData::new(vec![1; 589])], + 114_140_000, + bundle(), + None, + ) + .await + .expect_err("protocol version 14"); + assert!( + matches!( + error, + Error::ProtocolVersionNotActive { + method: BROADCAST_STATE_TRANSITION_METHOD, + required: 15, + .. + } + ), + "pinned {pinned}: {error:?}" + ); + } } /// The builder, which cannot ask the network, refuses below protocol version 15 naming diff --git a/packages/rs-sdk/src/sdk/protocol_gate_tests.rs b/packages/rs-sdk/src/sdk/protocol_gate_tests.rs index b9de81869ea..3eb87262780 100644 --- a/packages/rs-sdk/src/sdk/protocol_gate_tests.rs +++ b/packages/rs-sdk/src/sdk/protocol_gate_tests.rs @@ -27,6 +27,7 @@ use crate::platform::data_contracts_latest_versions::{ use crate::platform::documents::document_query::RankingDirection; use crate::platform::identity_keys_remaining_budgets::IdentityKeysRemainingBudgets; use crate::platform::private_document_entries::PrivateDocumentEntries; +use crate::platform::transition::broadcast::BroadcastStateTransition; use crate::platform::transition::private_document_create_from_shielded_pool::PrivateDocumentCreateFromShieldedPool; use crate::platform::types::epoch::current_epoch_probe_query; use crate::platform::types::identity::{ @@ -81,7 +82,9 @@ use dpp::identity::accessors::IdentityGettersV0; use dpp::platform_value::BinaryData; use dpp::prelude::{CoreBlockHeight, DataContract, Identifier, Identity}; use dpp::serialization::PlatformSerializableWithPlatformVersion; -use dpp::shielded::OrchardBundleParams; +use dpp::shielded::{OrchardBundleParams, SerializedAction}; +use dpp::state_transition::private_document_create_from_shielded_pool_transition::methods::PrivateDocumentCreateFromShieldedPoolTransitionMethodsV0; +use dpp::state_transition::private_document_create_from_shielded_pool_transition::PrivateDocumentCreateFromShieldedPoolTransition; use dpp::system_data_contracts::{load_system_data_contract, SystemDataContract}; use dpp::version::v13::PROTOCOL_VERSION_13; use dpp::version::v14::PROTOCOL_VERSION_14; @@ -2459,3 +2462,46 @@ async fn should_refuse_a_private_document_send_until_the_network_is_verified_at_ assert_eq!(probes(&sdk).await, 1, "pinned {pinned}"); } } + +/// A private document transition built elsewhere and sent through the generic broadcast is +/// gated as the dedicated send is: from an SDK at protocol version 15, seeded or pinned, on a +/// network a verified answer shows at 14, it is refused locally instead of broadcast to be +/// rejected. +#[tokio::test] +async fn should_refuse_a_generic_broadcast_of_a_private_document_transition_until_15() { + let action = SerializedAction { + nullifier: [1; 32], + rk: [2; 32], + cmx: [3; 32], + encrypted_note: vec![4; 216], + cv_net: [5; 32], + spend_auth_sig: [6; 64], + }; + let transition = PrivateDocumentCreateFromShieldedPoolTransition::try_from_bundle( + Identifier::new([4; 32]), + "privateContactRequest".to_string(), + vec![BinaryData::new(vec![1; 589])], + vec![action.clone(), action], + 114_140_000, + [7; 32], + vec![8; 64], + [9; 64], + PlatformVersion::latest(), + ) + .expect("a type 29 transition"); + for pinned in [false, true] { + let sdk = mock_sdk(PROTOCOL_VERSION_15, pinned, V14); + answer_probe(&sdk, "pv14").await; + match transition.broadcast(&sdk, None).await { + Err(Error::ProtocolVersionNotActive { + method: "broadcast_state_transition", + required: PROTOCOL_VERSION_15, + network: V14, + cause: None, + .. + }) => {} + other => panic!("pinned {pinned}: expected a local refusal, got {other:?}"), + } + assert_eq!(probes(&sdk).await, 1, "pinned {pinned}"); + } +} From 40e4f57f95968e67c367d9fe0a8dd15710b9b63e Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 9 Nov 2026 08:59:48 +0700 Subject: [PATCH 063/117] fix(sdk): keep a newer contract in the gRPC context provider, and say registration must `ContextProvider::register_data_contract` did not say a provider keeps a newer contract, and `GrpcContextProvider` replaced its cache entry unconditionally: a newer contract registered after a reader's last lookup could still be overwritten by the older one it fetched. - The trait's doc now states that a provider overriding it keeps a held contract of a higher version, deciding atomically under its own cache lock. - `Cache::put_unless` decides and inserts under the cache's lock. `GrpcContextProvider` registers through it, and its fetch on a miss inserts through it too, answering with the newer contract when one was registered meanwhile. - The writable test provider still replaces whatever it holds, documented as such, so a test shows the reader does not rely on the provider. Co-Authored-By: Claude Opus 5.5 --- packages/rs-context-provider/src/provider.rs | 6 ++ packages/rs-sdk/src/mock/provider.rs | 95 +++++++++++++++++-- .../src/platform/private_document_entries.rs | 5 +- 3 files changed, 97 insertions(+), 9 deletions(-) diff --git a/packages/rs-context-provider/src/provider.rs b/packages/rs-context-provider/src/provider.rs index a01258884ef..a80db01dac5 100644 --- a/packages/rs-context-provider/src/provider.rs +++ b/packages/rs-context-provider/src/provider.rs @@ -60,6 +60,12 @@ pub trait ContextProvider: Send + Sync { /// Providers that maintain a writable known-contracts cache (e.g. the /// mobile `TrustedHttpContextProvider`) override this; the default is a /// no-op for providers that fetch on demand or don't cache. + /// + /// A provider that overrides it keeps a held contract of a higher version + /// than `contract`, deciding atomically under its own cache lock: a + /// registration that waited on a fetch must not replace a newer contract + /// another operation registered meanwhile, which a proof of that newer + /// version would then fail to verify against. fn register_data_contract(&self, _contract: Arc) {} /// Fetches the token configuration for a specified token ID. diff --git a/packages/rs-sdk/src/mock/provider.rs b/packages/rs-sdk/src/mock/provider.rs index 68cbf1a26a9..8c790972673 100644 --- a/packages/rs-sdk/src/mock/provider.rs +++ b/packages/rs-sdk/src/mock/provider.rs @@ -6,6 +6,7 @@ use crate::sync::block_on; use crate::{Error, Sdk}; use arc_swap::ArcSwapAny; use dash_context_provider::{ContextProvider, ContextProviderError}; +use dpp::data_contract::accessors::v0::DataContractV0Getters; use dpp::data_contract::TokenConfiguration; use dpp::prelude::{CoreBlockHeight, DataContract, Identifier}; use dpp::version::PlatformVersion; @@ -212,22 +213,30 @@ impl ContextProvider for GrpcContextProvider { block_on(async move { DataContract::fetch(&sdk_cloned, contract_id).await })? .map_err(|e| ContextProviderError::DataContractFailure(e.to_string()))?; - if let Some(ref dc) = data_contract { - self.data_contracts_cache.put(*data_contract_id, dc.clone()); - }; - #[cfg(feature = "mocks")] if let Some(ref dc) = data_contract { self.dump_data_contract(dc); } - Ok(data_contract.map(Arc::new)) + // A newer contract registered while this one was fetched stays, and is the answer. + Ok(data_contract.map(|dc| { + let fetched_version = dc.version(); + self.data_contracts_cache + .put_unless(*data_contract_id, dc, |held| { + held.version() > fetched_version + }) + })) } + /// Keeps a held contract of a higher version, deciding under the cache's lock: a + /// registration that waited on a fetch does not replace a newer contract registered + /// meanwhile. fn register_data_contract(&self, contract: Arc) { - use dpp::data_contract::accessors::v0::DataContractV0Getters; + let version = contract.version(); self.data_contracts_cache - .put(contract.id(), (*contract).clone()); + .put_unless(contract.id(), (*contract).clone(), |held| { + held.version() > version + }); } fn get_token_configuration( @@ -276,4 +285,76 @@ impl Cache { let mut guard = self.inner.write().expect("cache lock poisoned"); guard.put(k, Arc::new(v)); } + + /// Insert `v` under `k` unless `keep_held` says the value held there stays, deciding + /// under the cache's lock, and return the value the cache holds after. + pub fn put_unless(&self, k: K, v: V, keep_held: impl FnOnce(&V) -> bool) -> Arc { + let mut guard = self.inner.write().expect("cache lock poisoned"); + if let Some(held) = guard.get(&k).filter(|held| keep_held(held)) { + return Arc::clone(held); + } + let value = Arc::new(v); + guard.put(k, Arc::clone(&value)); + value + } +} + +#[cfg(test)] +mod tests { + use super::*; + use dpp::data_contract::conversion::value::v0::DataContractValueConversionMethodsV0; + use dpp::platform_value::platform_value; + use dpp::platform_value::string_encoding::Encoding; + + /// A one-type contract `id` at `version`. + fn contract_at_version(id: [u8; 32], version: u32) -> DataContract { + DataContract::from_value( + platform_value!({ + "$formatVersion": "1", + "id": Identifier::from(id).to_string(Encoding::Base58), + "ownerId": Identifier::from([8; 32]).to_string(Encoding::Base58), + "version": version, + "documentSchemas": { + "note": { + "type": "object", + "properties": { + "text": { "type": "string", "maxLength": 10, "position": 0 } + }, + "additionalProperties": false + } + } + }), + false, + PlatformVersion::latest(), + ) + .expect("the contract parses") + } + + /// A registration that waited on its fetch while a newer contract was registered, as a + /// reader does between its last lookup and its registration, leaves the newer one: an + /// older registration never replaces it, a newer one does. + #[test] + fn should_keep_a_newer_contract_registered_while_an_older_one_was_fetched() { + let size = NonZeroUsize::new(8).expect("non-zero"); + let provider = + GrpcContextProvider::new(None, "127.0.0.1", 1, "user", "password", size, size, size) + .expect("a provider"); + let id = Identifier::from([0x73; 32]); + let held = |provider: &GrpcContextProvider| { + provider + .get_data_contract(&id, PlatformVersion::latest()) + .expect("the provider answers") + .map(|contract| contract.version()) + }; + + provider.register_data_contract(Arc::new(contract_at_version([0x73; 32], 3))); + provider.register_data_contract(Arc::new(contract_at_version([0x73; 32], 2))); + assert_eq!( + held(&provider), + Some(3), + "the older registration keeps the newer" + ); + provider.register_data_contract(Arc::new(contract_at_version([0x73; 32], 4))); + assert_eq!(held(&provider), Some(4), "a newer registration replaces"); + } } diff --git a/packages/rs-sdk/src/platform/private_document_entries.rs b/packages/rs-sdk/src/platform/private_document_entries.rs index ce6bcdca6f7..5e98be2d9df 100644 --- a/packages/rs-sdk/src/platform/private_document_entries.rs +++ b/packages/rs-sdk/src/platform/private_document_entries.rs @@ -198,8 +198,9 @@ pub(crate) mod held_contract_provider { /// A context provider holding `held`, or nothing, whatever version it is asked for, as a /// provider's known contracts do. A `writable` one puts a registered contract in its - /// place, whatever its version; a read-only one keeps what it holds. Its clones share - /// what it holds. + /// place, whatever its version, unlike a provider keeping the newer one as + /// `ContextProvider::register_data_contract` asks: so a test shows a reader does not rely + /// on that. A read-only one keeps what it holds. Its clones share what it holds. #[derive(Clone)] pub(crate) struct HeldContractProvider { held: Arc>>>, From f7fcc4416d61bab05e6085e22e9c224bacb07569 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 9 Nov 2026 08:59:48 +0700 Subject: [PATCH 064/117] fix(sdk): report an identity proved before its entry as a retryable stale node When both fetches proved an identity below the height of an entry it checks, and when `private_contact_requests_from_scan` was given one proved too early, the SDK returned `Error::Generic`, which `can_retry` takes as terminal, though fetching again later or from another node may succeed. Both now return `Error::StaleNode(StaleNodeError::Height)` (the entry's height, the proof's, tolerance 0). A missing identity, or one given under another id, stays a non-retryable caller error. Co-Authored-By: Claude Opus 5.5 --- .../dashpay/private_contact_request.rs | 108 ++++++++++++------ 1 file changed, 75 insertions(+), 33 deletions(-) diff --git a/packages/rs-sdk/src/platform/dashpay/private_contact_request.rs b/packages/rs-sdk/src/platform/dashpay/private_contact_request.rs index 57bf53125c2..3a8c5ea7f91 100644 --- a/packages/rs-sdk/src/platform/dashpay/private_contact_request.rs +++ b/packages/rs-sdk/src/platform/dashpay/private_contact_request.rs @@ -23,6 +23,7 @@ //! the requests it sent from the store alone. use super::contact_request::{recipient_key_purpose_is_valid, sender_key_purpose_is_valid}; +use crate::error::StaleNodeError; use crate::platform::private_document_scan::{ PrivateDocumentScan, PrivateDocumentScanKeys, ReceivedPrivateDocumentEntry, SentPrivateDocumentEntry, @@ -865,12 +866,25 @@ pub struct ProvedIdentity { pub proved_at_height: u64, } +/// The error for an identity proved at `received_height`, before the block `expected_height` of +/// an entry it checks: the node's snapshot is older than the entry, so fetching it again, from +/// another node or later, may succeed ([`StaleNodeError::Height`], which `can_retry` takes as +/// retryable). +fn stale_identity(expected_height: u64, received_height: u64) -> Error { + Error::StaleNode(StaleNodeError::Height { + expected_height, + received_height, + tolerance_blocks: 0, + }) +} + /// How many identities [`Sdk::fetch_private_contact_request_identities`] fetches at once. const PRIVATE_CONTACT_REQUEST_IDENTITY_FETCHES: usize = 4; /// [`Sdk::fetch_private_contact_request_identities`] with `fetch` proving one identity: each /// identity `needed` lists is fetched, a few at a time, until proved at or after its height, -/// twice at most. A stale second proof or a failed fetch fails the whole fetch. +/// twice at most. A stale second proof fails the whole fetch with the retryable +/// `Error::StaleNode(StaleNodeError::Height)`, and a failed fetch with its own error. async fn fetch_proved_identities( needed: &BTreeMap, fetch: F, @@ -893,11 +907,13 @@ where proved = fetch(identity_id).await?; } if proved.proved_at_height < height { - return Err(Error::Generic(format!( - "the identity {identity_id} was proved at block {}, before the block \ - {height} of an entry it checks", - proved.proved_at_height - ))); + tracing::debug!( + identity = %identity_id, + proved_at = proved.proved_at_height, + needed = height, + "an identity was proved twice before the block of an entry it checks" + ); + return Err(stale_identity(height, proved.proved_at_height)); } Ok((identity_id, proved)) }) @@ -949,8 +965,9 @@ pub fn private_contact_request_identities(scan: &PrivateDocumentScan) -> BTreeMa /// Reads the private contact requests of a scan of DashPay's `privateContactRequest` store. /// /// `identities` holds the identities [`private_contact_request_identities`] lists, each proved -/// at or after the height it lists: a missing one, or one proved earlier, fails the read, to be -/// fetched again. Each entry, received or sent, is checked before its payload is read: +/// at or after the height it lists. One proved earlier fails the read with the retryable +/// `Error::StaleNode(StaleNodeError::Height)`, to be fetched again; a missing one, or one given +/// under another id, is the caller's error, which fetching again does not lift. Each entry, received or sent, is checked before its payload is read: /// - it must be signed, and its sender identity must exist; /// - the encryption key it names must be the sender's `ECDSA_SECP256K1` `ENCRYPTION` key, not /// disabled when the entry was appended, and bound, if at all, to DashPay or its contact @@ -1036,11 +1053,13 @@ pub fn private_contact_requests_from_scan( ))) } Some(proved) if proved.proved_at_height < height => { - return Err(Error::Generic(format!( - "the identity {identity_id} was proved at block {}, before the block {height} \ - of an entry it checks: fetch it again", - proved.proved_at_height - ))) + tracing::debug!( + identity = %identity_id, + proved_at = proved.proved_at_height, + needed = height, + "an identity was proved before the block of an entry it checks" + ); + return Err(stale_identity(height, proved.proved_at_height)); } Some(_) => {} } @@ -1433,6 +1452,7 @@ mod tests { use dpp::system_data_contracts::{load_system_data_contract, SystemDataContract}; use dpp::ProtocolError; use platform_encryption::sign_private_document_signing_data; + use rs_dapi_client::CanRetry; const SENDER_ID: [u8; 32] = [0xA0; 32]; const RECIPIENT_ID: [u8; 32] = [0xB0; 32]; @@ -3005,28 +3025,35 @@ mod tests { PlatformVersion::latest() ) .is_ok()); + let stale = private_contact_requests_from_scan( + &contract, + scan.clone(), + &proved(9), + PlatformVersion::latest(), + ) + .expect_err("a sender proved a block before its entry"); assert!( - private_contact_requests_from_scan( - &contract, - scan.clone(), - &proved(9), - PlatformVersion::latest() - ) - .is_err(), - "a sender proved a block before its entry" + matches!( + stale, + Error::StaleNode(StaleNodeError::Height { + expected_height: 10, + received_height: 9, + .. + }) + ), + "{stale:?}" ); + assert!(stale.can_retry(), "fetching it again may help"); let mut without_recipient = proved(10); without_recipient.remove(&Identifier::from(RECIPIENT_ID)); - assert!( - private_contact_requests_from_scan( - &contract, - scan, - &without_recipient, - PlatformVersion::latest() - ) - .is_err(), - "an identity not given" - ); + let missing = private_contact_requests_from_scan( + &contract, + scan, + &without_recipient, + PlatformVersion::latest(), + ) + .expect_err("an identity not given"); + assert!(!missing.can_retry(), "the caller's mistake: {missing:?}"); } /// Below protocol version 15 the SDK refuses private contact requests before resolving @@ -3251,7 +3278,7 @@ mod tests { proved_at_height: 10, }) ); - assert!(fetch_proved_identities( + let twice_stale = fetch_proved_identities( &needed, scripted(vec![ proved(Some(sender_identity(None)), 9), @@ -3259,7 +3286,22 @@ mod tests { ]), ) .await - .is_err()); + .expect_err("two stale proofs"); + assert!( + matches!( + twice_stale, + Error::StaleNode(StaleNodeError::Height { + expected_height: 10, + received_height: 9, + tolerance_blocks: 0, + }) + ), + "{twice_stale:?}" + ); + assert!( + twice_stale.can_retry(), + "a later fetch may be recent enough" + ); assert!(fetch_proved_identities( &needed, scripted(vec![Err(Error::Generic("unreachable".to_string()))]), From b065ca3853256cd94bf039a342f90948ff2df71a Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 9 Nov 2026 10:08:54 +0700 Subject: [PATCH 065/117] fix(sdk): read a private contact request under an eligible alias of its key The scan credited an opened entry to the id of the first key that opened it, and the DashPay reader checked only that id. One point can be held under several ids: a disabled compressed key 1 and an enabled uncompressed encoding of it as key 2, or two registrations of the identity's keys. A request sealed to key 2 was then refused for key 1, and the cursor moved past it. `ReceivedPrivateDocumentEntry` now carries `recipient_key_ids`, every id the point was given under for the identity across its registrations, in order (`recipient_key_id` is the first). The reader checks each as sealing does at the entry's time, and reads the request once, under the first eligible id. Co-Authored-By: Claude Opus 5.5 --- .../dashpay/private_contact_request.rs | 148 +++++++++++++++--- .../src/platform/private_document_scan.rs | 36 ++++- 2 files changed, 159 insertions(+), 25 deletions(-) diff --git a/packages/rs-sdk/src/platform/dashpay/private_contact_request.rs b/packages/rs-sdk/src/platform/dashpay/private_contact_request.rs index 3a8c5ea7f91..ac6d9600c4e 100644 --- a/packages/rs-sdk/src/platform/dashpay/private_contact_request.rs +++ b/packages/rs-sdk/src/platform/dashpay/private_contact_request.rs @@ -1105,7 +1105,7 @@ pub fn private_contact_requests_from_scan( reason, }; match reader.read_received(&received) { - Ok(request) => { + Ok((request, recipient_key_id)) => { let sender_id = Identifier::from(*received.entry.sender_identity_id()); if !seen.insert(( received.recipient_identity_id, @@ -1120,7 +1120,7 @@ pub fn private_contact_requests_from_scan( block_height: received.block_height, block_time_ms: received.block_time_ms, recipient_id: received.recipient_identity_id, - recipient_key_id: received.recipient_key_id, + recipient_key_id, sender_id, sender_key_id: received.entry.sender_key_id(), commitment: *received.entry.commitment(), @@ -1188,37 +1188,50 @@ impl EntryReader<'_> { .and_then(|proved| proved.identity.as_ref()) } - /// The request a received entry holds, once its sender and recipient key are checked. + /// The request a received entry holds, once its sender and recipient key are checked, + /// with the id of the recipient key it is read under: the first of the ids the entry's + /// point is held under (`ReceivedPrivateDocumentEntry::recipient_key_ids`) that sealing + /// would have encrypted to at the entry's time. fn read_received( &self, received: &ReceivedPrivateDocumentEntry, - ) -> Result { + ) -> Result<(PrivateContactRequest, KeyID), PrivateContactRequestRejection> { self.check_sender(&received.entry, received.block_time_ms)?; - // The key the entry opened with, checked as sealing checks it, at the entry's time. - let recipient_key_problem = match self.identity(&received.recipient_identity_id) { - None => Some("the recipient identity does not exist".to_string()), - Some(recipient) => match recipient_key( + let recipient = self + .identity(&received.recipient_identity_id) + .ok_or_else(|| { + PrivateContactRequestRejection::RecipientKeyNotUsable( + "the recipient identity does not exist".to_string(), + ) + })?; + let candidates: &[KeyID] = if received.recipient_key_ids.is_empty() { + std::slice::from_ref(&received.recipient_key_id) + } else { + &received.recipient_key_ids + }; + // Each id the point is held under, checked as sealing checks it, at the entry's time. + let mut first_problem = None; + for &key_id in candidates { + let problem = match recipient_key( recipient, - received.recipient_key_id, + key_id, self.dashpay_id, Some(received.block_time_ms), ) { - Err(problem) => Some(problem), + Err(problem) => problem, Ok(public_key) if public_key != *received.entry.recipient_public_key() => { - Some(format!( - "the recipient key {} is not the key the entry is encrypted to", - received.recipient_key_id - )) + format!("the recipient key {key_id} is not the key the entry is encrypted to") } - Ok(_) => None, - }, - }; - if let Some(problem) = recipient_key_problem { - return Err(PrivateContactRequestRejection::RecipientKeyNotUsable( - problem, - )); + Ok(_) => { + let request = (self.decode)(received.entry.payload())?; + return Ok((request, key_id)); + } + }; + first_problem.get_or_insert(problem); } - (self.decode)(received.entry.payload()) + Err(PrivateContactRequestRejection::RecipientKeyNotUsable( + first_problem.unwrap_or_else(|| "no recipient key is named".to_string()), + )) } /// The request a sent entry holds, once it is checked to be the identity's own: someone @@ -2303,6 +2316,97 @@ mod tests { )); } + /// One point held under two ids, a disabled compressed key 1 and an enabled uncompressed + /// encoding of it as key 2, opens under either: the request sealed to key 2 is read once, + /// under key 2, rather than refused for key 1. So it is when the identity's keys were given + /// in two registrations, and when key 1 comes first. + #[test] + fn should_read_a_request_under_an_eligible_alias_of_the_key_it_opened_with() { + let contract = dashpay(); + let sender = sender_identity(None); + let request = smallest_request(1); + let uncompressed = IdentityPublicKey::V0(IdentityPublicKeyV0 { + id: 2, + purpose: Purpose::DECRYPTION, + security_level: SecurityLevel::MEDIUM, + contract_bounds: None, + key_type: KeyType::ECDSA_SECP256K1, + read_only: false, + data: BinaryData::new( + PublicKey::from_secret_key(&secret_key(2)) + .serialize_uncompressed() + .to_vec(), + ), + disabled_at: None, + }); + let recipient = with_key( + with_key(recipient_identity(), uncompressed), + key( + 1, + Purpose::DECRYPTION, + KeyType::ECDSA_SECP256K1, + &secret_key(2), + Some(1), + ), + ); + let signature = sign_private_document_signing_data( + &private_contact_request_signing_data( + &contract, + &sender, + 1, + &recipient, + 2, + &request, + PlatformVersion::latest(), + ) + .expect("the keys check"), + &secret_key(9), + ); + let entry = seal_private_contact_request( + &contract, + &PrivateContactRequestSender { + identity: &sender, + encryption_key_id: 1, + encryption_secret_key: &secret_key(1), + signing_key_id: 0, + signature: &signature, + }, + &recipient, + 2, + &request, + &[3; 32], + PlatformVersion::latest(), + ) + .expect("the request seals to the enabled alias") + .to_vec(); + + let recipient_id = Identifier::from(RECIPIENT_ID); + for (case, keys) in [ + ( + "one registration", + PrivateDocumentScanKeys::new() + .with_recipient(recipient_id, &[(1, secret_key(2)), (2, secret_key(2))]), + ), + ( + "two registrations", + PrivateDocumentScanKeys::new() + .with_recipient(recipient_id, &[(1, secret_key(2))]) + .with_recipient(recipient_id, &[(2, secret_key(2))]), + ), + ] { + let scan = read( + &contract, + vec![entry.clone()], + &keys, + &[sender.clone(), recipient.clone()], + ); + assert_eq!(scan.rejected, vec![], "{case}"); + assert_eq!(scan.received.len(), 1, "{case}: read once"); + assert_eq!(scan.received[0].recipient_key_id, 2, "{case}"); + assert_eq!(scan.received[0].request, request, "{case}"); + } + } + /// An entry the sender's outgoing viewing key recovers is the sender's only when it names /// the identity and key that key is derived from and one of the identity's signing keys /// signed it: someone holding the encryption key could build the others. diff --git a/packages/rs-sdk/src/platform/private_document_scan.rs b/packages/rs-sdk/src/platform/private_document_scan.rs index 42f748849cf..63bfd436533 100644 --- a/packages/rs-sdk/src/platform/private_document_scan.rs +++ b/packages/rs-sdk/src/platform/private_document_scan.rs @@ -36,6 +36,8 @@ use zeroize::{Zeroize, Zeroizing}; struct ScanRecipient { identity_id: Identifier, key_ids: Vec, + /// The public key of each of `keys`, by the same index as `key_ids`. + public_keys: Vec, keys: PrivateDocumentRecipientKeys, } @@ -73,8 +75,14 @@ pub struct ReceivedPrivateDocumentEntry { pub block_time_ms: u64, /// The identity it is to. pub recipient_identity_id: Identifier, - /// The key of that identity it is encrypted to. + /// The key of that identity it is encrypted to: the first of [`Self::recipient_key_ids`]. pub recipient_key_id: KeyID, + /// Every key id the wallet's keys were given under for that identity whose key is the point + /// the entry is encrypted to, in the order they were given. One point may be held under + /// several ids, such as a compressed and an uncompressed encoding of it, or two + /// registrations of the identity: a reader takes the first one its key policy accepts at + /// the entry's time, rather than refusing the entry for an ineligible alias. + pub recipient_key_ids: Vec, /// The opened entry. Its sender is what it claims, until /// [`PrivateDocumentScanKeys::verify_sender`] checks it. pub entry: OpenedPrivateDocumentEntry, @@ -162,6 +170,7 @@ impl PrivateDocumentScanKeys { self.recipients.push(ScanRecipient { identity_id, key_ids: keys.iter().map(|(key_id, _)| *key_id).collect(), + public_keys: secret_keys.iter().map(PublicKey::from_secret_key).collect(), keys: PrivateDocumentRecipientKeys::new(&secret_keys), }); for secret_key in &mut secret_keys { @@ -324,16 +333,37 @@ impl PrivateDocumentScanKeys { } } let received = opened_for.and_then(|(recipient, opened)| { - let recipient_key_id = opened + let opening_key_id = opened .recipient_key_index() .and_then(|index| recipient.key_ids.get(index)) .copied()?; + // Every id the point the entry is encrypted to was given under for the identity, + // across its registrations: the reader picks one its key policy accepts. + let point = opened.recipient_public_key(); + let mut recipient_key_ids = Vec::new(); + for registration in self + .recipients + .iter() + .filter(|registration| registration.identity_id == recipient.identity_id) + { + for (key_id, public_key) in + registration.key_ids.iter().zip(®istration.public_keys) + { + if public_key == point && !recipient_key_ids.contains(key_id) { + recipient_key_ids.push(*key_id); + } + } + } + if recipient_key_ids.is_empty() { + recipient_key_ids.push(opening_key_id); + } Some(ReceivedPrivateDocumentEntry { position: entry.position, block_height: entry.block_height, block_time_ms: entry.block_time_ms, recipient_identity_id: recipient.identity_id, - recipient_key_id, + recipient_key_id: recipient_key_ids[0], + recipient_key_ids, entry: opened, }) }); From 4712db7dc3bc342cf620cf673390a2a8aaa11843 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 9 Nov 2026 10:10:59 +0700 Subject: [PATCH 066/117] fix(sdk): fetch again a private page an older snapshot left empty, then fail retryably A page from a position an earlier page showed the store holds can come from a node behind, within the SDK's tolerance, whose older snapshot ends at or before that position: an empty page. The walk refused it with `Error::Generic`, which `can_retry` takes as terminal, in the pages read one at a time and in a chunk's continuation alike. Such a page is now fetched again, twice at most (`fetch_ahead_of`), and a node still behind ends the walk, after the pages before it, with the new retryable `StaleNodeError::PrivateDocumentStoreBehind { position, total_count }`. An empty page from a snapshot that holds its position is still refused as malformed. The first page is not checked: an empty first page at the store's end is the store being read up. Co-Authored-By: Claude Opus 5.5 --- packages/rs-sdk/src/error.rs | 10 ++ .../src/platform/private_document_scan.rs | 167 +++++++++++++++++- 2 files changed, 174 insertions(+), 3 deletions(-) diff --git a/packages/rs-sdk/src/error.rs b/packages/rs-sdk/src/error.rs index 899bccfa103..044fd7898a5 100644 --- a/packages/rs-sdk/src/error.rs +++ b/packages/rs-sdk/src/error.rs @@ -638,6 +638,16 @@ pub enum StaleNodeError { /// Protocol version in the response metadata signed: u32, }, + /// Server proved a private document type's store of `total_count` entries for a page from + /// `position`, which an earlier page showed the store already holds: its snapshot is older + /// than the one the read started from. A later read, or another server, can serve it. + #[error("received a private document store of {total_count} entries for a page from position {position}, which it already holds; try another server")] + PrivateDocumentStoreBehind { + /// The page's first position + position: u64, + /// How many entries the server's snapshot of the store holds + total_count: u64, + }, } #[cfg(test)] diff --git a/packages/rs-sdk/src/platform/private_document_scan.rs b/packages/rs-sdk/src/platform/private_document_scan.rs index 63bfd436533..3ef3dd318cc 100644 --- a/packages/rs-sdk/src/platform/private_document_scan.rs +++ b/packages/rs-sdk/src/platform/private_document_scan.rs @@ -17,6 +17,7 @@ use std::future::Future; use std::pin::Pin; +use crate::error::StaleNodeError; use crate::platform::private_document_entries::{ PrivateDocumentEntries, PrivateDocumentEntriesQuery, PrivateDocumentEntry, }; @@ -485,7 +486,13 @@ where total, aligned, } => { - let entries = match fetch(start).await { + // Past the first page, a page from a snapshot that ends at or before + // `start` is fetched again (`fetch_ahead_of`). + let fetched = match total { + None => fetch(start).await, + Some(_) => fetch_ahead_of(&fetch, start).await, + }; + let entries = match fetched { Ok(entries) => entries, Err(error) => return Some((Err(error), PageWalk::Done)), }; @@ -543,6 +550,41 @@ where ) } +/// How many more times a page whose snapshot of the store ends at or before its start is +/// fetched before the walk gives up on it ([`fetch_ahead_of`]). +const BEHIND_PAGE_REFETCHES: usize = 2; + +/// The page `fetch` reads from `start`, a position an earlier page showed the store holds, +/// fetched again while it comes from a snapshot that ends at or before `start`: a node behind, +/// within the SDK's tolerance, proves an older store, which says nothing about the entries +/// from `start`. Still behind after [`BEHIND_PAGE_REFETCHES`] more fetches, it fails with the +/// retryable [`StaleNodeError::PrivateDocumentStoreBehind`]. An empty page from a snapshot +/// that holds `start` is returned as it is, for the walk to refuse. +async fn fetch_ahead_of(fetch: &F, start: u64) -> Result +where + F: Fn(u64) -> PageFuture, +{ + let behind = |entries: &PrivateDocumentEntries| { + entries.entries.is_empty() && entries.total_count <= start + }; + let mut entries = fetch(start).await?; + for _ in 0..BEHIND_PAGE_REFETCHES { + if !behind(&entries) { + return Ok(entries); + } + entries = fetch(start).await?; + } + if behind(&entries) { + return Err(Error::StaleNode( + StaleNodeError::PrivateDocumentStoreBehind { + position: start, + total_count: entries.total_count, + }, + )); + } + Ok(entries) +} + /// The pages of `chunk_size` entries from `first`, a chunk boundary, to `total`, fetched up to /// `max_concurrent` at once and yielded in store order. A page that ends at the end of an older /// snapshot of the store, partway through its chunk, is completed with the rest of the chunk @@ -566,7 +608,7 @@ where .map(move |start| { let fetch = fetch.clone(); async move { - let mut entries = fetch(start).await?; + let mut entries = fetch_ahead_of(&fetch, start).await?; // A page read from an older snapshot than the first page's (a node behind, // within the SDK's tolerance) may end at that snapshot's end, partway through // its chunk: the rest of the chunk is read from where it stopped, into the same @@ -592,7 +634,7 @@ where if end < entries.total_count { return Err(refused()); } - let rest = fetch(end).await?; + let rest = fetch_ahead_of(&fetch, end).await?; if rest.entries.first().map(|entry| entry.position) != Some(end) { return Err(refused()); } @@ -1360,6 +1402,125 @@ mod tests { assert_eq!(positions, (0..30).collect::>()); } + /// A fetch of Drive's pages of a store of 30 entries in chunks of 4 where the fetch from + /// `behind` is answered, its first `times` times, by a node whose store held `behind` + /// entries: no entry from there. + fn behind_at( + behind: u64, + times: usize, + ) -> impl Fn(u64) -> PageFuture + Clone + Send + Sync + 'static { + let left = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(times)); + move |start| { + let left = std::sync::Arc::clone(&left); + Box::pin(async move { + use std::sync::atomic::Ordering::SeqCst; + if start == behind + && left + .fetch_update(SeqCst, SeqCst, |left| left.checked_sub(1)) + .is_ok() + { + return Ok(drive_page(4, behind, start)); + } + Ok(drive_page(4, 30, start)) + }) + } + } + + /// The positions of `pages`, in order, each read. + fn positions(pages: Vec>) -> Vec { + pages + .into_iter() + .flat_map(|page| { + page.expect("every page reads") + .entries + .entries + .into_iter() + .map(|entry| entry.position) + }) + .collect() + } + + /// An empty page from a node behind, whose store ended at the page's start, is fetched + /// again rather than refused: in the walk's first pages, read one at a time, and once + /// pages are fetched at once, as a page and as a chunk's continuation. Every entry comes + /// once, in order. + #[tokio::test] + async fn should_fetch_again_an_empty_page_from_a_node_behind() { + // The second page, read one at a time; a page fetched at once; the continuation of + // the chunk from 12, which a node behind ended at 14. + for behind in [4, 12] { + let pages = walk( + behind_at(behind, 1), + 0, + MAX_CONCURRENT_PRIVATE_DOCUMENT_PAGE_FETCHES, + ) + .await; + assert_eq!( + positions(pages), + (0..30).collect::>(), + "behind at {behind}" + ); + } + let continuation = { + let continued = behind_at(14, 1); + move |start: u64| -> PageFuture { + if start == 12 { + return Box::pin(async move { Ok(drive_page(4, 14, start)) }); + } + continued(start) + } + }; + let pages = walk( + continuation, + 0, + MAX_CONCURRENT_PRIVATE_DOCUMENT_PAGE_FETCHES, + ) + .await; + assert_eq!( + positions(pages), + (0..30).collect::>(), + "continuation" + ); + } + + /// A page from a node that stays behind, past the refetches, ends the walk with a + /// retryable stale-node error, after the pages before it, in both the walk's phases. + #[tokio::test] + async fn should_end_with_a_retryable_error_on_a_node_that_stays_behind() { + use rs_dapi_client::CanRetry; + + for (behind, before) in [(4, vec![0]), (12, vec![0, 4, 8])] { + let pages = walk( + behind_at(behind, usize::MAX), + 0, + MAX_CONCURRENT_PRIVATE_DOCUMENT_PAGE_FETCHES, + ) + .await; + let starts: Vec = pages + .iter() + .take_while(|page| page.is_ok()) + .flatten() + .map(|page| page.start_position) + .collect(); + assert_eq!(starts, before, "behind at {behind}"); + let error = pages + .last() + .and_then(|page| page.as_ref().err()) + .expect("the walk ends with an error"); + assert!( + matches!( + error, + Error::StaleNode(StaleNodeError::PrivateDocumentStoreBehind { + position, + total_count, + }) if *position == behind && *total_count == behind + ), + "{error:?}" + ); + assert!(error.can_retry(), "behind at {behind}"); + } + } + /// A page short of its chunk, before the store's end, is an error rather than a gap. #[tokio::test] async fn should_refuse_a_page_short_of_its_chunk() { From 8558e835ea0f9cdeb155714a756dd2f98c237954 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 9 Nov 2026 10:28:10 +0700 Subject: [PATCH 067/117] feat(sdk): schedule a private store's page walk from the chunk size its proof binds Drive reads the store's chunk power from the store element a page's proof binds, to settle the page, but dropped it from the result, so the SDK walk learned the chunk size from page lengths with up to two pages read one at a time first. - `PrivateDocumentEntries` (Drive's type, which the proof verifier and the SDK return) gains `chunk_power: Option`. Drive's read and its proof verification fill it; a page read without a proof leaves it `None`, as the response carries no chunk power (the server's unproved response would need a new proto field). The mock encodes it. - The walk takes a proved page's chunk size: every page after the first is fetched at once from the first's end. A page proved with another chunk size than the walk's ends the walk with an error. Without a chunk power, the walk learns the size from page lengths as before. A client-side read result: no consensus change. Co-Authored-By: Claude Opus 5.5 --- .../src/types/private_document_entries.rs | 2 + .../fetch_private_document_entries/v0/mod.rs | 1 + .../document/private_document_store/mod.rs | 19 ++- .../verify_private_document_entries/v0/mod.rs | 1 + packages/rs-sdk/src/mock/requests.rs | 14 ++- .../dashpay/private_contact_request.rs | 3 + .../src/platform/private_document_entries.rs | 1 + .../src/platform/private_document_scan.rs | 115 ++++++++++++++++-- 8 files changed, 143 insertions(+), 13 deletions(-) diff --git a/packages/rs-drive-proof-verifier/src/types/private_document_entries.rs b/packages/rs-drive-proof-verifier/src/types/private_document_entries.rs index a4b30103478..77e0d3a741d 100644 --- a/packages/rs-drive-proof-verifier/src/types/private_document_entries.rs +++ b/packages/rs-drive-proof-verifier/src/types/private_document_entries.rs @@ -52,8 +52,10 @@ pub(crate) fn private_document_entries_from_response( }) }) .collect::, Error>>()?; + // The response carries no chunk power; only a proof binds it. Ok(PrivateDocumentEntries { entries, total_count, + chunk_power: None, }) } diff --git a/packages/rs-drive/src/drive/document/private_document_store/fetch_private_document_entries/v0/mod.rs b/packages/rs-drive/src/drive/document/private_document_store/fetch_private_document_entries/v0/mod.rs index b5f24e073c1..41db4ca621a 100644 --- a/packages/rs-drive/src/drive/document/private_document_store/fetch_private_document_entries/v0/mod.rs +++ b/packages/rs-drive/src/drive/document/private_document_store/fetch_private_document_entries/v0/mod.rs @@ -59,6 +59,7 @@ impl Drive { Ok(PrivateDocumentEntries { entries, total_count: page.total_count, + chunk_power: Some(chunk_power), }) } } diff --git a/packages/rs-drive/src/drive/document/private_document_store/mod.rs b/packages/rs-drive/src/drive/document/private_document_store/mod.rs index 23b0c98eb5b..5a9db3e9eab 100644 --- a/packages/rs-drive/src/drive/document/private_document_store/mod.rs +++ b/packages/rs-drive/src/drive/document/private_document_store/mod.rs @@ -125,12 +125,20 @@ impl PrivateDocumentEntry { /// A page of a private type's store: its entries in append order, and how many entries the /// store holds, so a page shorter than asked for ends at the store's last entry. +/// +/// A client-side read result, not consensus data. #[derive(Clone, Debug, Default, PartialEq, Eq)] pub struct PrivateDocumentEntries { /// The page's entries, in append order. pub entries: Vec, /// How many entries the store holds. pub total_count: u64, + /// The power of two the store's chunks are sized by, as the store committed it at + /// creation: a page reads from its first position to the end of the chunk holding it + /// (`private_document_entries_page_limit`). Drive's read and a verified proof give it, + /// from the store element the proof binds; `None` for a page read without a proof, whose + /// response does not carry it. + pub chunk_power: Option, } /// The private document declaration of `document_type`, which the entries of a store are read @@ -774,6 +782,8 @@ mod tests { .expect("the root hash"); assert_eq!(proved_root_hash, root_hash, "proved from {start}"); assert_eq!(proved_page, page, "proved from {start}"); + // Both carry the chunk power the store committed: 64 entries to a chunk. + assert_eq!(proved_page.chunk_power, Some(6), "proved from {start}"); page } @@ -811,7 +821,14 @@ mod tests { let platform_version = PlatformVersion::latest(); for start in [0, 5, 64] { let page = read_and_prove(&drive, &contract, start, platform_version); - assert_eq!(page, PrivateDocumentEntries::default(), "from {start}"); + assert_eq!( + page, + PrivateDocumentEntries { + chunk_power: Some(6), + ..Default::default() + }, + "from {start}" + ); } } diff --git a/packages/rs-drive/src/verify/document/verify_private_document_entries/v0/mod.rs b/packages/rs-drive/src/verify/document/verify_private_document_entries/v0/mod.rs index ca742068663..476fb106385 100644 --- a/packages/rs-drive/src/verify/document/verify_private_document_entries/v0/mod.rs +++ b/packages/rs-drive/src/verify/document/verify_private_document_entries/v0/mod.rs @@ -89,6 +89,7 @@ impl Drive { PrivateDocumentEntries { entries, total_count: page.total_count, + chunk_power: Some(chunk_power), }, )) } diff --git a/packages/rs-sdk/src/mock/requests.rs b/packages/rs-sdk/src/mock/requests.rs index 8d9fc055b8e..eab6ef5268f 100644 --- a/packages/rs-sdk/src/mock/requests.rs +++ b/packages/rs-sdk/src/mock/requests.rs @@ -448,8 +448,8 @@ impl MockResponse for YesNoVotePollState { } /// A page of private document entries as the mock encodes it: each entry's position, block -/// height, block time and data, and the store's count. -type MockPrivateDocumentEntries = (Vec<(u64, u64, u64, Vec)>, u64); +/// height, block time and data, the store's count, and its chunk power when the page has it. +type MockPrivateDocumentEntries = (Vec<(u64, u64, u64, Vec)>, u64, Option); impl MockResponse for PrivateDocumentEntries { fn mock_serialize(&self, _sdk: &MockDashPlatformSdk) -> Vec { @@ -465,15 +465,18 @@ impl MockResponse for PrivateDocumentEntries { ) }) .collect(); - bincode::encode_to_vec((entries, self.total_count), BINCODE_CONFIG) - .expect("encode PrivateDocumentEntries") + bincode::encode_to_vec( + (entries, self.total_count, self.chunk_power), + BINCODE_CONFIG, + ) + .expect("encode PrivateDocumentEntries") } fn mock_deserialize(_sdk: &MockDashPlatformSdk, buf: &[u8]) -> Self where Self: Sized, { - let ((entries, total_count), _): (MockPrivateDocumentEntries, usize) = + let ((entries, total_count, chunk_power), _): (MockPrivateDocumentEntries, usize) = bincode::decode_from_slice(buf, BINCODE_CONFIG).expect("decode PrivateDocumentEntries"); PrivateDocumentEntries { entries: entries @@ -488,6 +491,7 @@ impl MockResponse for PrivateDocumentEntries { ) .collect(), total_count, + chunk_power, } } } diff --git a/packages/rs-sdk/src/platform/dashpay/private_contact_request.rs b/packages/rs-sdk/src/platform/dashpay/private_contact_request.rs index ac6d9600c4e..d805765faab 100644 --- a/packages/rs-sdk/src/platform/dashpay/private_contact_request.rs +++ b/packages/rs-sdk/src/platform/dashpay/private_contact_request.rs @@ -1708,6 +1708,7 @@ mod tests { fn page(entries: Vec>) -> PrivateDocumentEntries { PrivateDocumentEntries { + chunk_power: None, total_count: entries.len() as u64, entries: entries .into_iter() @@ -3619,6 +3620,7 @@ mod tests { 0, ), Some(PrivateDocumentEntries { + chunk_power: None, entries: vec![], total_count: 0, }), @@ -3678,6 +3680,7 @@ mod tests { 0, ), Some(PrivateDocumentEntries { + chunk_power: None, entries: vec![], total_count: 0, }), diff --git a/packages/rs-sdk/src/platform/private_document_entries.rs b/packages/rs-sdk/src/platform/private_document_entries.rs index 5e98be2d9df..c6badf6f9f5 100644 --- a/packages/rs-sdk/src/platform/private_document_entries.rs +++ b/packages/rs-sdk/src/platform/private_document_entries.rs @@ -288,6 +288,7 @@ mod tests { .expect("expected a mock sdk"); let query = PrivateDocumentEntriesQuery::new(Identifier::new([4; 32]), "secret", 64); let page = PrivateDocumentEntries { + chunk_power: None, entries: (64..67u64) .map(|position| PrivateDocumentEntry { position, diff --git a/packages/rs-sdk/src/platform/private_document_scan.rs b/packages/rs-sdk/src/platform/private_document_scan.rs index 3ef3dd318cc..d34ecebd882 100644 --- a/packages/rs-sdk/src/platform/private_document_scan.rs +++ b/packages/rs-sdk/src/platform/private_document_scan.rs @@ -504,6 +504,12 @@ where // partway through a chunk: it sets no chunk size, and the next page // is read from where it stopped. let ended_its_snapshot = end >= entries.total_count; + // The chunk size a proof bound, from the store element: the page + // geometry, rather than learning it from page lengths. + let proved_chunk_size = entries + .chunk_power + .and_then(|power| 1u64.checked_shl(u32::from(power))) + .filter(|chunk_size| *chunk_size > 0); let page = PrivateDocumentPage { start_position: start, entries, @@ -520,9 +526,26 @@ where PageWalk::Done, )); } - // A page from a chunk boundary that stops short of the end of the store - // it was read from is one whole chunk, a power of two, and so is every - // page after it. + // A page that stops short of the end of the store it was read from ends at + // a chunk boundary: with the chunk size its proof binds, every page after + // it is a whole chunk from there. + if let Some(chunk_size) = proved_chunk_size + .filter(|chunk_size| !ended_its_snapshot && end % chunk_size == 0) + { + return Some(( + Ok(page), + PageWalk::Concurrent(Box::pin(whole_chunk_pages( + fetch, + end, + chunk_size, + total, + max_concurrent, + ))), + )); + } + // Without it, as a page read without a proof is: a page from a chunk + // boundary that stops short of the end of the store it was read from is + // one whole chunk, a power of two, and so is every page after it. let next = if aligned && !ended_its_snapshot && len.is_power_of_two() @@ -623,7 +646,15 @@ where of {chunk_size} the store's earlier pages set" )) }; - if len > chunk_size || first_position.is_some_and(|p| p != start) { + // A page whose proof binds another chunk size than the walk's is not of + // the store the walk reads. + let other_chunks = entries.chunk_power.is_some_and(|power| { + 1u64.checked_shl(u32::from(power)) != Some(chunk_size) + }); + if len > chunk_size + || other_chunks + || first_position.is_some_and(|p| p != start) + { return Err(refused()); } if len == chunk_size || end >= total { @@ -658,9 +689,12 @@ impl Sdk { /// appended while the pages are read are left for the next read, unless the last page's /// chunk holds them. /// - /// A page reads to the end of the chunk holding its start, which the client does not - /// learn from the node: so the first page, and the next one from the chunk boundary it - /// ends at, are read one at a time. That second page, short of the store's end, is a whole + /// A page reads to the end of the chunk holding its start. A proved page carries the + /// store's chunk size, from the store element its proof binds + /// ([`PrivateDocumentEntries::chunk_power`]): every page after the first is then fetched at + /// once from the first's end. A page without it, as one read without a proof is, leaves + /// the walk to learn it: the first page, and the next one from the chunk boundary it ends + /// at, are read one at a time. That second page, short of the store's end, is a whole /// chunk, which sets the size of every page after it; those are then fetched at once. A /// page that ends at the end of the store it was read from, an older snapshot than the /// first page's, sets no size: the pages after it are read one at a time until one does. A @@ -818,6 +852,7 @@ mod tests { fn page(start: u64, entries: Vec>, total_count: u64) -> PrivateDocumentEntries { PrivateDocumentEntries { + chunk_power: None, entries: entries .into_iter() .zip(start..) @@ -1064,6 +1099,7 @@ mod tests { (start - start % chunk_size + chunk_size).min(total) }; PrivateDocumentEntries { + chunk_power: None, entries: (start..end) .map(|position| PrivateDocumentEntry { position, @@ -1521,6 +1557,71 @@ mod tests { } } + /// `fetch`, its pages carrying the chunk power `power`, as proved pages do. + fn proved( + power: u8, + fetch: impl Fn(u64) -> PageFuture + Clone + Send + Sync + 'static, + ) -> impl Fn(u64) -> PageFuture + Clone + Send + Sync + 'static { + move |start| { + let page = fetch(start); + Box::pin(async move { + let mut page = page.await?; + page.chunk_power = Some(power); + Ok(page) + }) + } + } + + /// Proved pages carry the store's chunk size: every page after the first is fetched at + /// once from the first's end, where pages without it read the second one alone to learn + /// it, and the pages still come in order. + #[tokio::test] + async fn should_fetch_at_once_every_page_after_a_proved_first_one() { + let delay = std::time::Duration::from_millis(10); + let learning = InFlight::default(); + let pages = walk(learning.fetch(4, 12, delay, None), 0, 4).await; + assert_eq!(positions(pages), (0..12).collect::>()); + assert_eq!(learning.most(), 1, "the second page alone, then the third"); + + let geometry = InFlight::default(); + let pages = walk(proved(2, geometry.fetch(4, 12, delay, None)), 0, 4).await; + assert_eq!(positions(pages), (0..12).collect::>()); + assert_eq!(geometry.most(), 2, "the second and third pages at once"); + + // From inside a chunk: the first page ends at its chunk's boundary. + let geometry = InFlight::default(); + let pages = walk(proved(2, geometry.fetch(4, 30, delay, None)), 6, 16).await; + assert_eq!(positions(pages), (6..30).collect::>()); + assert_eq!(geometry.most(), 6, "every page after the first at once"); + } + + /// A page fetched at once whose proof binds another chunk size than the first page's is + /// not of the store the walk reads: it ends the walk with an error. + #[tokio::test] + async fn should_refuse_a_page_proved_with_another_chunk_size() { + let fetch = proved(2, |start: u64| -> PageFuture { + Box::pin(async move { Ok(drive_page(4, 16, start)) }) + }); + let other = move |start: u64| -> PageFuture { + let page = fetch(start); + Box::pin(async move { + let mut page = page.await?; + if start == 8 { + page.chunk_power = Some(3); + } + Ok(page) + }) + }; + let pages = walk(other, 0, 4).await; + assert!(pages[..2].iter().all(Result::is_ok)); + let error = pages[2] + .as_ref() + .expect_err("another chunk size") + .to_string(); + assert!(error.contains("not the chunk of 4"), "{error}"); + assert_eq!(pages.len(), 3); + } + /// A page short of its chunk, before the store's end, is an error rather than a gap. #[tokio::test] async fn should_refuse_a_page_short_of_its_chunk() { From 8079cb83ed0fd9ea609cc64c372a60c9fda3a5f2 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 9 Nov 2026 10:39:45 +0700 Subject: [PATCH 068/117] feat(platform-wallet-ffi): give the private contact request outcomes their FFI codes Public sends and accepts now return `PrivateContactRequestPending` and `PrivateContactRequestNeedsPrivateReply`, but `PlatformWalletFFIResult::from` mapped neither, so both reached the send and accept exports as `ErrorUnknown` (99). The codes the FFI branch defines are taken verbatim, so that branch merges cleanly: 68 `ErrorPrivateContactRequestNeedsPrivateReply`, 71 `ErrorPrivateContactRequestPending`, with 69 `ErrorShieldedBuildRequired` and 70 `ErrorProtocolVersionNotActive` (a non-transient SDK refusal) from the same block, their registry rows, and `map_spend_result` keeping 71 and a `WalletRemovedBeforeSend` at 98. Co-Authored-By: Claude Opus 5.5 --- .../ERROR_CODE_REGISTRY.md | 12 +- packages/rs-platform-wallet-ffi/src/error.rs | 131 ++++++++++++++++++ .../src/shielded_send.rs | 22 ++- 3 files changed, 162 insertions(+), 3 deletions(-) diff --git a/packages/rs-platform-wallet-ffi/ERROR_CODE_REGISTRY.md b/packages/rs-platform-wallet-ffi/ERROR_CODE_REGISTRY.md index 211a7d709ca..e7b1c10ab1e 100644 --- a/packages/rs-platform-wallet-ffi/ERROR_CODE_REGISTRY.md +++ b/packages/rs-platform-wallet-ffi/ERROR_CODE_REGISTRY.md @@ -114,7 +114,7 @@ These are shipped ABI. Do not renumber. | 98 | `NotFound` | Sentinel — `Option` returned as an error | | 99 | `ErrorUnknown` | Sentinel — unmapped/flattened errors | -**Next allocatable integer: 61** — 27–60 are all claimed (27, 29, 31, 34–42 +**Next allocatable integer: 72**: 27–71 are all claimed (27, 29, 31, 34–42 and 46 merged; 43–45 proposed by active #4313 at head `0302b188ab`; 47 and 48 proposed by active #4356 (47 renumbered from 42, 48 from 43 — see their rows below); 49–54 proposed by active #4586 (the persister @@ -123,7 +123,11 @@ shield debits and durable recovery errors; 58 proposed by #4799 for an unavailab identity balance response; 59 proposed by #5322 for an SPV teardown that needs a process restart; 60 proposed by the pooled Core-to-shielded wallet drain for `ErrorAssetLockTooManyInputs` (Rust, Swift and Kotlin mirrors — -renumbered from 59 when #5322 took that number on `v5.1-dev`); 28, 30, +renumbered from 59 when #5322 took that number on `v5.1-dev`); 61–67 held +by open branches with no row here yet (61 `ErrorAssetLockRecipientMismatch`, +62 both `ErrorShieldedClaimBindingMismatch` and `ErrorIdentityRevisionMismatch`, +63–66 the invitation-claim preflight, 67 `ErrorShieldedTreeInconsistent`); 68 +to 71 proposed for DashPay private contact requests; 28, 30, 32 and 33 reserved). **28, 30, 32 and 33 are RESERVED, not free**: 28 and 30 were vacated when the reservation trio moved to 34–36; 32 and 33 lapsed when their in-repo owners @@ -177,6 +181,10 @@ Fork-era numbers remain in the collision history, which is immutable record. | 58 | `ErrorIdentityBalanceUnavailable` | #4799 | Proposed — Platform returned no balance for a managed identity. Retrying the read is safe; this is distinct from missing wallet ownership and must not trigger registration or funding | | 59 | `ErrorSpvProcessRestartRequired` | #5322 | Proposed — SPV startup or teardown panicked; restart the host process. Not retryable, unlike 27, which SPV start, stop and storage clear now return while an SPV teardown is still running. Typed in Rust, Swift and Kotlin (the PR also types 27 on Kotlin) | | 60 | `ErrorAssetLockTooManyInputs` | Core-to-shielded pooled Max | Proposed — an asset lock needs more inputs than one may carry (100 on Dash Core relay policy and Platform consensus): the full drain, or since #5712 a fixed amount held only in many small coins (identity registration, top-up, invitation, platform-address funding). Two meanings. On a build: nothing was broadcast, the reservation is released, no partial drain, and a rebuild after consolidating is safe. On the resume of a tracked `Built` / `Broadcast` lock over the cap (since #5714): it is not re-sent, but an earlier attempt may have reached peers; the row stays tracked and nothing is released. Rust, Swift and Kotlin retain the typed refusal. Renumbered from 59 after #5322 claimed it | +| 68 | `ErrorPrivateContactRequestNeedsPrivateReply` | DashPay private contact requests (FFI and Swift) | Proposed. The contact requests with that identity came or went privately (protocol version 15), so a `contactRequest` document to it, in reply or as a rotation, would publish the relationship: the public accept refuses a reply, and the public send refuses a request or a rotation. Nothing was sent; the host accepts with `platform_wallet_manager_accept_contact_request_replying_in_kind` (which picks the reply itself, so a host calling it does not route on 68) or `platform_wallet_manager_accept_private_contact_request`, or sends with `platform_wallet_manager_send_private_contact_request`. Taken above 61–67, which open branches already hold. Rust and Swift (raw case, `init(ffi:)` arm, typed `PlatformWalletError.privateContactRequestNeedsPrivateReply` and an `ErrorHandlingTests` pin) carry it; Kotlin falls through to `PlatformWallet.Generic`, which keeps the integer | +| 69 | `ErrorShieldedBuildRequired` | DashPay private contact requests (FFI, Swift and Kotlin) | Proposed. The operation needs the shielded pool and the library was built without the `shielded` feature. Today only `platform_wallet_manager_accept_contact_request_replying_in_kind`, exported in every build, returns it, for a request that came privately: its private reply is paid from the pool. Nothing was sent; a public or adopted reply works on such a build. Not retryable on the same build. No existing code meant "shielded unsupported" (`ErrorWalletOperation` with a not-configured message is a runtime state of a shielded build), so it takes the next free number. Swift (raw case, `init(ffi:)` arm, typed `PlatformWalletError.shieldedBuildRequired`, `ErrorHandlingTests` pin) and Kotlin (`DashSdkError.PlatformWallet.ShieldedBuildRequired`, `DashSdkErrorTest` pin) type it | +| 70 | `ErrorProtocolVersionNotActive` | DashPay private contact requests (FFI, Swift and Kotlin) | Proposed. The SDK refused a request without sending it (`dash_sdk::Error::ProtocolVersionNotActive`), because the network is proven at a protocol version below the one that serves it or the SDK is pinned below it: for example the private contact request sync on a network before protocol version 15. Expected on such a network, so a host may stay quiet about it; it lifts when the network upgrades. Mapped by the blanket conversion for every operation that propagates the SDK error. A refusal that may lift sooner (`is_transient_protocol_version_refusal`: the network's version could not be confirmed) keeps the catch-all `ErrorUnknown`, as before. Previously both fell to `ErrorUnknown`. Swift (`PlatformWalletError.protocolVersionNotActive`) and Kotlin (`DashSdkError.PlatformWallet.ProtocolVersionNotActive`) type it | +| 71 | `ErrorPrivateContactRequestPending` | DashPay private contact requests (FFI, Swift and Kotlin) | Proposed. Maps `PlatformWalletError::PrivateContactRequestPending`: a private contact request to that identity is in flight (another private send or reply reserved it before sealing), or was paid for and a short read-back sync did not find the entry in the store yet; or the identity's private store could not be read to its end before its first private send or accept since load, which the wallet reads first so that a reply paid for in an earlier process is adopted rather than paid for again. Nothing new was paid; a later private contact request sync, or a retry, settles it. Pending, not failed: the host must not submit it again. Returned by the private send, the private accept and the in-kind accept; the shielded spend contract (`map_spend_result`) keeps the code rather than flattening it to `ErrorWalletOperation`. Swift (`PlatformWalletError.privateContactRequestPending`, `ErrorHandlingTests` pin) and Kotlin (`DashSdkError.PlatformWallet.PrivateContactRequestPending`, `DashSdkErrorTest` pin) type it, and both example apps keep the submission pending | **Code 31 left this table on 2026-08-04.** `ErrorSigningKeyUnavailable` sat here as #4183's proposal until #4183 merged (`189a3abb1c`); it is now in the merged diff --git a/packages/rs-platform-wallet-ffi/src/error.rs b/packages/rs-platform-wallet-ffi/src/error.rs index 762a8e35668..269ed8ee62b 100644 --- a/packages/rs-platform-wallet-ffi/src/error.rs +++ b/packages/rs-platform-wallet-ffi/src/error.rs @@ -622,6 +622,46 @@ pub enum PlatformWalletFFIResultCode { /// coins lets either lock fit. ErrorAssetLockTooManyInputs = 60, + /// Maps `PlatformWalletError::PrivateContactRequestNeedsPrivateReply`: + /// the contact requests with that identity came or went privately + /// (protocol version 15), so a public `contactRequest` to it, in reply or + /// as a rotation, would publish the relationship. The public send and the + /// public accept refuse it, and nothing was sent. Send it with + /// `platform_wallet_manager_send_private_contact_request`, or accept it + /// with `platform_wallet_manager_accept_contact_request_replying_in_kind` + /// (which replies privately itself) or + /// `platform_wallet_manager_accept_private_contact_request`, instead. + /// 61 to 67 are claimed by open branches, so this takes 68. + ErrorPrivateContactRequestNeedsPrivateReply = 68, + + /// The operation needs the shielded pool, and this library was built + /// without the `shielded` feature: today, accepting a contact request + /// that came privately with + /// `platform_wallet_manager_accept_contact_request_replying_in_kind`, + /// whose private reply is paid from the pool. Nothing was sent. Not + /// retryable on this build; a build with shielded support accepts it. + ErrorShieldedBuildRequired = 69, + + /// The SDK refused the request without sending it: the network is proven + /// at a protocol version below the one that serves it (for example + /// DashPay's private contact request store before protocol version 15), + /// or the SDK is pinned below it. Expected on such a network; it lifts + /// when the network upgrades. A refusal that may lift sooner, because the + /// network's version could not be confirmed, keeps the catch-all code. + ErrorProtocolVersionNotActive = 70, + + /// Maps `PlatformWalletError::PrivateContactRequestPending`: a private + /// contact request to that identity is in flight (another send or reply + /// reserved it), or was paid for and is not yet read back from the store; + /// or this identity's private store could not be read to its end before + /// its first private send or accept since load (the wallet reads it first, + /// so a reply paid for in an earlier process is adopted rather than paid + /// for again). Nothing new was paid. Do not submit it again: a later + /// private contact request sync + /// (`platform_wallet_sync_private_contact_requests`) or a retry settles + /// it. + ErrorPrivateContactRequestPending = 71, + /// The named thing does not exist. /// /// Originally (and still mostly) the code for every `Option` returned as an @@ -1059,6 +1099,18 @@ impl From for PlatformWalletFFIResult { PlatformWalletError::SpvProcessRestartRequired(..) => { PlatformWalletFFIResultCode::ErrorSpvProcessRestartRequired } + // The public send or accept refused a contact whose requests came + // or went privately; the host routes it to the private send or + // accept. + PlatformWalletError::PrivateContactRequestNeedsPrivateReply(..) => { + PlatformWalletFFIResultCode::ErrorPrivateContactRequestNeedsPrivateReply + } + // A private request to the contact is in flight or paid and not + // yet read back: pending, not failed, so the host must not submit + // it again. + PlatformWalletError::PrivateContactRequestPending(..) => { + PlatformWalletFFIResultCode::ErrorPrivateContactRequestPending + } // A signer failure can also reach this blanket impl wrapped as // `PlatformWalletError::Sdk(dash_sdk::Error::Protocol(..))` (any // wallet operation that propagates the SDK error via `?`). The @@ -1168,6 +1220,15 @@ impl From for PlatformWalletFFIResult { // `MessageSigningKeyUnavailable` mapped above. By the time a // `MessageSigningFailed` reason exists, any marker in it sits // mid-string and is deliberately not matched. + // The network does not serve the request yet, as its proven + // protocol version shows. A refusal that may lift soon (the query + // for the version failed for a reason that can pass) stays on the + // catch-all: that is the network failing, which a host surfaces. + PlatformWalletError::Sdk( + sdk_error @ dash_sdk::Error::ProtocolVersionNotActive { .. }, + ) if !sdk_error.is_transient_protocol_version_refusal() => { + PlatformWalletFFIResultCode::ErrorProtocolVersionNotActive + } _ => PlatformWalletFFIResultCode::ErrorUnknown, }; let result = PlatformWalletFFIResult::err(code, error.to_string()); @@ -2692,3 +2753,73 @@ mod identity_balance_error_tests { assert!(!result.message.is_null()); } } + +#[cfg(test)] +mod private_contact_request_error_tests { + use super::*; + #[cfg(feature = "shielded")] + use crate::shielded_send::map_spend_result; + + /// A store the network does not serve yet (DashPay's private contact + /// requests before protocol version 15) is told apart from a failure, so + /// a host can stay quiet about it; a refusal that may lift soon, because + /// the version could not be confirmed, is still the network failing. + #[test] + fn should_tell_a_store_the_network_does_not_serve_yet_by_its_own_code() { + let refused = |cause: Option>| { + PlatformWalletFFIResult::from(PlatformWalletError::Sdk( + dash_sdk::Error::ProtocolVersionNotActive { + method: "get_private_document_entries", + required: 15, + feature: None, + network: 14, + pinned: false, + cause, + }, + )) + }; + let lasting = refused(None); + assert_eq!( + lasting.code, + PlatformWalletFFIResultCode::ErrorProtocolVersionNotActive + ); + assert_eq!(lasting.code as u32, 70); + + let transient = refused(Some(Box::new(dash_sdk::Error::Generic( + "no node answered".to_string(), + )))); + assert_eq!(transient.code, PlatformWalletFFIResultCode::ErrorUnknown); + } + + #[test] + fn should_tell_a_private_request_from_other_accept_failures_by_code() { + let result = PlatformWalletFFIResult::from( + PlatformWalletError::PrivateContactRequestNeedsPrivateReply([7; 32].into()), + ); + assert_eq!( + result.code, + PlatformWalletFFIResultCode::ErrorPrivateContactRequestNeedsPrivateReply + ); + assert_eq!(result.code as u32, 68); + assert!(!result.message.is_null()); + } + + /// A private request in flight, or paid and not yet read back, is pending + /// rather than failed, whichever path reports it: the blanket conversion, + /// and the shielded spend contract the private send and accepts use. + #[test] + fn should_report_a_pending_private_request_by_its_own_code() { + let pending = || PlatformWalletError::PrivateContactRequestPending([7; 32].into()); + let result = PlatformWalletFFIResult::from(pending()); + assert_eq!( + result.code, + PlatformWalletFFIResultCode::ErrorPrivateContactRequestPending + ); + assert_eq!(result.code as u32, 71); + #[cfg(feature = "shielded")] + assert_eq!( + map_spend_result(Err(pending()), "private contact request").code, + PlatformWalletFFIResultCode::ErrorPrivateContactRequestPending + ); + } +} diff --git a/packages/rs-platform-wallet-ffi/src/shielded_send.rs b/packages/rs-platform-wallet-ffi/src/shielded_send.rs index 232d8a9a9c8..04ccfc420d2 100644 --- a/packages/rs-platform-wallet-ffi/src/shielded_send.rs +++ b/packages/rs-platform-wallet-ffi/src/shielded_send.rs @@ -596,7 +596,7 @@ fn poke_sync_on_unconfirmed(result: &Result, handle: /// withdraw) to a typed FFI result, mirroring the identity-create sibling's /// code split so hosts can tell "definitively failed, safe to retry" from /// "may have executed, do NOT retry". -fn map_spend_result( +pub(crate) fn map_spend_result( result: Result<(), PlatformWalletError>, operation: &str, ) -> PlatformWalletFFIResult { @@ -620,6 +620,13 @@ fn map_spend_result( Err(e @ PlatformWalletError::ShieldedIdentityDebitPending { .. }) | Err(e @ PlatformWalletError::ShieldedRecoveryCorrupted { .. }) | Err(e @ PlatformWalletError::ShieldedRecoveryKeysRequired { .. }) => e.into(), + // A private contact request in flight, or paid and not yet read back + // from the store: nothing new was paid, and a later private sync + // settles it, so it keeps its own pending code. A wallet removed + // before the payment started keeps the lifecycle refusal code + // (`NotFound`) every other export gives it. + Err(e @ PlatformWalletError::PrivateContactRequestPending(..)) + | Err(e @ PlatformWalletError::WalletRemovedBeforeSend(..)) => e.into(), // Retryable: the wallet couldn't build the spend against any // Platform-recorded anchor yet (its commitment tree is mid-block after // an index-chunk sync). Nothing was broadcast and the notes were @@ -2992,6 +2999,19 @@ mod tests { assert!(message_of(&result).contains("this request was not started")); } + /// A private payment refused because its wallet was removed first keeps + /// the lifecycle refusal code, not the generic operation failure. + #[test] + fn map_spend_result_keeps_a_wallet_removed_before_send_at_not_found() { + let removed: Result<(), PlatformWalletError> = Err( + PlatformWalletError::WalletRemovedBeforeSend("wallet removed".to_string()), + ); + let result = map_spend_result(removed, "private contact request"); + assert_eq!(result.code, PlatformWalletFFIResultCode::NotFound); + assert_eq!(result.code as i32, 98); + assert!(message_of(&result).contains("wallet removed")); + } + /// A key-unavailable signer completion preserved under `Sdk` by the wallet /// layer must keep code 31 through `map_spend_result` (the shield from /// identity path) instead of flattening to `ErrorWalletOperation`; a From e3654c2949e282390ce35fecfdbc902f2ba50442 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 9 Nov 2026 10:42:40 +0700 Subject: [PATCH 069/117] fix(platform-wallet): wipe a private request's sealing seed when its send is dropped The ephemeral seed a private contact request is sealed with was a plain array, wiped after the awaited seal returned: a send dropped while the provider sealed skipped that and left the seed unwiped. It is now held in `Zeroizing` from before it is filled, so every exit wipes it. Co-Authored-By: Claude Opus 5.5 --- .../wallet/identity/network/private_contact_requests.rs | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/private_contact_requests.rs b/packages/rs-platform-wallet/src/wallet/identity/network/private_contact_requests.rs index c8d400807ec..b6140ddf2ed 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/private_contact_requests.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/private_contact_requests.rs @@ -59,7 +59,7 @@ use key_wallet::bip32::{DerivationPath, KeyDerivationType}; use key_wallet_manager::WalletManager; use platform_encryption::{encrypt_account_label, encrypt_extended_public_key, COMPACT_XPUB_LEN}; use tokio::sync::RwLock; -use zeroize::Zeroize; +use zeroize::Zeroizing; use super::contact_requests::{ ingest_received_requests, ingest_sent_requests, newest_received_per_sender, @@ -920,14 +920,15 @@ impl DashPayView<'_, B> { let signature = private_contact_request_signature(signer, &signing_key, &signing_data).await?; - let mut esk_seed = [0u8; 32]; + // Wiped on drop, so a send dropped while the seal is awaited wipes it too. + let mut esk_seed = Zeroizing::new([0u8; 32]); StdRng::try_from_os_rng() .map_err(|error| { PlatformWalletError::InvalidIdentityData(format!( "the OS RNG failed while sealing a private contact request: {error}" )) })? - .fill_bytes(&mut esk_seed); + .fill_bytes(&mut *esk_seed); let sealed = crypto .seal_private_contact_request( &sender_enc_path, @@ -943,7 +944,7 @@ impl DashPayView<'_, B> { self.sdk.version(), ) .await; - esk_seed.zeroize(); + drop(esk_seed); let entry = sealed?; // An entry starts with its 32-byte commitment. let commitment: [u8; 32] = entry From 8f7088bb3d4d707d89a9bc8411f120050ba66a70 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 9 Nov 2026 10:42:51 +0700 Subject: [PATCH 070/117] docs(platform-wallet): note the ShieldedStore 'static bound in the API migration Custom shielded stores that borrow from their application must own the resource, by value or behind an `Arc`, now that the trait is `'static`. Co-Authored-By: Claude Opus 5.5 --- packages/rs-platform-wallet/README.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/rs-platform-wallet/README.md b/packages/rs-platform-wallet/README.md index 1fc475bed45..d21264ad70a 100644 --- a/packages/rs-platform-wallet/README.md +++ b/packages/rs-platform-wallet/README.md @@ -146,6 +146,12 @@ The local shielded balance API introduces three Rust source compatibility change index is zero. An absent row is different from a recorded empty scan; do not infer presence merely from `last_synced_index > 0`. +## Shielded store lifetime migration + +`ShieldedStore` now requires `Send + Sync + 'static`: a spend dropped before it hands its reserved notes over releases them on a spawned task when the store is locked, and that task owns the store's `Arc`. This is a Rust source compatibility change, not a consensus one. + +A custom `ShieldedStore` that borrows from its embedding application (a `&'a` connection, configuration or buffer held in the store type) no longer satisfies the trait. Move the borrowed resource into the store, by value or behind an `Arc` (an `Arc>` or `Arc>` for one shared with the application), so the store type has no lifetime parameter. The in-repo [InMemoryShieldedStore](src/wallet/shielded/store.rs) and [FileBackedShieldedStore](src/wallet/shielded/file_store.rs) already own what they use and need no change. + ## Dependencies - `key-wallet`: Core wallet functionality From e37af8e1a662701c2861f62142a9b9849f86d583 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 9 Nov 2026 10:52:14 +0700 Subject: [PATCH 071/117] fix(platform-wallet): keep a failed spend's notes in its guard until the rollback releases them Every shielded spend handed its note-reservation guard over before its outcome was handled, so a definite failure released the notes through a separate rollback that waited for the store lock with the guard already disarmed. A spend dropped while that rollback waited (the lock held by a sync or another spend) left its notes reserved for the rest of the session. The guard is now handed over only by the outcomes that own the notes: a success, which marks them spent, and an unconfirmed spend, which sync releases. A definite failure reclaims the guard and releases the notes through it, so a drop at any point on the way still releases them. Co-Authored-By: Claude Opus 5.5 --- .../src/wallet/shielded/operations.rs | 273 +++++++++++++----- 1 file changed, 202 insertions(+), 71 deletions(-) diff --git a/packages/rs-platform-wallet/src/wallet/shielded/operations.rs b/packages/rs-platform-wallet/src/wallet/shielded/operations.rs index 8fb9048eeb3..776faeb7d8b 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/operations.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/operations.rs @@ -1489,12 +1489,11 @@ pub(in crate::wallet) async fn identity_top_up_from_pool_with_metadata< .await } .await; - // The outcome below owns the notes from here: it finalizes, keeps or - // cancels their reservation. - reservation.hand_over(); match result { Ok((proof, metadata)) => { + // It executed: the outcome owns the notes, which it marks spent. + reservation.hand_over(); record_activity_status( store, persister, @@ -1549,8 +1548,15 @@ pub(in crate::wallet) async fn identity_top_up_from_pool_with_metadata< }; Ok((proven_balance, metadata)) } - Err(e @ PlatformWalletError::ShieldedSpendUnconfirmed { .. }) => Err(e), + Err(e @ PlatformWalletError::ShieldedSpendUnconfirmed { .. }) => { + // It may have executed: the reservation stays, released by sync. + reservation.hand_over(); + Err(e) + } Err(e) => { + // It did not execute: the guard owns the notes until they are + // released, so a drop on the way still releases them. + reservation.reclaim(); record_activity_status( store, persister, @@ -1561,7 +1567,7 @@ pub(in crate::wallet) async fn identity_top_up_from_pool_with_metadata< None, ) .await; - cancel_pending(store, id, &selected_notes).await; + reservation.cancel().await; Err(e) } } @@ -1705,12 +1711,11 @@ pub(in crate::wallet) async fn private_document_create_from_pool_with_metadata< .await } .await; - // The outcome below owns the notes from here: it finalizes, keeps or - // cancels their reservation. - reservation.hand_over(); match result { Ok((_proof, metadata)) => { + // It executed: the outcome owns the notes, which it marks spent. + reservation.hand_over(); record_activity_status( store, persister, @@ -1737,8 +1742,15 @@ pub(in crate::wallet) async fn private_document_create_from_pool_with_metadata< ); Ok(metadata) } - Err(e @ PlatformWalletError::ShieldedSpendUnconfirmed { .. }) => Err(e), + Err(e @ PlatformWalletError::ShieldedSpendUnconfirmed { .. }) => { + // It may have executed: the reservation stays, released by sync. + reservation.hand_over(); + Err(e) + } Err(e) => { + // It did not execute: the guard owns the notes until they are + // released, so a drop on the way still releases them. + reservation.reclaim(); record_activity_status( store, persister, @@ -1749,7 +1761,7 @@ pub(in crate::wallet) async fn private_document_create_from_pool_with_metadata< None, ) .await; - cancel_pending(store, id, &selected_notes).await; + reservation.cancel().await; Err(e) } } @@ -1882,12 +1894,11 @@ pub async fn unshield( .await } .await; - // The outcome below owns the notes from here: it finalizes, keeps or - // cancels their reservation. - reservation.hand_over(); match result { Ok(_) => { + // It executed: the outcome owns the notes, which it marks spent. + reservation.hand_over(); record_activity_status( store, persister, @@ -1938,8 +1949,15 @@ pub async fn unshield( // the outcome is genuinely unknown here — a later scan that finds // the spend will flip the row to Confirmed (its id matches), and // until then surfacing it as Pending is honest. - Err(e @ PlatformWalletError::ShieldedSpendUnconfirmed { .. }) => Err(e), + Err(e @ PlatformWalletError::ShieldedSpendUnconfirmed { .. }) => { + // It may have executed: the reservation stays, released by sync. + reservation.hand_over(); + Err(e) + } Err(e) => { + // It did not execute: the guard owns the notes until they are + // released, so a drop on the way still releases them. + reservation.reclaim(); // Definitive failure: the spend never executed. Mark the // activity row Failed (upsert by id) before releasing notes. record_activity_status( @@ -1952,7 +1970,7 @@ pub async fn unshield( None, ) .await; - cancel_pending(store, id, &selected_notes).await; + reservation.cancel().await; Err(e) } } @@ -2067,12 +2085,11 @@ pub async fn transfer( .await } .await; - // The outcome below owns the notes from here: it finalizes, keeps or - // cancels their reservation. - reservation.hand_over(); match result { Ok(_) => { + // It executed: the outcome owns the notes, which it marks spent. + reservation.hand_over(); record_activity_status( store, persister, @@ -2103,8 +2120,15 @@ pub async fn transfer( // Ambiguous post-broadcast confirmation failure: leave the // reservation (and the Pending activity row) in place — a later // scan flips it to Confirmed (see `unshield`'s outer match). - Err(e @ PlatformWalletError::ShieldedSpendUnconfirmed { .. }) => Err(e), + Err(e @ PlatformWalletError::ShieldedSpendUnconfirmed { .. }) => { + // It may have executed: the reservation stays, released by sync. + reservation.hand_over(); + Err(e) + } Err(e) => { + // It did not execute: the guard owns the notes until they are + // released, so a drop on the way still releases them. + reservation.reclaim(); record_activity_status( store, persister, @@ -2115,7 +2139,7 @@ pub async fn transfer( None, ) .await; - cancel_pending(store, id, &selected_notes).await; + reservation.cancel().await; Err(e) } } @@ -2242,12 +2266,11 @@ pub async fn withdraw( .await } .await; - // The outcome below owns the notes from here: it finalizes, keeps or - // cancels their reservation. - reservation.hand_over(); match result { Ok(_) => { + // It executed: the outcome owns the notes, which it marks spent. + reservation.hand_over(); record_activity_status( store, persister, @@ -2278,8 +2301,15 @@ pub async fn withdraw( // Ambiguous post-broadcast confirmation failure: leave the // reservation (and the Pending activity row) in place — a later // scan flips it to Confirmed (see `unshield`'s outer match). - Err(e @ PlatformWalletError::ShieldedSpendUnconfirmed { .. }) => Err(e), + Err(e @ PlatformWalletError::ShieldedSpendUnconfirmed { .. }) => { + // It may have executed: the reservation stays, released by sync. + reservation.hand_over(); + Err(e) + } Err(e) => { + // It did not execute: the guard owns the notes until they are + // released, so a drop on the way still releases them. + reservation.reclaim(); record_activity_status( store, persister, @@ -2290,7 +2320,7 @@ pub async fn withdraw( None, ) .await; - cancel_pending(store, id, &selected_notes).await; + reservation.cancel().await; Err(e) } } @@ -2610,12 +2640,11 @@ where Ok::<(Identifier, Identity), PlatformWalletError>((identity.id(), identity)) } .await; - // The outcome below owns the notes from here: it finalizes, keeps or - // cancels their reservation. - reservation.hand_over(); match result { Ok((identity_id, identity)) => { + // It executed: the outcome owns the notes, which it marks spent. + reservation.hand_over(); record_activity_status( store, persister, @@ -2648,7 +2677,7 @@ where Ok((identity_id, identity)) } // The broadcast was accepted but its result couldn't be confirmed and the identity wasn't - // found by a direct fetch. Do NOT `cancel_pending` here: `pending_nullifiers` is in-memory + // found by a direct fetch. Do NOT release the notes here: `pending_nullifiers` is in-memory // only (see `SubwalletState`, "never persisted; the next sync after a crash reconciles"), // and `mark_spent` during nullifier sync clears matching reservations. So if the transition // actually executed, the next sync promotes these notes to spent; if it truly never landed, @@ -2656,9 +2685,16 @@ where // invite double-spend attempts against notes that may already be consumed on chain — the // very hazard this variant exists to prevent. The activity row likewise stays `Pending` // (a later scan that finds the identity's change note flips it to Confirmed). - Err(e @ PlatformWalletError::ShieldedBroadcastUnconfirmed { .. }) => Err(e), + Err(e @ PlatformWalletError::ShieldedBroadcastUnconfirmed { .. }) => { + // It may have executed: the reservation stays, released by sync. + reservation.hand_over(); + Err(e) + } Err(e) => { if error_releases_note_reservation(&e) { + // It did not execute: the guard owns the notes until they are + // released, so a drop on the way still releases them. + reservation.reclaim(); // Definitive failure: the identity was never created. Mark // the activity row Failed (upsert by id) before releasing. record_activity_status( @@ -2671,7 +2707,10 @@ where None, ) .await; - cancel_pending(store, id, &selected_notes).await; + reservation.cancel().await; + } else { + // It may have executed: the reservation stays, released by sync. + reservation.hand_over(); } Err(e) } @@ -3010,7 +3049,7 @@ async fn mark_notes_spent( /// The reservation is in-memory only — see /// [`ShieldedStore::mark_pending`] for the crash-recovery note. /// Callers must pair this with [`finalize_pending`] (on -/// broadcast success) or [`cancel_pending`] (on failure) so the +/// broadcast success) or [`NoteReservation::cancel`] (on failure) so the /// reservation is always released; until they hand it over /// ([`NoteReservation::hand_over`]), the returned guard releases it when /// the spend is dropped. @@ -3031,7 +3070,7 @@ async fn reserve_unspent_notes( /// `amount` plus the fee of `fee_kind`, which [`reserve_unspent_notes`] does /// for a positive amount. A spend paying only its fee (an /// `PrivateDocumentCreateFromShieldedPool`) reserves with an `amount` of 0. -/// Callers must pair this with [`finalize_pending`] / [`cancel_pending`], and +/// Callers must pair this with [`finalize_pending`] / [`NoteReservation::cancel`], and /// hand the guard over once they own the reservation. async fn reserve_notes_covering( sdk: &Arc, @@ -3081,7 +3120,7 @@ fn mark_notes_pending( /// future dropped before its notes have an owner that frees them (an anchor /// on each reservation, which a pruned anchor releases, or the outcome /// handling that finalizes or cancels them) leaves them spendable again, as -/// [`cancel_pending`] would, rather than excluded for the rest of the +/// [`NoteReservation::cancel`] would, rather than excluded for the rest of the /// session. The release takes the store lock when it is free, and otherwise /// runs on a spawned task once it frees; outside a runtime the notes stay /// reserved until restart. @@ -3097,6 +3136,22 @@ impl NoteReservation { pub(super) fn hand_over(&mut self) { self.handed_over = true; } + + /// Own the reservation again, for a spend now known not to have executed: dropped from + /// here on, the guard releases the notes, whatever owned them before. + pub(super) fn reclaim(&mut self) { + self.handed_over = false; + } + + /// Release the notes now, for a spend that did not execute. + /// The guard owns them until the release is done: a spend dropped while it waits for the + /// store lock still releases them on drop. + pub(super) async fn cancel(mut self) { + self.handed_over = false; + let mut store = self.store.write().await; + release_reserved_notes(&mut *store, self.id, &self.nullifiers); + self.nullifiers.clear(); + } } impl Drop for NoteReservation { @@ -3124,7 +3179,7 @@ impl Drop for NoteReservation { } } -/// Clear the reservations of `nullifiers`, as [`cancel_pending`] does. +/// Clear the reservations of `nullifiers`. fn release_reserved_notes( store: &mut S, id: SubwalletId, @@ -3145,7 +3200,7 @@ fn release_reserved_notes( /// (the fee is metered FROM the denomination, not added to the target) in one write-locked /// critical section, gating on `denomination > predicted_fee` via /// [`select_notes_for_denomination`]. Returns the selected notes, total input value, and the -/// predicted fee. Callers must pair this with [`finalize_pending`] / [`cancel_pending`], and +/// predicted fee. Callers must pair this with [`finalize_pending`] / [`NoteReservation::cancel`], and /// hand the guard over once they own the reservation. async fn reserve_unspent_notes_for_denomination( sdk: &Arc, @@ -3188,25 +3243,6 @@ async fn finalize_pending( mark_notes_spent(store, persister, wallet_id, id, notes).await } -/// Roll back a reservation when the broadcast / wait fails. -/// Best-effort and doesn't surface its own errors — the caller -/// is already returning the broadcast error. -async fn cancel_pending( - store: &Arc>, - id: SubwalletId, - notes: &[ShieldedNote], -) { - let mut store = store.write().await; - for note in notes { - if let Err(e) = store.clear_pending(id, ¬e.nullifier) { - tracing::warn!( - error = %e, - "cancel_pending: clear_pending failed; the next note scan will reconcile" - ); - } - } -} - /// Record the recorded `anchor` the spend was built against and the /// linked activity entry on every selected note's reservation, so a /// spend that ends broadcast-accepted-but-unconfirmed can be released @@ -3219,7 +3255,7 @@ async fn cancel_pending( /// reservation won't self-release on a pruned anchor (it still frees /// on the next restart), so it must never abort a spend about to /// broadcast. A success (`finalize_pending`) or a definite failure -/// (`cancel_pending`) removes the entry, so only an ambiguous +/// (`NoteReservation::cancel`) removes the entry, so only an ambiguous /// unconfirmed outcome leaves it carrying the anchor. /// /// Returns whether every reservation now carries the anchor, so a pruned @@ -3795,7 +3831,7 @@ pub(crate) fn note_rejected_private_documents( /// (Platform ran the transition and rejected it on its merits — see /// [`carries_consensus_rejection`]), means the spend never executed → /// [`PlatformWalletError::ShieldedBroadcastFailed`], and the caller -/// releases the note reservations via [`cancel_pending`]; +/// releases the note reservations via [`NoteReservation::cancel`]; /// - a no-verdict `broadcast()` failure (`AlreadyExists` proves the tx IS /// in the mempool after a lost-ACK retry; timeouts merely allow it) /// falls through to the result wait, which either proves execution or @@ -4052,13 +4088,25 @@ mod shield_recipient_tests { #[cfg(test)] mod redrive_tests { use super::*; + use crate::wallet::identity::network::PaymentStarted; use crate::wallet::shielded::store::InMemoryShieldedStore; + use dash_sdk::dpp::dashcore::Network; + use dash_sdk::dpp::prelude::CoreBlockHeight; + use dash_sdk::error::ContextProviderError; use dash_sdk::error::StateTransitionBroadcastError; + use dash_sdk::platform::ContextProvider; + use dash_sdk::sdk::AddressList; use dpp::consensus::state::shielded::nullifier_already_spent_error::NullifierAlreadySpentError; use dpp::consensus::state::state_error::StateError; use dpp::consensus::ConsensusError; + use dpp::data_contract::{DataContract, TokenConfiguration}; use dpp::serialization::PlatformSerializable; use dpp::state_transition::shield_from_identity_transition::v0::ShieldFromIdentityTransitionV0; + use dpp::version::PlatformVersion; + use futures::{poll, FutureExt}; + use grovedb_commitment_tree::ProvingKey; + use std::str::FromStr; + use std::task::Poll; fn identity_shield_transition(identity_id: [u8; 32], nonce: u64) -> StateTransition { ShieldFromIdentityTransitionV0 { @@ -4375,6 +4423,103 @@ mod redrive_tests { ); } + /// A spend that failed before anything was broadcast, here a private document payment + /// whose anchor fetch the mock refuses, and is dropped while its rollback waits for the + /// store lock another task holds, still releases its notes: the guard owns them until the + /// rollback has cleared them. + #[tokio::test] + async fn should_release_the_notes_of_a_spend_dropped_during_its_failure_rollback() { + struct UnreachableProver; + impl OrchardProver for UnreachableProver { + fn proving_key(&self) -> &ProvingKey { + panic!("nothing is proved") + } + } + + let sdk = Arc::new( + dash_sdk::SdkBuilder::new_mock() + .with_version(PlatformVersion::latest()) + .build() + .expect("a mock SDK"), + ); + let wallet_id = [0x65; 32]; + let id = SubwalletId::new(wallet_id, 0); + let store = Arc::new(RwLock::new(InMemoryShieldedStore::new())); + let note = ShieldedNote { + position: 0, + cmx: [1; 32], + nullifier: [0x5E; 32], + block_height: 1, + is_spent: false, + value: 1_000_000_000_000, + note_data: vec![], + }; + store.write().await.save_note(id, ¬e).expect("a note"); + let keys = OrchardKeySet::from_seed(&[0x42; 64], Network::Testnet, 0).expect("keys"); + let started = PaymentStarted::default(); + + // The spend waits for the store behind this task; this task's next acquisition waits + // behind the spend, so the lock passes to it right after the spend's reservation. + let held = store.write().await; + let mut spend = Box::pin(private_document_create_from_pool_with_metadata( + &sdk, + &store, + None, + wallet_id, + &keys, + 0, + Identifier::from([0x44; 32]), + "privateContactRequest", + vec![BinaryData::new(vec![1; 589])], + None, + &started, + &UnreachableProver, + )); + assert!(poll!(&mut spend).is_pending(), "waits to reserve"); + let mut again = Box::pin(store.write()); + assert!(poll!(&mut again).is_pending(), "queued behind the spend"); + drop(held); + // Reserved, refused by the anchor fetch, and waiting to roll back behind `again`. + assert!( + poll!(&mut spend).is_pending(), + "the rollback waits for the store" + ); + let Poll::Ready(contended) = poll!(&mut again) else { + panic!("the lock passes on after the reservation"); + }; + assert!( + contended.get_unspent_notes(id).expect("notes").is_empty(), + "reserved while the rollback waits" + ); + drop(spend); + drop(contended); + for _ in 0..100 { + if !store + .read() + .await + .get_unspent_notes(id) + .expect("notes") + .is_empty() + { + break; + } + tokio::task::yield_now().await; + } + assert_eq!( + store + .read() + .await + .get_unspent_notes(id) + .expect("notes") + .iter() + .map(|note| note.nullifier) + .collect::>(), + vec![note.nullifier], + "the note is spendable again" + ); + assert!(!started.is_marked()); + } + /// A reservation dropped while the store is locked is released once the /// lock frees, on a spawned task; one handed over stays. #[tokio::test] @@ -4432,17 +4577,6 @@ mod redrive_tests { /// connection and never answers. #[tokio::test] async fn should_release_the_notes_of_a_spend_dropped_during_its_anchor_fetch() { - use crate::wallet::identity::network::PaymentStarted; - use dash_sdk::dpp::dashcore::Network; - use dash_sdk::dpp::prelude::CoreBlockHeight; - use dash_sdk::error::ContextProviderError; - use dash_sdk::platform::ContextProvider; - use dash_sdk::sdk::AddressList; - use dpp::data_contract::{DataContract, TokenConfiguration}; - use dpp::version::PlatformVersion; - use grovedb_commitment_tree::ProvingKey; - use std::str::FromStr; - struct NoKeys; impl ContextProvider for NoKeys { fn get_data_contract( @@ -4551,9 +4685,6 @@ mod redrive_tests { /// lock frees, a save marks it. #[tokio::test] async fn should_mark_a_payment_started_only_once_its_record_is_saved() { - use crate::wallet::identity::network::PaymentStarted; - use futures::FutureExt; - let store = Arc::new(RwLock::new(InMemoryShieldedStore::new())); let id = SubwalletId::new([0x62; 32], 0); let transition = identity_shield_transition([0xBB; 32], 4); From d7687057e4dbb5c18c9183df77e63e4c96347926 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 9 Nov 2026 10:59:47 +0700 Subject: [PATCH 072/117] fix(platform-wallet): keep a private payment's rejection for the session so no restoration revives it A restoration of the private contact requests whose payment is unconfirmed reads the payments' records, or opens their entries, before it takes the wallet lock to reserve their pairs. A payment the shielded coordinator settled as never executing in between had its rejection taken by a send's reconciliation, which found nothing to release yet; the restoration then reserved the pair for an entry that can never be appended, and the pair stayed blocked until a restart. The wallet now keeps every rejected commitment for the session, beside the notices a reconciliation takes, and both restorations check it under the wallet lock before they reserve a pair. A rejection is noted before it can be taken, and the take and the check both run under the wallet lock, so a restoration that resumes after a reconciliation sees it. Co-Authored-By: Claude Opus 5.5 --- .../network/private_contact_requests.rs | 185 +++++++++++++++++- .../src/wallet/persister.rs | 56 ++++-- 2 files changed, 214 insertions(+), 27 deletions(-) diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/private_contact_requests.rs b/packages/rs-platform-wallet/src/wallet/identity/network/private_contact_requests.rs index b6140ddf2ed..1690684455e 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/private_contact_requests.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/private_contact_requests.rs @@ -1632,8 +1632,11 @@ impl DashPayView<'_, B> { /// request the sender's tracked request to that recipient records or /// supersedes is discharged and left alone, as /// [`Self::restore_pending_private_contact_requests`] leaves it; a - /// sender that is not one of the wallet's identities is skipped. Returns - /// the (sender, recipient) pairs restored. + /// sender that is not one of the wallet's identities is skipped. So is a + /// payment the shielded coordinator settled as never executing in this + /// session, though its record was read before that + /// (`WalletPersister::private_entry_rejected`, checked under the wallet + /// lock). Returns the (sender, recipient) pairs restored. #[cfg(feature = "shielded")] pub(crate) async fn restore_private_payments( &self, @@ -1648,6 +1651,10 @@ impl DashPayView<'_, B> { .get_wallet_info_mut(&self.wallet_id) .ok_or_else(|| PlatformWalletError::WalletNotFound(hex::encode(self.wallet_id)))?; for &(sender, recipient, account_reference, commitment) in payments { + // Settled as never executing, though its record was read before. + if self.persister.private_entry_rejected(&commitment) { + continue; + } let Some(managed) = info.identity_manager.managed_identity_mut(&sender) else { continue; }; @@ -1694,7 +1701,11 @@ impl DashPayView<'_, B> { /// Open `entries` as one page of the store and reserve the recipient of /// each the wallet's identities sent whose request is neither recorded /// nor superseded yet; the pair `paying` is retargeted to its restored - /// entry instead. + /// entry instead. An entry whose payment the shielded coordinator settled + /// as never executing in this session is left alone, though the + /// settlement came while the entries were opened + /// (`WalletPersister::private_entry_rejected`, checked under the wallet + /// lock). async fn restore_private_reservations( &self, entries: &[BinaryData], @@ -1790,6 +1801,10 @@ impl DashPayView<'_, B> { continue; } let commitment = *sent.entry.commitment(); + // Settled as never executing while its entry was opened. + if self.persister.private_entry_rejected(&commitment) { + continue; + } if paying == Some((sender, recipient)) { managed.retarget_private_reply(recipient, commitment); } else { @@ -1814,11 +1829,14 @@ mod tests { use crate::wallet::identity::network::identity_auth_derivation_path_for_type; use crate::wallet::identity::network::SeedCryptoProvider; use crate::wallet::platform_wallet::{PlatformWallet, WalletId}; + #[cfg(feature = "shielded")] + use crate::wallet::shielded::operations::note_rejected_private_documents; use crate::PlatformWalletManager; use dash_sdk::error::ContextProviderError; use dash_sdk::platform::private_document_entries::{ PrivateDocumentEntriesQuery, PrivateDocumentEntry, }; + use dash_sdk::platform::private_document_scan::PrivateDocumentScan; use dash_sdk::platform::ContextProvider; use dpp::address_funds::AddressWitness; use dpp::data_contract::{DataContract, TokenConfiguration}; @@ -3906,11 +3924,13 @@ mod tests { /// A provider deriving from a test seed that runs hooks first: before /// deriving a receiving xpub, as a slow or interactive signer pauses a - /// send's preparation, and before scanning pages. + /// send's preparation, before scanning pages, and before scanning a + /// single page, as a restoration does. struct HookedCrypto { inner: SeedCryptoProvider, before_receiving_xpub: Option, before_scan: Option, + before_page_scan: Option, } impl HookedCrypto { @@ -3919,6 +3939,7 @@ mod tests { inner: SeedCryptoProvider::from_seed(seed(phrase), Network::Testnet), before_receiving_xpub: None, before_scan: None, + before_page_scan: None, } } } @@ -4011,16 +4032,35 @@ mod tests { .await } + async fn scan_private_document_page( + &self, + keys: &PrivateDocumentKeyPaths, + data_contract_id: Identifier, + document_type_name: &str, + start_position: u64, + page: &PrivateDocumentEntries, + ) -> Result { + if let Some(hook) = &self.before_page_scan { + hook().await; + } + self.inner + .scan_private_document_page( + keys, + data_contract_id, + document_type_name, + start_position, + page, + ) + .await + } + async fn scan_private_document_pages( &self, keys: &PrivateDocumentKeyPaths, data_contract_id: Identifier, document_type_name: &str, pages: &[(u64, &PrivateDocumentEntries)], - ) -> Result< - Vec, - PlatformWalletError, - > { + ) -> Result, PlatformWalletError> { if let Some(hook) = &self.before_scan { hook().await; } @@ -4392,8 +4432,6 @@ mod tests { #[cfg(feature = "shielded")] #[tokio::test] async fn should_free_a_pair_once_its_unconfirmed_payment_is_rejected_for_good() { - use crate::wallet::shielded::operations::note_rejected_private_documents; - let alice = identity(ALICE_PHRASE, ALICE); let bob = identity(BOB_PHRASE, BOB); let alice_crypto = SeedCryptoProvider::from_seed(seed(ALICE_PHRASE), Network::Testnet); @@ -4480,6 +4518,133 @@ mod tests { assert_eq!(retry.lock().expect("retry").len(), 1, "the retry pays"); } + /// A restoration that opened an unconfirmed request's entry before the + /// shielded coordinator settled its payment as never executing, and that + /// resumes only after a send's reconciliation took the rejection, reserves + /// nothing: the rejection outlives that take for the session. Restoring + /// from the payment's record afterwards reserves nothing either, and a + /// retry pays. + #[cfg(feature = "shielded")] + #[tokio::test] + async fn should_not_restore_a_request_rejected_for_good_while_its_restore_scanned() { + let alice = identity(ALICE_PHRASE, ALICE); + let bob = identity(BOB_PHRASE, BOB); + let alice_crypto = SeedCryptoProvider::from_seed(seed(ALICE_PHRASE), Network::Testnet); + let alice_signer = signer(ALICE_PHRASE, &alice); + let (_manager, alice_wallet) = wallet(ALICE_PHRASE, &alice, &[&alice, &bob], vec![]).await; + let kept = Arc::new(Mutex::new(Vec::new())); + let unconfirmed = { + let kept = Arc::clone(&kept); + move |entry: BinaryData, started: PaymentStarted| { + started.mark(); + kept.lock().expect("kept").push(entry); + std::future::ready(Err::( + PlatformWalletError::ShieldedSpendUnconfirmed { + operation: "private document create", + reason: "the proof fetch timed out".to_string(), + }, + )) + } + }; + let sent = alice_wallet + .identity() + .dashpay() + .send_private_contact_request( + &alice.id(), + &bob.id(), + None, + AutoAcceptProofSource::None, + None, + &alice_signer, + &alice_crypto, + unconfirmed, + ) + .await; + assert!( + matches!( + sent, + Err(PlatformWalletError::ShieldedSpendUnconfirmed { .. }) + ), + "{sent:?}" + ); + let entry = kept.lock().expect("kept").remove(0); + let commitment: [u8; 32] = entry.as_slice()[..32] + .try_into() + .expect("an entry starts with its commitment"); + + // Restarted: nothing reserved until a restoration runs. + let (_restarted_manager, restarted) = + wallet(ALICE_PHRASE, &alice, &[&alice, &bob], vec![]).await; + assert!(!pending_to(&restarted, ALICE, &bob).await); + let view = restarted.identity().dashpay(); + let persister = &restarted.identity().persister; + + let scanning = Arc::new(tokio::sync::Notify::new()); + let resume = Arc::new(tokio::sync::Notify::new()); + let mut pausing = HookedCrypto::new(ALICE_PHRASE); + pausing.before_page_scan = Some({ + let (scanning, resume) = (Arc::clone(&scanning), Arc::clone(&resume)); + Arc::new(move || { + let (scanning, resume) = (Arc::clone(&scanning), Arc::clone(&resume)); + Box::pin(async move { + scanning.notify_one(); + resume.notified().await; + }) + }) + }); + let restore = + view.restore_pending_private_contact_requests(std::slice::from_ref(&entry), &pausing); + let reject = async { + scanning.notified().await; + // The redrive pass's definitive rejection of the saved transition, + // then a send's reconciliation, with nothing restored to release. + note_rejected_private_documents( + &private_contact_request_payment(entry.clone()), + Some(persister), + ); + view.release_rejected_private_requests() + .await + .expect("the release"); + assert!( + !persister.has_rejected_private_entries(), + "the rejection was taken" + ); + resume.notify_one(); + }; + let (restored, ()) = tokio::join!(restore, reject); + assert_eq!(restored.expect("the restoration"), vec![]); + assert!(!pending_to(&restarted, ALICE, &bob).await); + + let from_record = view + .restore_private_payments(&[(alice.id(), bob.id(), 0, commitment)]) + .await + .expect("the restoration from the record"); + assert_eq!(from_record, vec![]); + assert!(!pending_to(&restarted, ALICE, &bob).await); + + let retry = Arc::new(Mutex::new(Vec::new())); + let retried = view + .send_private_contact_request( + &alice.id(), + &bob.id(), + None, + AutoAcceptProofSource::None, + None, + &alice_signer, + &alice_crypto, + keep_entry(&retry), + ) + .await; + assert!( + matches!( + retried, + Err(PlatformWalletError::PrivateContactRequestPending(_)) + ), + "paid, not read back: {retried:?}" + ); + assert_eq!(retry.lock().expect("retry").len(), 1, "the retry pays"); + } + /// The type 29 transition paying for `entry`, with placeholder actions: /// as the shielded store keeps a payment's signed transition. #[cfg(feature = "shielded")] diff --git a/packages/rs-platform-wallet/src/wallet/persister.rs b/packages/rs-platform-wallet/src/wallet/persister.rs index 1cb743a34f0..597c4db53f2 100644 --- a/packages/rs-platform-wallet/src/wallet/persister.rs +++ b/packages/rs-platform-wallet/src/wallet/persister.rs @@ -63,7 +63,23 @@ pub struct WalletPersister { /// unconfirmed spend as failed for good, and the wallet releases the /// private contact request reservations held for them. Shared by the /// clones the wallet and the coordinator hold. In memory only. - rejected_private_entries: Arc>>, + rejected_private_entries: Arc>, +} + +/// The commitments [`WalletPersister::note_rejected_private_entries`] noted, +/// both sets updated under one lock. +#[derive(Default)] +struct RejectedPrivateEntries { + /// Noted since the last take, for the wallet to release what it holds for + /// them ([`WalletPersister::take_rejected_private_entries`]). + unapplied: BTreeSet<[u8; 32]>, + /// Every one noted in this session, kept after the take: a restoration that + /// read an entry before its rejection and reserves its pair after the + /// release ran checks it here first + /// ([`WalletPersister::private_entry_rejected`]). One commitment per + /// payment settled as failed for good, gone with the process, as are the + /// reservations it guards. + session: BTreeSet<[u8; 32]>, } impl WalletPersister { @@ -72,7 +88,7 @@ impl WalletPersister { wallet_id, inner, contact_payment_gate: Arc::new(tokio::sync::Mutex::new(())), - rejected_private_entries: Arc::new(std::sync::Mutex::new(BTreeSet::new())), + rejected_private_entries: Arc::default(), } } @@ -84,32 +100,38 @@ impl WalletPersister { &self, commitments: impl IntoIterator, ) { - self.rejected_private_entries - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .extend(commitments); + let mut rejected = self.rejected_entries(); + for commitment in commitments { + rejected.unapplied.insert(commitment); + rejected.session.insert(commitment); + } } /// Whether [`Self::note_rejected_private_entries`] noted any commitment /// since the last take. pub(crate) fn has_rejected_private_entries(&self) -> bool { - !self - .rejected_private_entries - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .is_empty() + !self.rejected_entries().unapplied.is_empty() } /// Take the commitments [`Self::note_rejected_private_entries`] noted /// since the last take. Take them only where they are applied with /// nothing awaited in between, as nothing keeps a set taken and dropped. pub(crate) fn take_rejected_private_entries(&self) -> BTreeSet<[u8; 32]> { - std::mem::take( - &mut *self - .rejected_private_entries - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner), - ) + std::mem::take(&mut self.rejected_entries().unapplied) + } + + /// Whether [`Self::note_rejected_private_entries`] noted `commitment` in + /// this session, taken or not: a restoration checks it under the wallet + /// lock before it reserves a pair for that entry, so a rejection noted and + /// applied while the restoration read its entries still holds. + pub(crate) fn private_entry_rejected(&self, commitment: &[u8; 32]) -> bool { + self.rejected_entries().session.contains(commitment) + } + + fn rejected_entries(&self) -> std::sync::MutexGuard<'_, RejectedPrivateEntries> { + self.rejected_private_entries + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) } /// Keep contact-pool snapshots in reservation order across Core and From cc6fef5b8c44b73a6ba09c8e5bdcca69ec8eca8e Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 9 Nov 2026 11:18:20 +0700 Subject: [PATCH 073/117] fix(platform-wallet): hold a reloaded wallet's public contact requests until its private payments are restored A private contact request whose payment is unconfirmed when the process ends is restored, from the payment's record in the shielded store, only when the wallet binds its shielded account. A public send or accept after a restart and before that bind read DashPay's store, found nothing there (the entry is appended only once the payment executes), and published the pair the private request kept off chain. A load now holds the public contact requests, sent or accepted, of each wallet that bound a shielded account before, which its persisted viewing key shows: a bind persists the key before installing anything and refuses when it cannot, so a wallet without one never bound a shielded account and is never held. While held, they are refused with `PrivateContactPaymentsNotRestored`, with nothing read, signed or broadcast. A successful restoration lifts the hold. Besides the bind, `configure_shielded` now restores every held wallet's payments from the store with no keys, and a load after it restores those it loads. The FFI reports the refusal as code 71 (pending), so hosts retry as they do for a pending request. Co-Authored-By: Claude Opus 5.5 --- .../ERROR_CODE_REGISTRY.md | 2 +- packages/rs-platform-wallet-ffi/src/error.rs | 26 ++ packages/rs-platform-wallet/README.md | 6 + packages/rs-platform-wallet/src/error.rs | 14 + .../rs-platform-wallet/src/manager/load.rs | 191 +++++++++++++- .../rs-platform-wallet/src/manager/mod.rs | 49 +++- .../network/private_contact_requests.rs | 243 +++++++++++++++--- .../src/wallet/persister.rs | 35 +++ .../src/wallet/platform_wallet.rs | 19 +- 9 files changed, 536 insertions(+), 49 deletions(-) diff --git a/packages/rs-platform-wallet-ffi/ERROR_CODE_REGISTRY.md b/packages/rs-platform-wallet-ffi/ERROR_CODE_REGISTRY.md index e7b1c10ab1e..6fc98045611 100644 --- a/packages/rs-platform-wallet-ffi/ERROR_CODE_REGISTRY.md +++ b/packages/rs-platform-wallet-ffi/ERROR_CODE_REGISTRY.md @@ -184,7 +184,7 @@ Fork-era numbers remain in the collision history, which is immutable record. | 68 | `ErrorPrivateContactRequestNeedsPrivateReply` | DashPay private contact requests (FFI and Swift) | Proposed. The contact requests with that identity came or went privately (protocol version 15), so a `contactRequest` document to it, in reply or as a rotation, would publish the relationship: the public accept refuses a reply, and the public send refuses a request or a rotation. Nothing was sent; the host accepts with `platform_wallet_manager_accept_contact_request_replying_in_kind` (which picks the reply itself, so a host calling it does not route on 68) or `platform_wallet_manager_accept_private_contact_request`, or sends with `platform_wallet_manager_send_private_contact_request`. Taken above 61–67, which open branches already hold. Rust and Swift (raw case, `init(ffi:)` arm, typed `PlatformWalletError.privateContactRequestNeedsPrivateReply` and an `ErrorHandlingTests` pin) carry it; Kotlin falls through to `PlatformWallet.Generic`, which keeps the integer | | 69 | `ErrorShieldedBuildRequired` | DashPay private contact requests (FFI, Swift and Kotlin) | Proposed. The operation needs the shielded pool and the library was built without the `shielded` feature. Today only `platform_wallet_manager_accept_contact_request_replying_in_kind`, exported in every build, returns it, for a request that came privately: its private reply is paid from the pool. Nothing was sent; a public or adopted reply works on such a build. Not retryable on the same build. No existing code meant "shielded unsupported" (`ErrorWalletOperation` with a not-configured message is a runtime state of a shielded build), so it takes the next free number. Swift (raw case, `init(ffi:)` arm, typed `PlatformWalletError.shieldedBuildRequired`, `ErrorHandlingTests` pin) and Kotlin (`DashSdkError.PlatformWallet.ShieldedBuildRequired`, `DashSdkErrorTest` pin) type it | | 70 | `ErrorProtocolVersionNotActive` | DashPay private contact requests (FFI, Swift and Kotlin) | Proposed. The SDK refused a request without sending it (`dash_sdk::Error::ProtocolVersionNotActive`), because the network is proven at a protocol version below the one that serves it or the SDK is pinned below it: for example the private contact request sync on a network before protocol version 15. Expected on such a network, so a host may stay quiet about it; it lifts when the network upgrades. Mapped by the blanket conversion for every operation that propagates the SDK error. A refusal that may lift sooner (`is_transient_protocol_version_refusal`: the network's version could not be confirmed) keeps the catch-all `ErrorUnknown`, as before. Previously both fell to `ErrorUnknown`. Swift (`PlatformWalletError.protocolVersionNotActive`) and Kotlin (`DashSdkError.PlatformWallet.ProtocolVersionNotActive`) type it | -| 71 | `ErrorPrivateContactRequestPending` | DashPay private contact requests (FFI, Swift and Kotlin) | Proposed. Maps `PlatformWalletError::PrivateContactRequestPending`: a private contact request to that identity is in flight (another private send or reply reserved it before sealing), or was paid for and a short read-back sync did not find the entry in the store yet; or the identity's private store could not be read to its end before its first private send or accept since load, which the wallet reads first so that a reply paid for in an earlier process is adopted rather than paid for again. Nothing new was paid; a later private contact request sync, or a retry, settles it. Pending, not failed: the host must not submit it again. Returned by the private send, the private accept and the in-kind accept; the shielded spend contract (`map_spend_result`) keeps the code rather than flattening it to `ErrorWalletOperation`. Swift (`PlatformWalletError.privateContactRequestPending`, `ErrorHandlingTests` pin) and Kotlin (`DashSdkError.PlatformWallet.PrivateContactRequestPending`, `DashSdkErrorTest` pin) type it, and both example apps keep the submission pending | +| 71 | `ErrorPrivateContactRequestPending` | DashPay private contact requests (FFI, Swift and Kotlin) | Proposed. Maps `PlatformWalletError::PrivateContactRequestPending`: a private contact request to that identity is in flight (another private send or reply reserved it before sealing), or was paid for and a short read-back sync did not find the entry in the store yet; or the identity's private store could not be read to its end before its first private send or accept since load, which the wallet reads first so that a reply paid for in an earlier process is adopted rather than paid for again. Nothing new was paid; a later private contact request sync, or a retry, settles it. Pending, not failed: the host must not submit it again. Returned by the private send, the private accept and the in-kind accept; the shielded spend contract (`map_spend_result`) keeps the code rather than flattening it to `ErrorWalletOperation`. Also maps `PlatformWalletError::PrivateContactPaymentsNotRestored`, returned with nothing signed or broadcast by the public send, the public accept and the in-kind accept of a wallet loaded after binding a shielded account in an earlier process, until its unconfirmed private payments are restored from the shielded store: configuring the shielded store after the load restores them with no keys, as binding the wallet's shielded account does; a retry after either goes ahead. Swift (`PlatformWalletError.privateContactRequestPending`, `ErrorHandlingTests` pin) and Kotlin (`DashSdkError.PlatformWallet.PrivateContactRequestPending`, `DashSdkErrorTest` pin) type it, and both example apps keep the submission pending | **Code 31 left this table on 2026-08-04.** `ErrorSigningKeyUnavailable` sat here as #4183's proposal until #4183 merged (`189a3abb1c`); it is now in the merged diff --git a/packages/rs-platform-wallet-ffi/src/error.rs b/packages/rs-platform-wallet-ffi/src/error.rs index 269ed8ee62b..4724881382d 100644 --- a/packages/rs-platform-wallet-ffi/src/error.rs +++ b/packages/rs-platform-wallet-ffi/src/error.rs @@ -660,6 +660,14 @@ pub enum PlatformWalletFFIResultCode { /// private contact request sync /// (`platform_wallet_sync_private_contact_requests`) or a retry settles /// it. + /// + /// Also maps `PlatformWalletError::PrivateContactPaymentsNotRestored`: a + /// wallet loaded after binding a shielded account in an earlier process + /// sends and accepts no public contact request, nothing signed or + /// broadcast, until its unconfirmed private payments are restored from the + /// shielded store. Configuring the shielded store after the load restores + /// them with no keys; so does binding the wallet's shielded account. Retry + /// after either. ErrorPrivateContactRequestPending = 71, /// The named thing does not exist. @@ -1111,6 +1119,11 @@ impl From for PlatformWalletFFIResult { PlatformWalletError::PrivateContactRequestPending(..) => { PlatformWalletFFIResultCode::ErrorPrivateContactRequestPending } + // A loaded wallet's private payments are not restored yet: the + // pair may be held, so the host retries as for a pending one. + PlatformWalletError::PrivateContactPaymentsNotRestored => { + PlatformWalletFFIResultCode::ErrorPrivateContactRequestPending + } // A signer failure can also reach this blanket impl wrapped as // `PlatformWalletError::Sdk(dash_sdk::Error::Protocol(..))` (any // wallet operation that propagates the SDK error via `?`). The @@ -2822,4 +2835,17 @@ mod private_contact_request_error_tests { PlatformWalletFFIResultCode::ErrorPrivateContactRequestPending ); } + + /// A public contact request a loaded wallet refuses until its private + /// payments are restored reaches the host as pending, to retry once the + /// shielded store is configured. + #[test] + fn should_report_unrestored_private_payments_as_pending() { + let result = + PlatformWalletFFIResult::from(PlatformWalletError::PrivateContactPaymentsNotRestored); + assert_eq!( + result.code, + PlatformWalletFFIResultCode::ErrorPrivateContactRequestPending + ); + } } diff --git a/packages/rs-platform-wallet/README.md b/packages/rs-platform-wallet/README.md index d21264ad70a..3874edc415e 100644 --- a/packages/rs-platform-wallet/README.md +++ b/packages/rs-platform-wallet/README.md @@ -121,6 +121,12 @@ Recovery preserves the original signed record with an `Unknown` outcome across r Startup errors distinguish damaged recovery metadata or undecodable signed bytes (`ShieldedRecoveryCorrupted`) from missing/replaced viewing keys (`ShieldedRecoveryKeysRequired`). Failure to recover an output set can also mean damaged ciphertext or output metadata; restore the original keys or a known-good backup before choosing explicit recovery. Invalid SQLite identifiers, nullifiers, and state flags fail startup without deleting their rows. When signed bytes are damaged but the recovery key remains readable, listing retains its account/activity identifiers and leaves the undecodable identity, nonce, and amount absent. +## Private contact request payments after a restart + +A private contact request's payment that is unconfirmed when the process ends stays in the shielded store with the request it pays for. Until its reservation is restored, nothing shows that the pair is taken: the request's entry is not in DashPay's store until the payment executes. So a wallet that `load_from_persistor` loads after it bound a shielded account (its persisted viewing key shows it) sends and accepts no public contact request until its payments are restored, and returns `PrivateContactPaymentsNotRestored` (FFI code 71, pending) with nothing signed or broadcast. A wallet with no persisted viewing key never bound a shielded account, since a bind persists the key before installing anything and refuses when it cannot, so it is never held. + +`PlatformWalletManager::configure_shielded` restores the payments of every held wallet from the store, with no keys, and a load after it restores those it loads. Binding a wallet's shielded account restores its own. Hosts that use shielded support call `configure_shielded` right after the load; a host that opens no shielded store this session leaves those wallets' public contact requests refused until it does. + ## Shielded balance API migration The local shielded balance API introduces three Rust source compatibility changes: diff --git a/packages/rs-platform-wallet/src/error.rs b/packages/rs-platform-wallet/src/error.rs index 23712a42552..43bac718309 100644 --- a/packages/rs-platform-wallet/src/error.rs +++ b/packages/rs-platform-wallet/src/error.rs @@ -678,6 +678,20 @@ pub enum PlatformWalletError { )] PrivateContactRequestPending(Identifier), + /// This wallet bound a shielded account before this process started, so + /// the shielded store may hold a private contact request payment from an + /// earlier process that is still unconfirmed, and its reservations are not + /// restored yet: no public contact request is sent or accepted, nothing + /// signed or broadcast, until they are. Opening the shielded store + /// (`PlatformWalletManager::configure_shielded`) restores them, with no + /// keys; so does binding the wallet's shielded account. + #[error( + "this wallet's unconfirmed private contact request payments from an earlier process are \ + not restored yet; configure the shielded store or bind the wallet's shielded account, \ + then try again" + )] + PrivateContactPaymentsNotRestored, + /// No DPNS `domain` document exists for the requested name (exact /// normalized-label lookup came back empty). Distinct from /// [`Self::InvalidParameter`]: the input was well-formed, the name diff --git a/packages/rs-platform-wallet/src/manager/load.rs b/packages/rs-platform-wallet/src/manager/load.rs index b1ce7e9e0f2..b5f43aa7c1b 100644 --- a/packages/rs-platform-wallet/src/manager/load.rs +++ b/packages/rs-platform-wallet/src/manager/load.rs @@ -1,6 +1,8 @@ //! Hydrate a [`PlatformWalletManager`] from its persister. use std::collections::BTreeMap; +#[cfg(feature = "shielded")] +use std::collections::BTreeSet; use std::sync::Arc; use crate::changeset::{ @@ -88,11 +90,21 @@ impl PlatformWalletManager

{ let ClientStartState { mut platform_addresses, wallets, - // Shielded restore happens lazily on `bind_shielded`, - // not here — drop the snapshot at this entry point. #[cfg(feature = "shielded")] - shielded: _, + shielded, } = start_state; + // Shielded restore happens lazily on `bind_shielded`, not here: only + // which wallets bound a shielded account is kept from the snapshot. + // A bind persists the account's viewing key before anything is + // installed, and refuses when it cannot, so a wallet with no persisted + // viewing key never bound one, and its shielded store holds no private + // contact request payment of its. A Clear deletes both together. + #[cfg(feature = "shielded")] + let bound_shielded: BTreeSet = shielded + .viewing_keys + .keys() + .map(|id| id.wallet_id) + .collect(); // Tracked (wallet-independent) masternodes ride the same startup // hydration; a failure logs and starts empty rather than failing @@ -552,6 +564,17 @@ impl PlatformWalletManager

{ platform_wallet.platform().initialize().await; } + // Before the wallet is published: a public contact request it + // sends or accepts waits until the reservations of its unconfirmed + // private payments from an earlier process are restored from the + // shielded store, which only a configured store can show. + #[cfg(feature = "shielded")] + if bound_shielded.contains(&wallet_id) { + platform_wallet + .persister() + .hold_until_private_payments_restored(); + } + let platform_wallet = Arc::new(platform_wallet); self.wallets.rcu(|wallets| { let mut wallets = std::collections::BTreeMap::clone(wallets); @@ -670,7 +693,16 @@ impl PlatformWalletManager

{ return Err(err); } - // Past the rollback point: every registration here is one this load + // Past the rollback point. A store configured before this load + // restores the held wallets' private payments now, with no keys; + // otherwise `configure_shielded` does. + #[cfg(feature = "shielded")] + if let Some(coordinator) = self.shielded_coordinator().await { + self.restore_held_private_contact_payments(&coordinator) + .await; + } + + // Every registration here is one this load // actually committed, so the transactions now have a wallet to belong // to for as long as it stays registered. // @@ -1907,3 +1939,154 @@ mod tests { ); } } + +#[cfg(all(test, feature = "shielded"))] +mod private_payment_hold_tests { + use std::collections::BTreeMap; + use std::sync::Arc; + + use key_wallet::test_utils::TestWalletContext; + use key_wallet::wallet::ManagedWalletInfo; + use key_wallet::Wallet; + + use crate::changeset::{ + ClientStartState, ClientWalletStartState, IdentityManagerStartState, PersistenceError, + PlatformWalletChangeSet, PlatformWalletPersistence, ShieldedSyncStartState, + }; + use crate::events::PlatformEventHandler; + use crate::test_support::NoopTestEventHandler; + use crate::wallet::platform_wallet::WalletId; + use crate::wallet::shielded::SubwalletId; + use crate::PlatformWalletManager; + + /// Two wallets, of which only `bound` has a persisted shielded viewing + /// key: it bound a shielded account in an earlier process. + struct ShieldedTracePersister { + wallets: Vec<(Wallet, ManagedWalletInfo)>, + bound: WalletId, + } + + impl PlatformWalletPersistence for ShieldedTracePersister { + fn store( + &self, + _wallet_id: WalletId, + _changeset: PlatformWalletChangeSet, + ) -> Result<(), PersistenceError> { + Ok(()) + } + + fn flush(&self, _wallet_id: WalletId) -> Result<(), PersistenceError> { + Ok(()) + } + + fn load(&self) -> Result { + let wallets = self + .wallets + .iter() + .map(|(wallet, managed)| { + ( + wallet.compute_wallet_id(), + ClientWalletStartState { + wallet: wallet.clone(), + wallet_info: managed.clone(), + identity_manager: IdentityManagerStartState::default(), + unused_asset_locks: BTreeMap::new(), + unconfirmed_outgoing_txs: Vec::new(), + dashpay_backfill: Default::default(), + }, + ) + }) + .collect(); + let mut shielded = ShieldedSyncStartState::default(); + shielded + .viewing_keys + .insert(SubwalletId::new(self.bound, 0), vec![0; 96]); + Ok(ClientStartState { + wallets, + shielded, + ..Default::default() + }) + } + } + + /// A manager over two wallets, only the first of which bound a shielded + /// account before; with their ids. + fn manager() -> ( + Arc>, + WalletId, + WalletId, + ) { + let bound = TestWalletContext::new_random(); + let unbound = TestWalletContext::new_random(); + let bound_id = bound.wallet.compute_wallet_id(); + let unbound_id = unbound.wallet.compute_wallet_id(); + let persister = ShieldedTracePersister { + wallets: vec![ + (bound.wallet, bound.managed_wallet), + (unbound.wallet, unbound.managed_wallet), + ], + bound: bound_id, + }; + let sdk = Arc::new(dash_sdk::SdkBuilder::new_mock().build().expect("mock sdk")); + let event_handler: Arc = Arc::new(NoopTestEventHandler); + let manager = Arc::new(PlatformWalletManager::new( + sdk, + Arc::new(persister), + event_handler, + )); + (manager, bound_id, unbound_id) + } + + /// Whether `wallet_id`'s public contact requests wait for its private + /// payments to be restored. + async fn held( + manager: &PlatformWalletManager, + wallet_id: WalletId, + ) -> bool { + manager + .get_wallet(&wallet_id) + .await + .expect("loaded") + .persister() + .private_payments_unrestored() + } + + /// A loaded wallet that bound a shielded account before holds its public + /// contact requests until the shielded store is configured, which + /// restores its private payments with no keys; one that never bound a + /// shielded account is never held. + #[tokio::test] + async fn should_hold_a_loaded_wallet_that_bound_a_shielded_account_until_the_store_opens() { + let (manager, bound, unbound) = manager(); + manager.load_from_persistor().await.expect("loaded"); + assert!( + held(&manager, bound).await, + "may have an unrestored payment" + ); + assert!( + !held(&manager, unbound).await, + "never bound a shielded account" + ); + + let dir = tempfile::tempdir().expect("a directory"); + manager + .configure_shielded(dir.path().join("tree.sqlite")) + .await + .expect("configured"); + assert!(!held(&manager, bound).await, "restored from the store"); + } + + /// A wallet loaded once the shielded store is configured is restored by + /// the load itself. + #[tokio::test] + async fn should_restore_a_wallet_loaded_once_the_store_is_configured() { + let (manager, bound, _) = manager(); + let dir = tempfile::tempdir().expect("a directory"); + manager + .configure_shielded(dir.path().join("tree.sqlite")) + .await + .expect("configured"); + manager.load_from_persistor().await.expect("loaded"); + assert!(!held(&manager, bound).await, "restored by the load"); + } +} diff --git a/packages/rs-platform-wallet/src/manager/mod.rs b/packages/rs-platform-wallet/src/manager/mod.rs index 63de4e4d49b..81918982df1 100644 --- a/packages/rs-platform-wallet/src/manager/mod.rs +++ b/packages/rs-platform-wallet/src/manager/mod.rs @@ -47,6 +47,8 @@ use crate::wallet::asset_lock::LockNotifyHandler; use crate::wallet::core::{BalanceUpdateHandler, SpendObservationHandler}; use crate::wallet::identity::network::DashPayPaymentHandler; use crate::wallet::platform_wallet::{PlatformWalletInfo, WalletId}; +#[cfg(feature = "shielded")] +use crate::wallet::shielded::NetworkShieldedCoordinator; use crate::wallet::PlatformWallet; /// Registry key identifying each background worker the manager joins at @@ -900,6 +902,14 @@ impl PlatformWalletManager

{ /// per-wallet bind looks up the coordinator from the manager /// and errors out if it hasn't been configured. /// + /// Once the store is open, restores the reservations of the unconfirmed + /// private contact request payments of every wallet the manager holds + /// whose public contact requests a load held for them, with no keys + /// ([`Self::restore_held_private_contact_payments`]). Call it right after + /// [`Self::load_from_persistor`] for a wallet that bound a shielded + /// account before: until then, its public contact requests are refused + /// with [`PlatformWalletError::PrivateContactPaymentsNotRestored`]. + /// /// Subsequent calls with the **same** `db_path` are a no-op /// (configuration is idempotent at the path level). A second /// call with a **different** path returns @@ -918,6 +928,9 @@ impl PlatformWalletManager

{ let mut slot = self.shielded_coordinator.write().await; if let Some(existing) = slot.as_ref() { if existing.db_path() == db_path.as_path() { + let existing = Arc::clone(existing); + drop(slot); + self.restore_held_private_contact_payments(&existing).await; return Ok(()); } return Err(crate::error::PlatformWalletError::ShieldedStoreError( @@ -969,10 +982,44 @@ impl PlatformWalletManager

{ .on_shielded_tree_progress(leaves_committed, total_target); }, ))); - *slot = Some(coordinator); + *slot = Some(Arc::clone(&coordinator)); + drop(slot); + self.restore_held_private_contact_payments(&coordinator) + .await; Ok(()) } + /// Restore from `coordinator`'s store, with no keys, the reservations of + /// the unconfirmed private contact request payments of every wallet this + /// manager holds whose public contact requests wait for them + /// ([`PlatformWallet::restore_private_contact_payments`]): a wallet loaded + /// after binding a shielded account in an earlier process. A wallet whose + /// restoration fails stays held, until a bind or a later call restores it. + #[cfg(feature = "shielded")] + pub(crate) async fn restore_held_private_contact_payments( + &self, + coordinator: &Arc, + ) { + let held: Vec> = self + .wallets + .load() + .values() + .filter(|wallet| wallet.persister().private_payments_unrestored()) + .cloned() + .collect(); + for wallet in held { + if let Err(error) = wallet.restore_private_contact_payments(coordinator).await { + tracing::warn!( + wallet_id = %hex::encode(wallet.wallet_id()), + %error, + "unconfirmed private contact request payments not restored; the wallet's \ + public contact requests stay held until a bind or a later configure \ + restores them" + ); + } + } + } + /// Snapshot of the currently-installed shielded coordinator, /// or `None` if `configure_shielded` hasn't run yet on this /// manager. Cloned `Arc` so callers can hold the coordinator diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/private_contact_requests.rs b/packages/rs-platform-wallet/src/wallet/identity/network/private_contact_requests.rs index 1690684455e..3b49fd69a98 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/private_contact_requests.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/private_contact_requests.rs @@ -706,6 +706,12 @@ impl DashPayView<'_, B> { /// say whether the network or this SDK is below it, and an SDK pinned /// below it refuses a store the network has. Without that evidence the /// refusal comes back as it is, and nothing is sent. + /// + /// Refused first, with nothing read, while the wallet's unconfirmed + /// private payments from an earlier process may be unrestored + /// ([`PlatformWalletError::PrivateContactPaymentsNotRestored`]): such a + /// payment's entry is not in the store until it executes, so no read + /// shows the pair it holds; only the shielded store's record of it does. pub(super) async fn read_private_store_before_deciding( &self, identity_id: &Identifier, @@ -715,6 +721,9 @@ impl DashPayView<'_, B> { where C: ContactCryptoProvider + Sync, { + if self.persister.private_payments_unrestored() { + return Err(PlatformWalletError::PrivateContactPaymentsNotRestored); + } match self .read_private_store_before_sending(identity_id, recipient, crypto) .await @@ -1825,12 +1834,21 @@ mod tests { ClientStartState, IdentityManagerStartState, PersistenceCapabilities, PersistenceError, PlatformWalletChangeSet, PlatformWalletPersistence, }; + #[cfg(feature = "shielded")] + use crate::changeset::{ClientWalletStartState, ShieldedSyncStartState}; use crate::events::{EventHandler, PlatformEventHandler}; use crate::wallet::identity::network::identity_auth_derivation_path_for_type; use crate::wallet::identity::network::SeedCryptoProvider; use crate::wallet::platform_wallet::{PlatformWallet, WalletId}; #[cfg(feature = "shielded")] - use crate::wallet::shielded::operations::note_rejected_private_documents; + use crate::wallet::shielded::operations::{ + arm_redrive_before_broadcast, note_rejected_private_documents, + }; + #[cfg(feature = "shielded")] + use crate::wallet::shielded::{ + FileBackedShieldedStore, NetworkShieldedCoordinator, OrchardKeySet, PrivateContactPayment, + SubwalletId, + }; use crate::PlatformWalletManager; use dash_sdk::error::ContextProviderError; use dash_sdk::platform::private_document_entries::{ @@ -2108,6 +2126,40 @@ mod tests { platform_version: &'static PlatformVersion, verified_network: Option, ) -> (PlatformWalletManager

, Arc) { + let sdk = dashpay_sdk(known, entries, platform_version, verified_network).await; + let manager = + PlatformWalletManager::new(Arc::new(sdk), persistence, Arc::new(NoopEventHandler)); + let wallet = manager + .create_wallet_from_seed_bytes( + Network::Testnet, + &seed(phrase), + WalletAccountCreationOptions::Default, + Some(0), + ) + .await + .expect("wallet"); + { + let iw = wallet.identity(); + let mut wm = iw.wallet_manager.write().await; + let info = wm + .get_wallet_info_mut(&wallet.wallet_id()) + .expect("wallet info"); + info.identity_manager + .add_identity(own.clone(), 0, wallet.wallet_id(), &iw.persister) + .expect("own identity"); + } + (manager, wallet) + } + + /// The mock SDK of [`wallet_at`]: pinned to `platform_version`, holding + /// DashPay as that version bundles it, answering fetches of `known` and + /// of the private store's first page, holding `entries`. + async fn dashpay_sdk( + known: &[&Identity], + entries: Vec>, + platform_version: &'static PlatformVersion, + verified_network: Option, + ) -> dash_sdk::Sdk { let contract = Arc::new( dpp::system_data_contracts::load_system_data_contract( dpp::data_contracts::SystemDataContract::Dashpay, @@ -2163,29 +2215,99 @@ mod tests { ) .await .expect("page expectation"); + sdk + } - let manager = - PlatformWalletManager::new(Arc::new(sdk), persistence, Arc::new(NoopEventHandler)); - let wallet = manager - .create_wallet_from_seed_bytes( - Network::Testnet, - &seed(phrase), - WalletAccountCreationOptions::Default, - Some(0), - ) - .await - .expect("wallet"); - { - let iw = wallet.identity(); - let mut wm = iw.wallet_manager.write().await; - let info = wm - .get_wallet_info_mut(&wallet.wallet_id()) - .expect("wallet info"); + /// A backend handing back, on every load, a wallet as [`snapshot`] took + /// it, with the viewing key its shielded account 0 bound with: the + /// relaunch of a wallet that bound a shielded account in an earlier + /// process. + #[cfg(feature = "shielded")] + struct SnapshotPersistence { + wallet: key_wallet::Wallet, + core: key_wallet::wallet::ManagedWalletInfo, + identities: BTreeMap, + viewing_key: [u8; 96], + } + + #[cfg(feature = "shielded")] + impl PlatformWalletPersistence for SnapshotPersistence { + fn persistence_capabilities(&self) -> PersistenceCapabilities { + PersistenceCapabilities::SHIELDED_FVK_RESTART + } + + fn store( + &self, + _wallet_id: WalletId, + _changeset: PlatformWalletChangeSet, + ) -> Result<(), PersistenceError> { + Ok(()) + } + + fn flush(&self, _wallet_id: WalletId) -> Result<(), PersistenceError> { + Ok(()) + } + + fn load(&self) -> Result { + let wallet_id = self.wallet.compute_wallet_id(); + let mut wallet_identities = BTreeMap::new(); + wallet_identities.insert(wallet_id, self.identities.clone()); + let mut wallets = BTreeMap::new(); + wallets.insert( + wallet_id, + ClientWalletStartState { + wallet: self.wallet.clone(), + wallet_info: self.core.clone(), + identity_manager: IdentityManagerStartState { + wallet_identities, + ..Default::default() + }, + unused_asset_locks: BTreeMap::new(), + unconfirmed_outgoing_txs: Vec::new(), + dashpay_backfill: Default::default(), + }, + ); + let mut shielded = ShieldedSyncStartState::default(); + shielded + .viewing_keys + .insert(SubwalletId::new(wallet_id, 0), self.viewing_key.to_vec()); + Ok(ClientStartState { + wallets, + shielded, + ..Default::default() + }) + } + } + + /// `wallet`, from `phrase`, with its identity `owner`, as a backend keeps + /// it once its shielded account 0 is bound. + #[cfg(feature = "shielded")] + async fn snapshot( + wallet: &PlatformWallet, + owner: &Identifier, + phrase: &str, + ) -> SnapshotPersistence { + let iw = wallet.identity(); + let wm = iw.wallet_manager.read().await; + let wallet_id = wallet.wallet_id(); + let info = wm.get_wallet_info(&wallet_id).expect("wallet info"); + let mut identities = BTreeMap::new(); + identities.insert( + 0, info.identity_manager - .add_identity(own.clone(), 0, wallet.wallet_id(), &iw.persister) - .expect("own identity"); + .managed_identity(owner) + .expect("the owner") + .clone(), + ); + SnapshotPersistence { + wallet: wm.get_wallet(&wallet_id).expect("the wallet").clone(), + core: info.core_wallet.clone(), + identities, + viewing_key: OrchardKeySet::from_seed(&seed(phrase), Network::Testnet, 0) + .expect("shielded keys") + .viewing_keys() + .to_fvk_bytes(), } - (manager, wallet) } /// The page of DashPay's private store from `start`, holding `entries`. @@ -4685,11 +4807,6 @@ mod tests { #[cfg(feature = "shielded")] #[tokio::test] async fn should_restore_a_request_whose_process_exited_during_its_result_wait() { - use crate::wallet::shielded::operations::arm_redrive_before_broadcast; - use crate::wallet::shielded::{ - FileBackedShieldedStore, NetworkShieldedCoordinator, SubwalletId, - }; - let alice = identity(ALICE_PHRASE, ALICE); let bob = identity(BOB_PHRASE, BOB); let alice_crypto = SeedCryptoProvider::from_seed(seed(ALICE_PHRASE), Network::Testnet); @@ -5138,15 +5255,13 @@ mod tests { /// the process ends is saved with the request it pays for: binding the /// restarted wallet to its shielded coordinator reserves the pair again, /// with no host call and no keys. Before any sync, neither the accept a - /// host calls nor a public send then goes to the contact. + /// host calls nor a public send then goes to the contact. Before the + /// bind, the relaunched wallet, which bound its shielded account before, + /// refuses both too, with nothing sent: the reservation is not restored + /// yet. #[cfg(feature = "shielded")] #[tokio::test] async fn should_not_reply_publicly_after_a_restart_with_a_private_reply_unconfirmed() { - use crate::wallet::shielded::operations::arm_redrive_before_broadcast; - use crate::wallet::shielded::{ - FileBackedShieldedStore, NetworkShieldedCoordinator, PrivateContactPayment, SubwalletId, - }; - let alice = identity(ALICE_PHRASE, ALICE); let bob = identity(BOB_PHRASE, BOB); let bob_crypto = SeedCryptoProvider::from_seed(seed(BOB_PHRASE), Network::Testnet); @@ -5167,6 +5282,8 @@ mod tests { let before_coordinator = coordinator_at(db_path.clone()); let (_before_manager, before) = wallet(BOB_PHRASE, &bob, &[&alice, &bob], vec![]).await; add_incoming(&before, BOB, &incoming).await; + // What the backend keeps of the wallet, before the reply is paid. + let kept = Arc::new(snapshot(&before, &bob.id(), BOB_PHRASE).await); let subwallet = SubwalletId::new(before.wallet_id(), 0); let pay = { let store = Arc::clone(before_coordinator.store()); @@ -5217,16 +5334,60 @@ mod tests { // The restart: the reply is not in the store and the host restores // nothing; only the bind can. let after = coordinator_at(db_path); - let (_restarted_manager, restarted) = wallet_on( - RecordingPersistence::attesting(PersistenceCapabilities::SHIELDED_FVK_RESTART), - BOB_PHRASE, - &bob, - &[&alice, &bob], - vec![], - ) - .await; - add_incoming(&restarted, BOB, &incoming).await; + let restarted_manager = PlatformWalletManager::new( + Arc::new(dashpay_sdk(&[&alice, &bob], vec![], PlatformVersion::latest(), None).await), + kept, + Arc::new(NoopEventHandler), + ); + restarted_manager + .load_from_persistor() + .await + .expect("the relaunch loads"); + let restarted = restarted_manager + .get_wallet(&before.wallet_id()) + .await + .expect("the relaunched wallet"); assert!(!pending_to(&restarted, BOB, &alice).await); + + // Before the bind: refused, with nothing read or sent. + let accepted = restarted + .identity() + .dashpay() + .accept_contact_request_replying_in_kind( + &incoming, + None, + &bob_signer, + &bob_crypto, + no_payment, + ) + .await; + assert!( + matches!( + accepted, + Err(PlatformWalletError::PrivateContactPaymentsNotRestored) + ), + "{accepted:?}" + ); + let public = restarted + .identity() + .dashpay() + .send_contact_request_with_external_signer( + &bob.id(), + &alice.id(), + None, + AutoAcceptProofSource::None, + &bob_signer, + &bob_crypto, + ) + .await; + assert!( + matches!( + public, + Err(PlatformWalletError::PrivateContactPaymentsNotRestored) + ), + "{public:?}" + ); + restarted .bind_shielded(&seed(BOB_PHRASE), &[0], &after) .await diff --git a/packages/rs-platform-wallet/src/wallet/persister.rs b/packages/rs-platform-wallet/src/wallet/persister.rs index 597c4db53f2..0e20c4516d6 100644 --- a/packages/rs-platform-wallet/src/wallet/persister.rs +++ b/packages/rs-platform-wallet/src/wallet/persister.rs @@ -5,6 +5,7 @@ //! a fixed `wallet_id` so callers don't need to pass the ID on every call. use std::collections::BTreeSet; +use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; use dashcore::Txid; @@ -64,6 +65,13 @@ pub struct WalletPersister { /// private contact request reservations held for them. Shared by the /// clones the wallet and the coordinator hold. In memory only. rejected_private_entries: Arc>, + /// Whether the reservations of this wallet's unconfirmed private contact + /// request payments from an earlier process may still be unrestored: + /// raised at load for a wallet that bound a shielded account before, and + /// lowered once a restoration from the shielded store succeeds. While it + /// is up, public contact requests are neither sent nor accepted. Shared by + /// the clones the wallet and its views hold. In memory only. + private_payments_unrestored: Arc, } /// The commitments [`WalletPersister::note_rejected_private_entries`] noted, @@ -89,6 +97,7 @@ impl WalletPersister { inner, contact_payment_gate: Arc::new(tokio::sync::Mutex::new(())), rejected_private_entries: Arc::default(), + private_payments_unrestored: Arc::default(), } } @@ -128,6 +137,32 @@ impl WalletPersister { self.rejected_entries().session.contains(commitment) } + /// Hold this wallet's public contact requests, sent or accepted, until + /// [`Self::private_payments_restored`]: its shielded store may hold an + /// unconfirmed private payment from an earlier process whose pair no + /// reservation holds yet. + #[cfg(feature = "shielded")] + pub(crate) fn hold_until_private_payments_restored(&self) { + self.private_payments_unrestored + .store(true, Ordering::Release); + } + + /// The reservations of this wallet's unconfirmed private payments are + /// restored: release the hold [`Self::hold_until_private_payments_restored`] + /// raised. Called after the restored reservations are in place, so a send + /// that sees the hold gone sees them. + #[cfg(feature = "shielded")] + pub(crate) fn private_payments_restored(&self) { + self.private_payments_unrestored + .store(false, Ordering::Release); + } + + /// Whether [`Self::hold_until_private_payments_restored`] holds this + /// wallet's public contact requests. + pub(crate) fn private_payments_unrestored(&self) -> bool { + self.private_payments_unrestored.load(Ordering::Acquire) + } + fn rejected_entries(&self) -> std::sync::MutexGuard<'_, RejectedPrivateEntries> { self.rejected_private_entries .lock() diff --git a/packages/rs-platform-wallet/src/wallet/platform_wallet.rs b/packages/rs-platform-wallet/src/wallet/platform_wallet.rs index fc42440aeb1..efe5e86219e 100644 --- a/packages/rs-platform-wallet/src/wallet/platform_wallet.rs +++ b/packages/rs-platform-wallet/src/wallet/platform_wallet.rs @@ -1809,6 +1809,16 @@ impl PlatformWallet { /// [`Self::restore_pending_private_contact_requests`] or the payment's /// own check. An identity added to the wallet after the bind is not /// covered until the next bind or that call. + /// + /// Run also by [`PlatformWalletManager::configure_shielded`] for every + /// wallet the manager holds, and by its load for the wallets it loads once + /// the store is configured: no keys are needed. Once it succeeds, the + /// wallet's public contact requests, which a load holds for a wallet that + /// bound a shielded account before + /// ([`PlatformWalletError::PrivateContactPaymentsNotRestored`]), go + /// ahead. + /// + /// [`PlatformWalletManager::configure_shielded`]: crate::PlatformWalletManager::configure_shielded #[cfg(feature = "shielded")] pub async fn restore_private_contact_payments( &self, @@ -1837,10 +1847,15 @@ impl PlatformWallet { }) }) .collect(); - self.identity + let restored = self + .identity .dashpay() .restore_private_payments(&payments) - .await + .await?; + // After the restored reservations are in place: a public send that + // sees the hold gone sees them. + self.persister.private_payments_restored(); + Ok(restored) } /// The entries of the private contact requests whose payment from this From a3a7fc448586e0336d1cb150fe9a62e3cbaf33bc Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 9 Nov 2026 11:26:43 +0700 Subject: [PATCH 074/117] fix(platform-wallet): refuse a private contact request pass whose provider holds another seed A private contact request sync scanned DashPay's store with whatever keys its crypto provider derived. A provider resolving another valid mnemonic derives valid but unrelated keys: they open nothing, yet the pass recorded its pages as read and a backend kept that position. Its key fingerprint hashes the identity's public keys, not what the provider derived, so fixing the provider did not rewind it, and the requests those pages held stayed unread across launches. Each pass now checks, before any page is read or any position moves, that the provider resolves this wallet's seed (`verify_seed_binds`, one account-0 xpub derivation a pass), and is refused with `SeedMismatch` otherwise. A pass with nothing to scan derives nothing. Co-Authored-By: Claude Opus 5.5 --- .../network/private_contact_requests.rs | 74 ++++++++++++++++--- 1 file changed, 65 insertions(+), 9 deletions(-) diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/private_contact_requests.rs b/packages/rs-platform-wallet/src/wallet/identity/network/private_contact_requests.rs index 3b49fd69a98..6562b616c76 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/private_contact_requests.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/private_contact_requests.rs @@ -1192,6 +1192,13 @@ impl DashPayView<'_, B> { /// wallet: accepting it replies privately, which pays from the shielded /// pool, so it is accepted by hand /// ([`Self::accept_private_contact_request`]). + /// + /// Refused with [`PlatformWalletError::SeedMismatch`], before any page + /// is read and with no position moved, when `crypto` does not resolve + /// this wallet's seed ([`IdentityWallet::verify_seed_binds`]): another + /// seed derives valid but unrelated keys, which open nothing, so its pass + /// would record as read pages holding requests it never saw, and nothing + /// rewinds them once the provider is fixed. pub async fn sync_private_contact_requests( &self, crypto: &C, @@ -1207,6 +1214,9 @@ impl DashPayView<'_, B> { report.complete = true; return Ok(report); }; + // Before anything is read or recorded: once a pass, as the keys it + // scans with all come from the same provider. + self.verify_seed_binds(crypto).await?; let identity_indexes: BTreeMap = identities .iter() .map(|identity| (identity.identity_id, identity.identity_index)) @@ -1870,7 +1880,7 @@ mod tests { use platform_encryption::{ account_reference_version, decrypt_account_label, decrypt_extended_public_key, }; - use std::sync::atomic::{AtomicBool, Ordering}; + use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; use std::sync::Mutex; use std::time::Duration; @@ -4224,18 +4234,22 @@ mod tests { } } - /// A hook that, the first time it runs, says so on `reached` and waits - /// for `release`. - fn pause_once(reached: Arc, release: Arc) -> Hook { - let first = Arc::new(AtomicBool::new(true)); + /// A hook that, the `call`th time it runs (from 1), says so on `reached` + /// and waits for `release`. + fn pause_on_call( + call: usize, + reached: Arc, + release: Arc, + ) -> Hook { + let runs = Arc::new(AtomicUsize::new(0)); Arc::new(move || { - let (reached, release, first) = ( + let (reached, release, runs) = ( Arc::clone(&reached), Arc::clone(&release), - Arc::clone(&first), + Arc::clone(&runs), ); Box::pin(async move { - if first.swap(false, Ordering::SeqCst) { + if runs.fetch_add(1, Ordering::SeqCst) + 1 == call { reached.notify_one(); release.notified().await; } @@ -4257,9 +4271,11 @@ mod tests { let view = alice_wallet.identity().dashpay(); let reached = Arc::new(tokio::sync::Notify::new()); let release = Arc::new(tokio::sync::Notify::new()); + // The second derivation: the first checks the provider's seed before + // the store is read. let mut pausing = HookedCrypto::new(ALICE_PHRASE); pausing.before_receiving_xpub = - Some(pause_once(Arc::clone(&reached), Arc::clone(&release))); + Some(pause_on_call(2, Arc::clone(&reached), Arc::clone(&release))); let paid = Arc::new(Mutex::new(Vec::new())); let (alice_id, bob_id) = (alice.id(), bob.id()); @@ -4414,6 +4430,46 @@ mod tests { .await; } + /// A pass whose provider resolves another wallet's seed, valid but + /// deriving unrelated keys that open nothing, is refused with nothing + /// read or recorded; the pass with the wallet's own seed then reads the + /// request the refused one would have passed over. + #[tokio::test] + async fn should_refuse_a_pass_with_another_seed_and_leave_its_position_unmoved() { + let alice = identity(ALICE_PHRASE, ALICE); + let bob = identity(BOB_PHRASE, BOB); + let (entry, _) = alice_to_bob([0x55; 43]).await; + let (_manager, bob_wallet) = wallet(BOB_PHRASE, &bob, &[&alice, &bob], vec![entry]).await; + let view = bob_wallet.identity().dashpay(); + + let another_seed = SeedCryptoProvider::from_seed(seed(ALICE_PHRASE), Network::Testnet); + let refused = view.sync_private_contact_requests(&another_seed, 4).await; + assert!( + matches!(refused, Err(PlatformWalletError::SeedMismatch { .. })), + "{refused:?}" + ); + managed(&bob_wallet, BOB, |managed| { + assert_eq!( + managed.dashpay().private_contact_requests_position(), + 0, + "nothing recorded as read" + ); + assert!(!managed.dashpay().private_contact_requests_read()); + assert!(managed.dashpay().incoming_request(&alice.id()).is_none()); + }) + .await; + + let bob_crypto = SeedCryptoProvider::from_seed(seed(BOB_PHRASE), Network::Testnet); + view.sync_private_contact_requests(&bob_crypto, 4) + .await + .expect("the pass with Bob's seed"); + managed(&bob_wallet, BOB, |managed| { + assert!(managed.dashpay().incoming_request(&alice.id()).is_some()); + assert_eq!(managed.dashpay().private_contact_requests_position(), 1); + }) + .await; + } + /// A pass that started with older keys commits no progress once a key /// update lands under it, even when the rewind that update brings is /// taken while the pass runs: the next pass reads with the new keys from From 4386ec09af79fcff89c0cc94fffbb420058587f1 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 9 Nov 2026 11:39:27 +0700 Subject: [PATCH 075/117] docs(sdk): say that a private request's key expiry is the identity's current limit The reader checks a signing key's expiry against the entry's block time using the expiry the identity given carries now, not the one the key had when the entry was appended. An identity may extend a key's expiry, reviving an expired key, so an entry signed after the key's first expiry is refused against an identity read before the extension and read against one read after it. The read's documentation now says so, beside its note on historical key membership, and a test pins the expired, extended, read-again sequence. Co-Authored-By: Claude Opus 5.5 --- .../dashpay/private_contact_request.rs | 100 +++++++++++++----- 1 file changed, 74 insertions(+), 26 deletions(-) diff --git a/packages/rs-sdk/src/platform/dashpay/private_contact_request.rs b/packages/rs-sdk/src/platform/dashpay/private_contact_request.rs index d805765faab..482136722d4 100644 --- a/packages/rs-sdk/src/platform/dashpay/private_contact_request.rs +++ b/packages/rs-sdk/src/platform/dashpay/private_contact_request.rs @@ -627,7 +627,9 @@ fn bounds_problem( /// `AUTHENTICATION` key at one of `levels`, of a signing key type, bound to no other contract or /// document type, enabled then and not expired then (the expiry instant itself is expired, as /// dpp's `is_expired_at` says). The reader checks it at the entry's block time, which decides; -/// the seal and the key list check it now, so a key about to be refused is not offered. +/// the seal and the key list check it now, so a key about to be refused is not offered. The +/// expiry is `key`'s as given: its identity's current limit, which an extension since the entry +/// may have moved past it ([`private_contact_requests_from_scan`]). fn signing_key_problem( key: &IdentityPublicKey, levels: &[SecurityLevel], @@ -984,6 +986,12 @@ pub fn private_contact_request_identities(scan: &PrivateDocumentScan) -> BTreeMa /// when a key was disabled, not when it was added. Adding a key takes the identity's master key, /// so a key added later still speaks for the identity's owner. /// +/// Likewise, a key's expiry is the limit the identity given carries now, compared with the +/// entry's block time, not the limit the key had when the entry was appended. An identity may +/// extend a key's expiry, which revives an expired key: an entry its key signed after the key's +/// first expiry is refused against an identity read before the extension, and read against one +/// read after it, once the new expiry is past the entry's block time. +/// /// The first request from a sender to a recipient for an account reference is kept. A wallet /// keeps the requests it already holds from earlier scans, and drops a later one from the same /// sender for the same account reference as this does. @@ -3201,35 +3209,37 @@ mod tests { ); } + /// The sender, its signing key expiring at `expires_at`. + fn sender_signing_until(expires_at: u64) -> Identity { + with_key( + sender_identity(None), + IdentityPublicKey::V1( + dpp::identity::identity_public_key::v1::IdentityPublicKeyV1 { + id: 0, + purpose: Purpose::AUTHENTICATION, + security_level: SecurityLevel::HIGH, + contract_bounds: None, + key_type: KeyType::ECDSA_SECP256K1, + read_only: false, + data: BinaryData::new( + PublicKey::from_secret_key(&secret_key(9)) + .serialize() + .to_vec(), + ), + disabled_at: None, + total_budget: None, + expires_at: Some(expires_at), + }, + ), + ) + } + /// A signing key with an expiry signs entries appended before it, not at or after it, and /// is not offered once expired. #[test] fn should_refuse_a_signing_key_expired_when_the_entry_was_appended() { let contract = dashpay(); let entry = seal(&contract, &sender_identity(None), &smallest_request(1), 1); - let expiring = |expires_at| { - with_key( - sender_identity(None), - IdentityPublicKey::V1( - dpp::identity::identity_public_key::v1::IdentityPublicKeyV1 { - id: 0, - purpose: Purpose::AUTHENTICATION, - security_level: SecurityLevel::HIGH, - contract_bounds: None, - key_type: KeyType::ECDSA_SECP256K1, - read_only: false, - data: BinaryData::new( - PublicKey::from_secret_key(&secret_key(9)) - .serialize() - .to_vec(), - ), - disabled_at: None, - total_budget: None, - expires_at: Some(expires_at), - }, - ), - ) - }; for (case, expires_at, accepted) in [ ("after the entry", BLOCK_TIME_MS + 1, true), ("at the entry", BLOCK_TIME_MS, false), @@ -3239,7 +3249,7 @@ mod tests { &contract, vec![entry.clone()], &recipient_keys(), - &[expiring(expires_at), recipient_identity()], + &[sender_signing_until(expires_at), recipient_identity()], )); if accepted { assert_eq!(reasons, vec![], "{case}"); @@ -3254,13 +3264,51 @@ mod tests { } } assert!( - private_contact_request_signing_keys(&contract, &expiring(1)) + private_contact_request_signing_keys(&contract, &sender_signing_until(1)) .expect("DashPay v3") .is_empty(), "an expired key is not offered" ); } + /// The expiry checked is the sender's key's limit as the identity given carries it, not the + /// one it had when the entry was appended: an entry the key signed after it expired is + /// refused against the identity as it was then, and read once the identity has extended + /// the key's expiry past the entry and is read again. + #[test] + fn should_read_an_entry_signed_after_an_expiry_once_the_expiry_is_extended_past_it() { + let contract = dashpay(); + let entry = seal(&contract, &sender_identity(None), &smallest_request(1), 1); + + let expired = reasons(read( + &contract, + vec![entry.clone()], + &recipient_keys(), + &[ + sender_signing_until(BLOCK_TIME_MS - 1), + recipient_identity(), + ], + )); + assert!( + matches!( + expired.as_slice(), + [PrivateContactRequestRejection::SenderKeyNotUsable(_)] + ), + "{expired:?}" + ); + + let extended = reasons(read( + &contract, + vec![entry], + &recipient_keys(), + &[ + sender_signing_until(BLOCK_TIME_MS + 1), + recipient_identity(), + ], + )); + assert_eq!(extended, vec![], "read with the extended expiry"); + } + /// The recipient key an entry names must be the one it is encrypted to, whether it is a /// key sealing could not encrypt to or an eligible one holding another point, and an /// identity must be given under its own id. From f9d669fa23c6669932d6e78542d29f630f897d60 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 9 Nov 2026 12:35:15 +0700 Subject: [PATCH 076/117] fix(sdk-trusted-context-provider): keep a newer known contract over an older one added or hydrated `add_known_contract`, `add_known_contracts` and `with_known_contracts` replaced a held contract whatever its version, so an application restoring an older cached contract while a page verification ran could put back a contract without the private type the verification had resolved, and the page would then be refused. They now keep a held contract of a higher version, as `register_data_contract` does, through one rule both providers share; an equal version still replaces. An explicit replacement stays possible: `replace_known_contract` puts a contract in place of whatever is held, which the wasm SDK's `addKnownContract` seed now calls, keeping its documented replacement with an older or equal version. Co-Authored-By: Claude Opus 5.5 --- .../src/known_contracts.rs | 26 +++++++ .../src/lib.rs | 1 + .../src/provider.rs | 73 ++++++++++++++----- .../src/verified_provider.rs | 63 +++++++++++++--- packages/wasm-sdk/src/context_provider.rs | 18 ++++- packages/wasm-sdk/src/sdk.rs | 2 +- 6 files changed, 148 insertions(+), 35 deletions(-) create mode 100644 packages/rs-sdk-trusted-context-provider/src/known_contracts.rs diff --git a/packages/rs-sdk-trusted-context-provider/src/known_contracts.rs b/packages/rs-sdk-trusted-context-provider/src/known_contracts.rs new file mode 100644 index 00000000000..f9ef425c385 --- /dev/null +++ b/packages/rs-sdk-trusted-context-provider/src/known_contracts.rs @@ -0,0 +1,26 @@ +//! The known-contract cache both context providers keep. + +use std::collections::HashMap; +use std::sync::Arc; + +use dpp::data_contract::accessors::v0::DataContractV0Getters; +use dpp::prelude::{DataContract, Identifier}; + +/// Hold `contract` under its id, unless the contract held there has a higher version. A +/// registration that waited on its fetch, or a hydration from a cache saved earlier, must not +/// put an older contract back over one another operation promoted meanwhile: a page that +/// operation verified against the newer one would be checked against the older one next. An +/// equal version replaces the held body. +pub(crate) fn hold_unless_newer_held( + known: &mut HashMap>, + contract: Arc, +) { + let id = contract.id(); + if known + .get(&id) + .is_some_and(|held| held.version() > contract.version()) + { + return; + } + known.insert(id, contract); +} diff --git a/packages/rs-sdk-trusted-context-provider/src/lib.rs b/packages/rs-sdk-trusted-context-provider/src/lib.rs index 28ac9f5ef3c..eaf3920149a 100644 --- a/packages/rs-sdk-trusted-context-provider/src/lib.rs +++ b/packages/rs-sdk-trusted-context-provider/src/lib.rs @@ -18,6 +18,7 @@ //! system-contract and HTTP plumbing; the name of the crate predates it. pub mod error; +mod known_contracts; mod proof_fetches; mod proof_transport; pub mod provider; diff --git a/packages/rs-sdk-trusted-context-provider/src/provider.rs b/packages/rs-sdk-trusted-context-provider/src/provider.rs index 55e0a5f52ac..e49ee66192b 100644 --- a/packages/rs-sdk-trusted-context-provider/src/provider.rs +++ b/packages/rs-sdk-trusted-context-provider/src/provider.rs @@ -1,5 +1,6 @@ use crate::error::{with_causes, TrustedContextProviderError}; use crate::get_quorum_base_url; +use crate::known_contracts::hold_unless_newer_held; use crate::quorum_refresh::{ contained, sleep, FetchedQuorums, QuorumRefreshes, QUORUM_LIST_REQUEST_TIMEOUT, }; @@ -266,13 +267,9 @@ impl TrustedHttpContextProvider { } /// Set known contracts that will be served immediately without fallback + /// ([`Self::add_known_contracts`]). pub fn with_known_contracts(self, contracts: Vec) -> Self { - let mut known = self.known_contracts.lock().unwrap(); - for contract in contracts { - let id = contract.id(); - known.insert(id, Arc::new(contract)); - } - drop(known); + self.add_known_contracts(contracts); self } @@ -282,8 +279,19 @@ impl TrustedHttpContextProvider { self } - /// Add a data contract to the known contracts cache + /// Add a data contract to the known contracts cache, unless it holds one of + /// a higher version under the same id, which another operation promoted: + /// an older contract restored from a saved cache does not replace it. pub fn add_known_contract(&self, contract: DataContract) { + let mut known = self.known_contracts.lock().unwrap(); + hold_unless_newer_held(&mut known, Arc::new(contract)); + } + + /// Put a data contract in the known contracts cache in place of whatever it + /// holds under its id, of any version: an explicit replacement by a holder + /// that means it, such as one rolling back to a contract it bundles. + /// [`Self::add_known_contract`] keeps a newer one instead. + pub fn replace_known_contract(&self, contract: DataContract) { let id = contract.id(); let mut known = self.known_contracts.lock().unwrap(); known.insert(id, Arc::new(contract)); @@ -318,12 +326,12 @@ impl TrustedHttpContextProvider { known.remove(id).is_some() } - /// Add multiple data contracts to the known contracts cache + /// Add multiple data contracts to the known contracts cache, each as + /// [`Self::add_known_contract`] adds one. pub fn add_known_contracts(&self, contracts: Vec) { let mut known = self.known_contracts.lock().unwrap(); for contract in contracts { - let id = contract.id(); - known.insert(id, Arc::new(contract)); + hold_unless_newer_held(&mut known, Arc::new(contract)); } } @@ -1031,15 +1039,8 @@ impl ContextProvider for TrustedHttpContextProvider { /// Keeps a held contract of a higher version: a registration that waited /// on a fetch must not replace one another operation promoted meanwhile. fn register_data_contract(&self, contract: Arc) { - let id = contract.id(); let mut known = self.known_contracts.lock().unwrap(); - if known - .get(&id) - .is_some_and(|held| held.version() > contract.version()) - { - return; - } - known.insert(id, contract); + hold_unless_newer_held(&mut known, contract); } fn get_token_configuration( @@ -2313,6 +2314,42 @@ pub(crate) mod tests { assert_eq!(held(&provider), Some(3), "an older one does not"); } + /// A contract added or hydrated replaces a held one of a lower or equal + /// version, and leaves one of a higher version: an older contract restored + /// from a saved cache does not undo a promotion. + #[test] + fn should_keep_a_newer_contract_over_an_older_one_added_or_hydrated() { + let id = Identifier::from([0x73; 32]); + let held = |provider: &TrustedHttpContextProvider| { + provider + .get_known_contract(&id) + .map(|contract| contract.version()) + }; + let provider = TrustedHttpContextProvider::new_with_url( + Network::Testnet, + "https://127.0.0.1".to_string(), + NonZeroUsize::new(100).unwrap(), + ) + .unwrap() + .with_known_contracts(vec![ + contract_at_version([0x73; 32], 3), + contract_at_version([0x73; 32], 2), + ]); + assert_eq!(held(&provider), Some(3), "hydration keeps the newer one"); + provider.add_known_contract(contract_at_version([0x73; 32], 2)); + assert_eq!( + held(&provider), + Some(3), + "an older one added does not replace" + ); + provider.add_known_contracts(vec![contract_at_version([0x73; 32], 1)]); + assert_eq!(held(&provider), Some(3), "nor one hydrated"); + provider.add_known_contract(contract_at_version([0x73; 32], 4)); + assert_eq!(held(&provider), Some(4), "a newer one replaces"); + provider.replace_known_contract(contract_at_version([0x73; 32], 1)); + assert_eq!(held(&provider), Some(1), "an explicit replacement replaces"); + } + #[test] fn test_known_contracts() { use dpp::version::PlatformVersion; diff --git a/packages/rs-sdk-trusted-context-provider/src/verified_provider.rs b/packages/rs-sdk-trusted-context-provider/src/verified_provider.rs index 0a8a6247916..e1fe972f6ec 100644 --- a/packages/rs-sdk-trusted-context-provider/src/verified_provider.rs +++ b/packages/rs-sdk-trusted-context-provider/src/verified_provider.rs @@ -10,6 +10,7 @@ //! requests but cannot make the SDK accept a forged key or ban honest nodes. use crate::get_quorum_base_url; +use crate::known_contracts::hold_unless_newer_held; use crate::proof_fetches::{ProofFetches, Timing, DKG_INTERVAL_BLOCKS}; use crate::proof_transport::ProofTransport; use crate::system_contracts::{ @@ -603,19 +604,30 @@ impl VerifiedHttpContextProvider { self } - /// Add a data contract to the known contracts cache. + /// Add a data contract to the known contracts cache, unless it holds one of + /// a higher version under the same id, which another operation promoted: + /// an older contract restored from a saved cache does not replace it. pub fn add_known_contract(&self, contract: DataContract) { - lock(&self.inner.known_contracts).insert(contract.id(), Arc::new(contract)); + hold_unless_newer_held(&mut lock(&self.inner.known_contracts), Arc::new(contract)); } - /// Add data contracts to the known contracts cache. + /// Add data contracts to the known contracts cache, each as + /// [`Self::add_known_contract`] adds one. pub fn add_known_contracts(&self, contracts: Vec) { let mut known = lock(&self.inner.known_contracts); for contract in contracts { - known.insert(contract.id(), Arc::new(contract)); + hold_unless_newer_held(&mut known, Arc::new(contract)); } } + /// Put a data contract in the known contracts cache in place of whatever it + /// holds under its id, of any version: an explicit replacement by a holder + /// that means it, such as one rolling back to a contract it bundles. + /// [`Self::add_known_contract`] keeps a newer one instead. + pub fn replace_known_contract(&self, contract: DataContract) { + lock(&self.inner.known_contracts).insert(contract.id(), Arc::new(contract)); + } + /// Insert a contract only when its version is strictly newer, returning /// the retained entry. Equal versions preserve the existing body. pub fn add_known_contract_if_newer(&self, contract: DataContract) -> Arc { @@ -672,14 +684,7 @@ impl ContextProvider for VerifiedHttpContextProvider { /// Keeps a held contract of a higher version: a registration that waited /// on a fetch must not replace one another operation promoted meanwhile. fn register_data_contract(&self, contract: Arc) { - let mut known = lock(&self.inner.known_contracts); - if known - .get(&contract.id()) - .is_some_and(|held| held.version() > contract.version()) - { - return; - } - known.insert(contract.id(), contract); + hold_unless_newer_held(&mut lock(&self.inner.known_contracts), contract); } fn get_token_configuration( @@ -1101,6 +1106,40 @@ mod tests { assert_eq!(held(&provider), Some(3), "an older one does not"); } + /// A contract added or hydrated replaces a held one of a lower or equal + /// version, and leaves one of a higher version: an older contract restored + /// from a saved cache does not undo a promotion. + #[test] + fn should_keep_a_newer_contract_over_an_older_one_added_or_hydrated() { + use crate::provider::tests::contract_at_version; + + let id = Identifier::from([0x74; 32]); + let held = |provider: &VerifiedHttpContextProvider| { + provider + .get_known_contract(&id) + .map(|contract| contract.version()) + }; + let provider = VerifiedHttpContextProvider::new(Network::Testnet, cache_size()) + .expect("a provider") + .with_known_contracts(vec![ + contract_at_version([0x74; 32], 3), + contract_at_version([0x74; 32], 2), + ]); + assert_eq!(held(&provider), Some(3), "hydration keeps the newer one"); + provider.add_known_contract(contract_at_version([0x74; 32], 2)); + assert_eq!( + held(&provider), + Some(3), + "an older one added does not replace" + ); + provider.add_known_contracts(vec![contract_at_version([0x74; 32], 1)]); + assert_eq!(held(&provider), Some(3), "nor one hydrated"); + provider.add_known_contract(contract_at_version([0x74; 32], 4)); + assert_eq!(held(&provider), Some(4), "a newer one replaces"); + provider.replace_known_contract(contract_at_version([0x74; 32], 1)); + assert_eq!(held(&provider), Some(1), "an explicit replacement replaces"); + } + fn cache_size() -> NonZeroUsize { NonZeroUsize::new(32).expect("non-zero") } diff --git a/packages/wasm-sdk/src/context_provider.rs b/packages/wasm-sdk/src/context_provider.rs index 39d4371b2be..86f2ae98d55 100644 --- a/packages/wasm-sdk/src/context_provider.rs +++ b/packages/wasm-sdk/src/context_provider.rs @@ -369,11 +369,19 @@ impl WasmTrustedContext { &self.discovered_addresses } - /// Add a data contract to the known contracts cache + /// Add a data contract to the known contracts cache, unless it holds a newer + /// one under the same id. pub fn add_known_contract(&self, contract: DataContract) { self.inner.add_known_contract(contract); } + /// Put a data contract in the known contracts cache in place of whatever it + /// holds under its id, of any version: an explicit seed + /// (`WasmSdk::add_known_contract_js`). + pub(crate) fn replace_known_contract(&self, contract: DataContract) { + self.inner.replace_known_contract(contract); + } + /// Keep the strictly newer contract, preserving the existing body on ties. pub(crate) fn add_known_contract_if_newer(&self, contract: DataContract) -> Arc { self.inner.add_known_contract_if_newer(contract) @@ -463,10 +471,12 @@ impl QuorumContext { } } - pub(crate) fn add_known_contract(&self, contract: DataContract) { + /// Put `contract` in place of whatever is held under its id, of any version: + /// an explicit seed (`WasmSdk::add_known_contract_js`). + pub(crate) fn replace_known_contract(&self, contract: DataContract) { match self { - Self::Trusted(context) => context.add_known_contract(contract), - Self::Verified(context) => context.add_known_contract(contract), + Self::Trusted(context) => context.replace_known_contract(contract), + Self::Verified(context) => context.replace_known_contract(contract), } } diff --git a/packages/wasm-sdk/src/sdk.rs b/packages/wasm-sdk/src/sdk.rs index e0b4ff23109..cbb05230d68 100644 --- a/packages/wasm-sdk/src/sdk.rs +++ b/packages/wasm-sdk/src/sdk.rs @@ -292,7 +292,7 @@ impl WasmSdk { }; let contract: DataContract = contract.clone().into(); contract_store::store(self.contract_slot(), &contract, self.sdk.version()); - context.add_known_contract(contract); + context.replace_known_contract(contract); true } From 311ae6293ba1297a2435ab91bce9a41141687d27 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 9 Nov 2026 12:35:54 +0700 Subject: [PATCH 077/117] fix(sdk): keep a failed refresh as the cause of a private document send's activation refusal An unpinned SDK below protocol version 15 refreshes its version before a private document send, and refuses when it is still below. The refresh swallowed its failure, so the refusal carried no cause and `is_transient_protocol_version_refusal` read it as final: a caller stopped retrying although the network may serve version 15 already, the refresh having failed only for want of an answer. The send now keeps the refresh's failure as the refusal's cause, as a failed probe is the cause of the gate's own refusal; the pin and the network's version read as before. `refresh_protocol_version` keeps its signature. Co-Authored-By: Claude Opus 5.5 --- .../src/platform/private_document_entries.rs | 1 + ...vate_document_create_from_shielded_pool.rs | 39 +++++++++++++------ packages/rs-sdk/src/sdk.rs | 12 +++++- packages/rs-sdk/src/sdk/protocol_gate.rs | 8 +++- 4 files changed, 44 insertions(+), 16 deletions(-) diff --git a/packages/rs-sdk/src/platform/private_document_entries.rs b/packages/rs-sdk/src/platform/private_document_entries.rs index c6badf6f9f5..270f6d8a1c7 100644 --- a/packages/rs-sdk/src/platform/private_document_entries.rs +++ b/packages/rs-sdk/src/platform/private_document_entries.rs @@ -150,6 +150,7 @@ impl Sdk { PRIVATE_DOCUMENT_CREATE_FROM_SHIELDED_POOL_INITIAL_PROTOCOL_VERSION, PRIVATE_DOCUMENT_ENTRIES_METHOD, None, + None, )); } return Err(Error::Generic(format!( diff --git a/packages/rs-sdk/src/platform/transition/private_document_create_from_shielded_pool.rs b/packages/rs-sdk/src/platform/transition/private_document_create_from_shielded_pool.rs index 9f4a1c7433c..bc5df336703 100644 --- a/packages/rs-sdk/src/platform/transition/private_document_create_from_shielded_pool.rs +++ b/packages/rs-sdk/src/platform/transition/private_document_create_from_shielded_pool.rs @@ -114,16 +114,20 @@ impl PrivateDocumentCreateFromShieldedPool for Sdk { ) -> Result { // An unpinned SDK starts below protocol version 15, whose limits admit no entries: the // network's version is established before the transition is built against it. One - // still below it after that refuses with the structured activation error. + // still below it after that refuses with the structured activation error, caused by + // the refresh's failure when it failed, so a caller can tell a refusal that may lift + // at once from one the network's version explains. let requirement = private_document_create_requirement(); - if self.protocol_version_number() < requirement.protocol_version - && self.refresh_protocol_version().await? < requirement.protocol_version - { - return Err(self.protocol_version_not_active( - requirement.protocol_version, - requirement.method, - requirement.feature, - )); + if self.protocol_version_number() < requirement.protocol_version { + let (version, failure) = self.refresh_protocol_version_or_failure().await; + if version < requirement.protocol_version { + return Err(self.protocol_version_not_active( + requirement.protocol_version, + requirement.method, + requirement.feature, + failure, + )); + } } // The SDK's own version does not show the network's: one seeded or pinned at 15 passes // the check above on a network still at 14. The transition is sent only once a verified @@ -203,7 +207,9 @@ mod tests { /// Below protocol version 15 the send is refused before the transition is built, rather /// than built against limits that admit no entries, with the structured activation error: - /// for an SDK pinned there, and for one seeded there that cannot learn a newer version. + /// for an SDK pinned there, which asks nothing, and for one seeded there whose refresh + /// failed, here for want of an answer: that failure is the refusal's cause, so the refusal + /// reads as one that may lift, and the pin and the network's version read as before. #[tokio::test] async fn should_refuse_to_send_below_protocol_version_15() { let version_14 = PlatformVersion::get(14).expect("protocol version 14"); @@ -228,15 +234,24 @@ mod tests { .expect_err("protocol version 14"); assert!( matches!( - error, + &error, Error::ProtocolVersionNotActive { method: BROADCAST_STATE_TRANSITION_METHOD, required: 15, + // No answer was verified. + network: 0, + pinned: refused_pinned, + cause, .. - } + } if *refused_pinned == pinned && cause.is_some() == !pinned ), "pinned {pinned}: {error:?}" ); + assert_eq!( + error.is_transient_protocol_version_refusal(), + !pinned, + "pinned {pinned}: {error:?}" + ); } } diff --git a/packages/rs-sdk/src/sdk.rs b/packages/rs-sdk/src/sdk.rs index eadbab8b165..0c6051fb434 100644 --- a/packages/rs-sdk/src/sdk.rs +++ b/packages/rs-sdk/src/sdk.rs @@ -763,9 +763,16 @@ impl Sdk { /// /// [`SdkBuilder::with_version`]: SdkBuilder::with_version pub async fn refresh_protocol_version(&self) -> Result { + Ok(self.refresh_protocol_version_or_failure().await.0) + } + + /// [`Self::refresh_protocol_version`], with the fetch's failure when it failed: why the + /// version it leaves may not show the network's, for a caller that refuses on it. + pub(crate) async fn refresh_protocol_version_or_failure(&self) -> (u32, Option) { if !self.prove() { - return Ok(self.protocol_version_number()); + return (self.protocol_version_number(), None); } + let mut failure = None; if !self.version_pinned { if let Err(error) = ExtendedEpochInfo::fetch_current(self).await { tracing::warn!( @@ -776,9 +783,10 @@ impl Sdk { proof-verified version learned so far (never falling back to \ an unverified one)" ); + failure = Some(error); } } - Ok(self.protocol_version_number()) + (self.protocol_version_number(), failure) } /// Retrieve object `O` from proof contained in `request` (of type `R`) and `response`. diff --git a/packages/rs-sdk/src/sdk/protocol_gate.rs b/packages/rs-sdk/src/sdk/protocol_gate.rs index bcce03a2fa7..a746f8f10e5 100644 --- a/packages/rs-sdk/src/sdk/protocol_gate.rs +++ b/packages/rs-sdk/src/sdk/protocol_gate.rs @@ -256,12 +256,16 @@ impl Sdk { /// The refusal [`Self::ensure_network_serves`] gives a request that needs `required`, /// for a caller that finds what `required` introduced missing from the network's data - /// while this SDK runs below it: the network does not serve it yet. + /// while this SDK runs below it: the network does not serve it yet. `cause` is why the + /// SDK could not learn the network's version just now, when it tried and failed, as a + /// failed probe is the refusal's cause there + /// ([`Error::is_transient_protocol_version_refusal`]). pub(crate) fn protocol_version_not_active( &self, required: u32, method: &'static str, feature: Option<&'static str>, + cause: Option, ) -> Error { Error::ProtocolVersionNotActive { method, @@ -269,7 +273,7 @@ impl Sdk { feature, network: self.query_settings().network_protocol_basis(), pinned: pinned_below(required, &self.gate_state()), - cause: None, + cause: cause.map(Box::new), } } From 4e75e27297b6fc75b385e1a0987997efc2d5c0b6 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 9 Nov 2026 12:35:54 +0700 Subject: [PATCH 078/117] fix(sdk): read a private page against a newer contract with the type promoted during the fetch A private page read fetches the contract declaring the type when the provider holds none that does. A fetched contract without the type was refused at once, although another operation may have given the provider a newer contract declaring it while the fetch ran; the read then failed for a contract the provider already held. The provider is now asked again first, and a newer contract declaring the type is used; otherwise the refusal, and its protocol-version form below version 15, stand. Co-Authored-By: Claude Opus 5.5 --- .../dashpay/private_contact_request.rs | 48 +++++++++++++++++++ .../src/platform/private_document_entries.rs | 11 +++++ 2 files changed, 59 insertions(+) diff --git a/packages/rs-sdk/src/platform/dashpay/private_contact_request.rs b/packages/rs-sdk/src/platform/dashpay/private_contact_request.rs index 482136722d4..a2158105196 100644 --- a/packages/rs-sdk/src/platform/dashpay/private_contact_request.rs +++ b/packages/rs-sdk/src/platform/dashpay/private_contact_request.rs @@ -3758,6 +3758,54 @@ mod tests { assert_eq!(provider.held().expect("held").version(), 3); } + /// A fetched contract that declares no private type is refused, unless another + /// operation gave the provider a newer one declaring it while it was fetched: then that + /// one is read against. A newer one that declares no private type either leaves the + /// refusal. + #[cfg(feature = "mocks")] + #[tokio::test] + async fn should_use_a_newer_contract_with_the_type_promoted_while_one_without_was_fetched() { + let mut stale = dashpay_without_private_requests(); + stale.set_version(1); + let mut fetched = dashpay_without_private_requests(); + fetched.set_version(2); + + let mut promoted = dashpay(); + promoted.set_version(3); + let mut sdk = sdk_with(HeldContractProvider::promoting(stale.clone(), promoted)); + sdk.mock() + .expect_fetch(fetched.id(), Some(fetched.clone())) + .await + .expect("the DashPay fetch is expected"); + let resolved = sdk + .fetch_private_document_contract( + fetched.id(), + PRIVATE_CONTACT_REQUEST_DOCUMENT_TYPE_NAME, + ) + .await + .expect("the newer contract with the type"); + assert_eq!(resolved.version(), 3); + assert!(private_contact_request_type(&resolved).is_ok()); + + let mut promoted_without = dashpay_without_private_requests(); + promoted_without.set_version(3); + let mut sdk = sdk_with(HeldContractProvider::promoting(stale, promoted_without)); + sdk.mock() + .expect_fetch(fetched.id(), Some(fetched.clone())) + .await + .expect("the DashPay fetch is expected"); + let refused = sdk + .fetch_private_document_contract( + fetched.id(), + PRIVATE_CONTACT_REQUEST_DOCUMENT_TYPE_NAME, + ) + .await; + assert!( + matches!(&refused, Err(Error::Generic(message)) if message.contains("declares no private type")), + "{refused:?}" + ); + } + /// Below protocol version 15 there is no private store: a contract that declares no /// private type, on an SDK running 14, is refused as not active, and the page stream ends /// with that refusal before any page is fetched, which the mock, answering none, would diff --git a/packages/rs-sdk/src/platform/private_document_entries.rs b/packages/rs-sdk/src/platform/private_document_entries.rs index 270f6d8a1c7..bac639d3e91 100644 --- a/packages/rs-sdk/src/platform/private_document_entries.rs +++ b/packages/rs-sdk/src/platform/private_document_entries.rs @@ -143,6 +143,17 @@ impl Sdk { .map(Arc::new) .ok_or_else(|| Error::Generic(format!("the contract {data_contract_id} is gone")))?; if !declares_private_type(&contract, document_type_name) { + // Another operation may have given the provider a newer contract declaring the + // type while this one was fetched: it is read against that one. + if let Some(provider) = &provider { + if let Some(held) = provider.get_data_contract(&data_contract_id, self.version())? { + if held.version() > contract.version() + && declares_private_type(&held, document_type_name) + { + return Ok(held); + } + } + } if self.protocol_version_number() < PRIVATE_DOCUMENT_CREATE_FROM_SHIELDED_POOL_INITIAL_PROTOCOL_VERSION { From 235a4f85d0ed6d6d4d540046d3c8c41b4864b833 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 9 Nov 2026 12:35:54 +0700 Subject: [PATCH 079/117] fix(sdk): take the network as proven below a version only on a current hold or probe `network_proven_below` answered, for an SDK that cannot ask the network (no context provider, or no gate enforced), whether any version had been verified below the one asked. A version verified earlier, perhaps by a clone, does not show what the network runs now: after an upgrade it still read as below, so a caller took a refusal as "not active yet". That branch now answers no; the network reads as proven below only while a probe's epoch hold shows it, or once a probe does. A test's `mock_verified_protocol_version` now also sets such a hold, outlasting the test, so a mock network set below a version still reads as proven there. Co-Authored-By: Claude Opus 5.5 --- packages/rs-sdk/src/sdk/protocol_gate.rs | 18 +++++--- .../rs-sdk/src/sdk/protocol_gate_tests.rs | 45 +++++++++++++++++++ 2 files changed, 58 insertions(+), 5 deletions(-) diff --git a/packages/rs-sdk/src/sdk/protocol_gate.rs b/packages/rs-sdk/src/sdk/protocol_gate.rs index a746f8f10e5..c3c11119068 100644 --- a/packages/rs-sdk/src/sdk/protocol_gate.rs +++ b/packages/rs-sdk/src/sdk/protocol_gate.rs @@ -286,9 +286,10 @@ impl Sdk { /// A version shown below by the last probe's epoch hold ([`ProbeHold`]) is taken as it /// is. Otherwise the network is asked with the gate's probe, one proved query, pinned SDK /// or not, and the call fails when the probe does. `false` when the network is shown at - /// or above `protocol_version`, and when nothing can show it: an SDK without a context + /// or above `protocol_version`, and when nothing can show it now: an SDK without a context /// provider, or a mock SDK, whose expectations are not verified answers, unless a test set - /// one ([`Self::mock_verified_protocol_version`]). + /// one ([`Self::mock_verified_protocol_version`]). A version verified earlier, by this SDK + /// or a clone, shows nothing then without a hold: the network may have upgraded since. pub async fn network_proven_below(&self, protocol_version: u32) -> Result { let state = self.gate_state(); let verified = state.settings.verified_protocol_version; @@ -302,7 +303,7 @@ impl Sdk { return Ok(true); } if !state.enforced || !state.has_context_provider { - return Ok(verified != 0); + return Ok(false); } self.probe_network_protocol_version(protocol_version, None) .await?; @@ -312,13 +313,20 @@ impl Sdk { /// Take `protocol_version` as the network's verified version, as if a proved answer had /// shown it, for a mock SDK in a test that needs the network's version known: a mock's - /// expectations are not verified answers. It never lowers the version. A network SDK - /// refuses it with [`Error::Config`]. + /// expectations are not verified answers. It never lowers the version. The network then + /// reads as proven at that version for the rest of the test, as a probe in an epoch that + /// outlasts the test shows it ([`Self::network_proven_below`]). A network SDK refuses it + /// with [`Error::Config`]. #[cfg(feature = "mocks")] pub fn mock_verified_protocol_version(&self, protocol_version: u32) -> Result<(), Error> { match self.inner { SdkInstance::Mock { .. } => { self.record_verified_protocol_version(protocol_version); + self.probe_hold.set(ProbeHold { + network: protocol_version, + epoch: 0, + until_ms: u64::MAX, + }); Ok(()) } SdkInstance::Dapi { .. } => Err(Error::Config( diff --git a/packages/rs-sdk/src/sdk/protocol_gate_tests.rs b/packages/rs-sdk/src/sdk/protocol_gate_tests.rs index 3eb87262780..74edcf2ed7a 100644 --- a/packages/rs-sdk/src/sdk/protocol_gate_tests.rs +++ b/packages/rs-sdk/src/sdk/protocol_gate_tests.rs @@ -2425,6 +2425,51 @@ async fn should_tell_a_network_proven_below_a_version_from_a_pin_below_it() { ); } +/// A version verified earlier, by this SDK or a clone, does not show the network below a +/// version now, as it may have upgraded since: an SDK that cannot ask it, for want of a context +/// provider or because it enforces no gate, takes the network as proven below only while a +/// probe's epoch hold shows it. A test's mock version stands for such a hold. +#[tokio::test] +async fn should_not_take_a_version_verified_earlier_as_the_network_below_now() { + let now_ms = chrono::Utc::now().timestamp_millis() as u64; + let hold = |epoch, until_ms| ProbeHold { + network: V13, + epoch, + until_ms, + }; + let without_provider = mock_sdk(V13, false, V13); + without_provider.context_provider.store(None); + let unenforced = SdkBuilder::new_mock().build().unwrap(); + for (case, sdk) in [ + ("without a context provider", without_provider), + ("enforcing no gate", unenforced), + ] { + sdk.verified_protocol_version.store(V13, Ordering::Relaxed); + assert!( + !sdk.network_proven_below(V14).await.expect(case), + "{case}: a version verified earlier" + ); + sdk.probe_hold.set(hold(3, now_ms - 1)); + assert!( + !sdk.network_proven_below(V14).await.expect(case), + "{case}: a hold whose epoch has ended" + ); + sdk.probe_hold.set(hold(4, now_ms + 3_600_000)); + assert!( + sdk.network_proven_below(V14).await.expect(case), + "{case}: a hold that still shows it" + ); + } + + let mock = SdkBuilder::new_mock().build().unwrap(); + mock.mock_verified_protocol_version(V13).unwrap(); + assert!( + mock.network_proven_below(V14).await.unwrap(), + "a test's version" + ); + assert!(!mock.network_proven_below(V13).await.unwrap()); +} + /// A private document send from an SDK at protocol version 15, seeded or pinned, on a network /// a verified answer shows at 14 is refused before it is built, rather than broadcast to be /// rejected. From 5a91dc7a5fa93eb39e12858f0d5a43d9cd687d20 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 9 Nov 2026 12:51:28 +0700 Subject: [PATCH 080/117] fix(platform-wallet-ffi): keep a missing mnemonic as key-unavailable through the private sync's seed check The private contact request sync now checks its provider's seed before it reads a page, deriving the account-0 xpub through the resolver-backed provider. That derivation turned a resolver's NOT_FOUND into `InvalidIdentityData`, so the sync failed with `ErrorUnknown` (99) instead of `ErrorSigningKeyUnavailable` (31) and a host could not route it to key repair. It now maps signer failures as the page scan does, keeping a missing mnemonic as the key-unavailable outcome. Co-Authored-By: Claude Opus 5.5 --- .../rs-platform-wallet-ffi/src/dashpay.rs | 86 ++++++++++++++++++- 1 file changed, 84 insertions(+), 2 deletions(-) diff --git a/packages/rs-platform-wallet-ffi/src/dashpay.rs b/packages/rs-platform-wallet-ffi/src/dashpay.rs index f10e8e8e31f..fc04b02c647 100644 --- a/packages/rs-platform-wallet-ffi/src/dashpay.rs +++ b/packages/rs-platform-wallet-ffi/src/dashpay.rs @@ -1117,10 +1117,12 @@ impl platform_wallet::ContactCryptoProvider for ResolverContactCryptoProvider { path: &key_wallet::bip32::DerivationPath, ) -> Result { use key_wallet::signer::ExtendedPubKeySigner; + // A missing mnemonic stays the key-unavailable outcome: the private sync's seed check + // derives through here before any page is read. self.signer .extended_public_key(path) .await - .map_err(|e| platform_wallet::PlatformWalletError::InvalidIdentityData(e.to_string())) + .map_err(private_document_signer_error) } async fn ecdh_shared_secret( @@ -1305,7 +1307,8 @@ impl platform_wallet::ContactCryptoProvider for ResolverContactCryptoProvider { } } -/// A signer failure of a private document operation as a wallet error: the +/// A signer failure of a private document operation, or of the receiving xpub +/// derivation the private sync's seed check makes first, as a wallet error: the /// SDK's refusal stays typed; a missing mnemonic is the key-unavailable /// outcome (FFI code 31), carried as the signer's typed completion is, with /// the reserved marker at position 0 of a `ProtocolError::Generic` payload @@ -1895,6 +1898,85 @@ mod tests { unsafe { rs_sdk_ffi::dash_sdk_mnemonic_resolver_destroy(missing) }; } + /// A private sync whose resolver has no mnemonic is refused at its seed check, before any + /// page is read, with the key-unavailable code (31), as the page scan is, so a host routes + /// it to key repair rather than reporting an unknown failure. + #[test] + fn should_report_a_missing_mnemonic_at_the_private_sync_seed_check_as_key_unavailable() { + use dpp::identity::identity_public_key::v0::IdentityPublicKeyV0; + use dpp::identity::v0::IdentityV0; + use dpp::identity::{IdentityPublicKey, KeyType, Purpose, SecurityLevel}; + use key_wallet::wallet::initialization::WalletAccountCreationOptions; + + let manager = crate::test_fixtures::mock_manager(); + let wallet = runtime() + .block_on(manager.create_wallet_from_seed_bytes( + key_wallet::Network::Testnet, + &[7; 64], + WalletAccountCreationOptions::Default, + Some(0), + )) + .expect("a wallet"); + let key = dpp::secp256k1::PublicKey::from_secret_key( + &dpp::secp256k1::SecretKey::from_secret_bytes([3; 32]).expect("a secret key"), + ); + let identity = Identity::V0(IdentityV0 { + id: Identifier::from([0xB1; 32]), + public_keys: [( + 1, + IdentityPublicKey::V0(IdentityPublicKeyV0 { + id: 1, + purpose: Purpose::ENCRYPTION, + security_level: SecurityLevel::MEDIUM, + contract_bounds: None, + key_type: KeyType::ECDSA_SECP256K1, + read_only: false, + data: BinaryData::new(key.serialize().to_vec()), + disabled_at: None, + }), + )] + .into_iter() + .collect(), + balance: 0, + revision: 0, + }); + runtime().block_on(async { + let mut wm = wallet.wallet_manager().write().await; + wm.get_wallet_info_mut(&wallet.wallet_id()) + .expect("the wallet's info") + .identity_manager + .add_identity(identity, 0, wallet.wallet_id(), wallet.persister()) + .expect("the identity"); + }); + + let missing = unsafe { + rs_sdk_ffi::dash_sdk_mnemonic_resolver_create( + std::ptr::null_mut(), + missing_resolve, + noop_destroy, + ) + }; + let provider = unsafe { + resolver_contact_crypto_provider( + missing, + wallet.wallet_id(), + key_wallet::Network::Testnet, + ) + }; + let refused = runtime().block_on( + wallet + .identity() + .dashpay() + .sync_private_contact_requests(&provider, 1), + ); + let error = refused.expect_err("the mnemonic is missing"); + assert_eq!( + PlatformWalletFFIResult::from(error).code, + PlatformWalletFFIResultCode::ErrorSigningKeyUnavailable + ); + unsafe { rs_sdk_ffi::dash_sdk_mnemonic_resolver_destroy(missing) }; + } + // Marshalling-boundary coverage for the verify entry point, replacing what // the removed attach FFI's input-validation tests upheld. The crypto // semantics (matching seed binds, wrong seed rejected) are pinned library- From 4c81017bae2a7db6d78ce6a325815fa61b1395bc Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 9 Nov 2026 13:17:48 +0700 Subject: [PATCH 081/117] fix(platform-wallet): hold an identity reloaded with an unresolved private payment in every build The hold on a reloaded wallet's public contact requests came only from its persisted shielded viewing key, which only a build with the `shielded` feature loads. A wallet whose unconfirmed private payment a shielded build saved could be reopened by a build without the feature, scan a store that does not hold the entry yet, and publish the pair while the signed private transition could still execute. Each identity's private scan state record (`PrivateContactRequestsPosition`, persisted for a backend attesting `PRIVATE_CONTACT_REQUEST_SCAN_STATE`) now carries `private_payments_unresolved`. The wallet stores it set before a private payment may reach the network, refusing the send when that store fails, and stores it cleared once the identity holds no private request it is paying for or has not read back: read back, rejected for good, or released before it was paid. Loaded set, in any build, it holds the identity: its public sends and accepts, the in-kind accept included, are refused with `PrivateContactPaymentsNotRestored` until a restoration from the shielded store clears it. A private sync does not, and a build without the `shielded` feature never does, which is the fail-closed outcome: its private payment may still execute. `DashPayView::private_contact_readiness` reads, from the wallet's own state, whether its private payments are restored for an identity, whether its private store was read to its end since load, and whether the marker is set, so hosts need not infer readiness from refusals. Co-Authored-By: Claude Opus 5.5 --- packages/rs-platform-wallet/README.md | 7 +- .../src/changeset/changeset.rs | 13 + packages/rs-platform-wallet/src/error.rs | 10 +- packages/rs-platform-wallet/src/lib.rs | 5 +- .../rs-platform-wallet/src/manager/mod.rs | 17 +- .../src/wallet/identity/network/mod.rs | 2 +- .../network/private_contact_requests.rs | 569 ++++++++++++++++-- .../managed_identity/contact_requests.rs | 25 +- .../state/managed_identity/dashpay.rs | 25 + .../src/wallet/platform_wallet.rs | 24 +- 10 files changed, 638 insertions(+), 59 deletions(-) diff --git a/packages/rs-platform-wallet/README.md b/packages/rs-platform-wallet/README.md index 3874edc415e..c501bd885f3 100644 --- a/packages/rs-platform-wallet/README.md +++ b/packages/rs-platform-wallet/README.md @@ -123,9 +123,12 @@ Startup errors distinguish damaged recovery metadata or undecodable signed bytes ## Private contact request payments after a restart -A private contact request's payment that is unconfirmed when the process ends stays in the shielded store with the request it pays for. Until its reservation is restored, nothing shows that the pair is taken: the request's entry is not in DashPay's store until the payment executes. So a wallet that `load_from_persistor` loads after it bound a shielded account (its persisted viewing key shows it) sends and accepts no public contact request until its payments are restored, and returns `PrivateContactPaymentsNotRestored` (FFI code 71, pending) with nothing signed or broadcast. A wallet with no persisted viewing key never bound a shielded account, since a bind persists the key before installing anything and refuses when it cannot, so it is never held. +A private contact request's payment that is unconfirmed when the process ends stays in the shielded store with the request it pays for. Until its reservation is restored, nothing shows that the pair is taken: the request's entry is not in DashPay's store until the payment executes. So a reloaded wallet sends and accepts no public contact request while such a payment may be unrestored, and returns `PrivateContactPaymentsNotRestored` (FFI code 72) with nothing read, signed or broadcast. The in-kind accept is held the same way. Two things hold it: -`PlatformWalletManager::configure_shielded` restores the payments of every held wallet from the store, with no keys, and a load after it restores those it loads. Binding a wallet's shielded account restores its own. Hosts that use shielded support call `configure_shielded` right after the load; a host that opens no shielded store this session leaves those wallets' public contact requests refused until it does. +- In a build with the `shielded` feature, `load_from_persistor` holds a wallet that bound a shielded account before (its persisted viewing key shows it). A wallet with no persisted viewing key never bound one, since a bind persists the key before installing anything and refuses when it cannot. +- In every build, an identity whose private scan state marks a private payment that may be unresolved (`PrivateContactRequestsPosition::private_payments_unresolved`) is held. The wallet stores the marker before such a payment may reach the network, for a backend attesting `PRIVATE_CONTACT_REQUEST_SCAN_STATE`, and stores it cleared once the identity holds no private request it is paying for or has not read back. + +`PlatformWalletManager::configure_shielded` restores the payments of every held wallet from the store, with no keys, and a load after it restores those it loads. Binding a wallet's shielded account restores its own. A private sync does not. Hosts that use shielded support call `configure_shielded` right after the load. A host that opens no shielded store this session, and any build without the `shielded` feature, keeps those wallets' and identities' public contact requests refused: the private payment may still execute, and a public request would publish the pair the private one kept off chain. `DashPayView::private_contact_readiness` (FFI `platform_wallet_get_private_contact_readiness`) reads whether the payments are restored, whether the private store was read to its end since load, and whether the marker is set, so a host shows the wallet's state rather than inferring it from refusals. ## Shielded balance API migration diff --git a/packages/rs-platform-wallet/src/changeset/changeset.rs b/packages/rs-platform-wallet/src/changeset/changeset.rs index 39cbd6f3039..45b333fd055 100644 --- a/packages/rs-platform-wallet/src/changeset/changeset.rs +++ b/packages/rs-platform-wallet/src/changeset/changeset.rs @@ -1379,6 +1379,19 @@ pub struct PrivateContactRequestsPosition { /// (`ManagedIdentity::private_scan_key_fingerprint`). A loaded position /// whose fingerprint is not the identity's is read again from 0. pub key_fingerprint: [u8; 32], + /// Whether a private contact request this identity paid for may still be + /// unresolved: written set before such a payment may reach the network, + /// and cleared once the identity holds no private request it is paying + /// for or has not read back (each read back, definitively rejected, or + /// released before it was broadcast). Loaded set, it holds the identity's + /// public contact requests, sent or accepted, in every build, until a + /// restoration from the shielded store restores the reservations it + /// stands for (`PlatformWallet::restore_private_contact_payments`). A + /// build without the `shielded` feature never restores them, so it keeps + /// the identity held: its private payment may still execute, and a + /// public request would publish the pair the private one kept off chain. + #[cfg_attr(feature = "serde", serde(default))] + pub private_payments_unresolved: bool, } impl Merge for ContactChangeSet { diff --git a/packages/rs-platform-wallet/src/error.rs b/packages/rs-platform-wallet/src/error.rs index 43bac718309..ff8c2536086 100644 --- a/packages/rs-platform-wallet/src/error.rs +++ b/packages/rs-platform-wallet/src/error.rs @@ -678,13 +678,17 @@ pub enum PlatformWalletError { )] PrivateContactRequestPending(Identifier), - /// This wallet bound a shielded account before this process started, so - /// the shielded store may hold a private contact request payment from an + /// This wallet bound a shielded account before this process started, or + /// the identity was loaded with its private scan state marking a private + /// payment that may be unresolved + /// (`PrivateContactRequestsPosition::private_payments_unresolved`), so the + /// shielded store may hold a private contact request payment from an /// earlier process that is still unconfirmed, and its reservations are not /// restored yet: no public contact request is sent or accepted, nothing /// signed or broadcast, until they are. Opening the shielded store /// (`PlatformWalletManager::configure_shielded`) restores them, with no - /// keys; so does binding the wallet's shielded account. + /// keys; so does binding the wallet's shielded account. A private sync + /// does not, and a build without the `shielded` feature never does. #[error( "this wallet's unconfirmed private contact request payments from an earlier process are \ not restored yet; configure the shielded store or bind the wallet's shielded account, \ diff --git a/packages/rs-platform-wallet/src/lib.rs b/packages/rs-platform-wallet/src/lib.rs index 653b8d47098..70e7cb442f0 100644 --- a/packages/rs-platform-wallet/src/lib.rs +++ b/packages/rs-platform-wallet/src/lib.rs @@ -77,8 +77,9 @@ pub use wallet::core_address_key::{CoreAddressKeyError, CoreAddressPrivateKey}; pub use wallet::identity::network::{ derive_identity_auth_keypair, fetch_identity_public_keys, AutoAcceptProofSource, ContactCryptoProvider, ContactInfoOpened, ContactInfoPublishOutcome, ContactInfoSealed, - FetchedIdentityPublicKeys, PaymentStarted, PreparedDashPayPayment, PrivateContactSyncReport, - PrivateDocumentKeyPaths, SeedBindingVerification, IDENTITY_GAP_LIMIT, MASTER_KEY_INDEX, + FetchedIdentityPublicKeys, PaymentStarted, PreparedDashPayPayment, PrivateContactReadiness, + PrivateContactSyncReport, PrivateDocumentKeyPaths, SeedBindingVerification, IDENTITY_GAP_LIMIT, + MASTER_KEY_INDEX, }; pub use wallet::identity::{ calculate_account_reference, derive_auto_accept_private_key, derive_contact_payment_address, diff --git a/packages/rs-platform-wallet/src/manager/mod.rs b/packages/rs-platform-wallet/src/manager/mod.rs index 81918982df1..49a14217c61 100644 --- a/packages/rs-platform-wallet/src/manager/mod.rs +++ b/packages/rs-platform-wallet/src/manager/mod.rs @@ -993,21 +993,20 @@ impl PlatformWalletManager

{ /// the unconfirmed private contact request payments of every wallet this /// manager holds whose public contact requests wait for them /// ([`PlatformWallet::restore_private_contact_payments`]): a wallet loaded - /// after binding a shielded account in an earlier process. A wallet whose + /// after binding a shielded account in an earlier process, or with an + /// identity marking a private payment that may be unresolved + /// ([`PlatformWallet::private_payments_held`]). A wallet whose /// restoration fails stays held, until a bind or a later call restores it. #[cfg(feature = "shielded")] pub(crate) async fn restore_held_private_contact_payments( &self, coordinator: &Arc, ) { - let held: Vec> = self - .wallets - .load() - .values() - .filter(|wallet| wallet.persister().private_payments_unrestored()) - .cloned() - .collect(); - for wallet in held { + let wallets: Vec> = self.wallets.load().values().cloned().collect(); + for wallet in wallets { + if !wallet.private_payments_held().await { + continue; + } if let Err(error) = wallet.restore_private_contact_payments(coordinator).await { tracing::warn!( wallet_id = %hex::encode(wallet.wallet_id()), diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/mod.rs b/packages/rs-platform-wallet/src/wallet/identity/network/mod.rs index 85edb5c33e5..2be4eddd609 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/mod.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/mod.rs @@ -77,7 +77,7 @@ mod prepared_payment; pub use prepared_payment::PreparedDashPayPayment; mod private_contact_requests; pub use private_contact_requests::{ - PaymentStarted, PrivateContactSyncReport, PrivateDocumentKeyPaths, + PaymentStarted, PrivateContactReadiness, PrivateContactSyncReport, PrivateDocumentKeyPaths, }; mod profile; mod public_keys; diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/private_contact_requests.rs b/packages/rs-platform-wallet/src/wallet/identity/network/private_contact_requests.rs index 6562b616c76..b46754f9e93 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/private_contact_requests.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/private_contact_requests.rs @@ -102,6 +102,36 @@ impl PrivateDocumentKeyPaths { } } +/// What a contact request from one of the wallet's identities waits for, as +/// the wallet decides it ([`DashPayView::private_contact_readiness`]), so a +/// host shows it rather than inferring it from the refusals it got. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct PrivateContactReadiness { + /// Whether the reservations of the wallet's private payments from an + /// earlier process are restored for the identity. While false, its public + /// contact requests, sent or accepted, are refused with + /// [`PlatformWalletError::PrivateContactPaymentsNotRestored`]: the wallet + /// bound a shielded account before it was loaded, or the identity was + /// loaded marking a private payment that may be unresolved. Configuring + /// the shielded store, or binding the wallet's shielded account, restores + /// them; no private sync does. A build without the `shielded` feature + /// never does. + pub private_payments_restored: bool, + /// Whether a private sync read DashPay's private contact request store to + /// its end for the identity since load. Until then its first send or + /// accept reads it first, and is refused with + /// [`PlatformWalletError::PrivateContactRequestPending`] while that read + /// cannot finish; a private sync sets it. + pub private_store_read: bool, + /// Whether a private contact request the identity paid for may still be + /// unresolved: the marker its position record stores + /// (`PrivateContactRequestsPosition::private_payments_unresolved`), set + /// while it holds a private request it is paying for or has not read back. + /// A contact's own pending request is + /// [`DashPayState::private_reply_pending`](crate::wallet::identity::DashPayState::private_reply_pending). + pub unresolved_private_payment: bool, +} + /// What a pass over DashPay's private contact request store did. #[derive(Clone, Debug, Default)] pub struct PrivateContactSyncReport { @@ -247,6 +277,7 @@ impl PaymentStarted { /// have started, or the send kept it, the reservation stays for the sync. struct PrivateReplyReservation { wallet_manager: Arc>>, + persister: WalletPersister, wallet_id: WalletId, sender: Identifier, recipient: Identifier, @@ -274,6 +305,7 @@ impl PrivateReplyReservation { &self.recipient, self.attempt, ); + store_private_payment_marker(&self.persister, &wm, &self.wallet_id, &self.sender); self.kept = true; } } @@ -285,10 +317,14 @@ impl Drop for PrivateReplyReservation { } let (wallet_id, sender, recipient, attempt) = (self.wallet_id, self.sender, self.recipient, self.attempt); + let persister = self.persister.clone(); apply_detached( &self.wallet_manager, "a cancelled private contact request's reservation", - move |wm| release_reservation(wm, &wallet_id, &sender, &recipient, attempt), + move |wm| { + release_reservation(wm, &wallet_id, &sender, &recipient, attempt); + store_private_payment_marker(&persister, wm, &wallet_id, &sender); + }, ); } } @@ -337,6 +373,33 @@ fn release_reservation( } } +/// Store `owner`'s private read position record again, for a backend that +/// keeps it, as whether a private payment of `owner` may be unresolved +/// (`PrivateContactRequestsPosition::private_payments_unresolved`) may have +/// changed: a reservation was released or settled. Best effort: a failed +/// store leaves the marker set, which holds the identity after a restart +/// until a restoration clears it, never the other way round. +fn store_private_payment_marker( + persister: &WalletPersister, + wm: &WalletManager, + wallet_id: &WalletId, + owner: &Identifier, +) { + if !persister.persists_private_contact_scan_state() { + return; + } + if let Some(managed) = wm + .get_wallet_info(wallet_id) + .and_then(|info| info.identity_manager.managed_identity(owner)) + { + store_private_position( + persister, + *owner, + managed.private_contact_requests_position_record(), + ); + } +} + /// The changeset recording, for each contact one of `addresses` came from, the /// shielded address `owner` keeps for it now, or its removal. fn shielded_address_changeset( @@ -607,6 +670,7 @@ impl DashPayView<'_, B> { ))?; let mut reservation = PrivateReplyReservation { wallet_manager: Arc::clone(&self.wallet_manager), + persister: self.persister.clone(), wallet_id: self.wallet_id, sender: *sender_identity_id, recipient: *recipient_identity_id, @@ -712,6 +776,10 @@ impl DashPayView<'_, B> { /// ([`PlatformWalletError::PrivateContactPaymentsNotRestored`]): such a /// payment's entry is not in the store until it executes, so no read /// shows the pair it holds; only the shielded store's record of it does. + /// That is so for a wallet a shielded build loads with a persisted + /// viewing key, and, in every build, for an identity loaded with its + /// position record marking a private payment that may be unresolved + /// (`PrivateContactRequestsPosition::private_payments_unresolved`). pub(super) async fn read_private_store_before_deciding( &self, identity_id: &Identifier, @@ -721,7 +789,9 @@ impl DashPayView<'_, B> { where C: ContactCryptoProvider + Sync, { - if self.persister.private_payments_unrestored() { + if self.persister.private_payments_unrestored() + || self.private_payments_unrestored(identity_id).await? + { return Err(PlatformWalletError::PrivateContactPaymentsNotRestored); } match self @@ -776,6 +846,117 @@ impl DashPayView<'_, B> { Ok(()) } + /// Store `owner`'s position record, marking it as holding a private payment + /// that may be unresolved, before anything is paid, for a backend that + /// keeps it. A failed store refuses the send, with nothing paid: a payment + /// a restart could not see would leave the pair unheld. + fn store_private_payment_marker_before_paying( + &self, + owner: &Identifier, + marker: PrivateContactRequestsPosition, + ) -> Result<(), PlatformWalletError> { + if !self.persister.persists_private_contact_scan_state() { + return Ok(()); + } + let mut changeset = ContactChangeSet::default(); + changeset + .private_contact_requests_positions + .insert(*owner, marker); + self.persister.store(changeset.into()).map_err(|error| { + PlatformWalletError::Persistence(format!( + "the private contact request's unresolved payment was not stored, so it is not \ + paid for: {error}" + )) + }) + } + + /// Whether `identity_id` was loaded with a private contact request payment + /// that may be unresolved and no restoration has restored it since + /// ([`DashPayState::private_payments_unrestored`]). + /// + /// [`DashPayState::private_payments_unrestored`]: crate::wallet::identity::DashPayState::private_payments_unrestored + pub(crate) async fn private_payments_unrestored( + &self, + identity_id: &Identifier, + ) -> Result { + let wm = self.wallet_manager.read().await; + let managed = wm + .get_wallet_info(&self.wallet_id) + .ok_or_else(|| PlatformWalletError::WalletNotFound(hex::encode(self.wallet_id)))? + .identity_manager + .managed_identity(identity_id) + .ok_or(PlatformWalletError::IdentityNotFound(*identity_id))?; + Ok(managed.dashpay().private_payments_unrestored()) + } + + /// What a contact request from `identity_id` waits for, as the wallet + /// decides it ([`PrivateContactReadiness`]). A pure read. + pub async fn private_contact_readiness( + &self, + identity_id: &Identifier, + ) -> Result { + let wallet_held = self.persister.private_payments_unrestored(); + let wm = self.wallet_manager.read().await; + let dashpay = wm + .get_wallet_info(&self.wallet_id) + .ok_or_else(|| PlatformWalletError::WalletNotFound(hex::encode(self.wallet_id)))? + .identity_manager + .managed_identity(identity_id) + .ok_or(PlatformWalletError::IdentityNotFound(*identity_id))? + .dashpay(); + Ok(PrivateContactReadiness { + private_payments_restored: !wallet_held && !dashpay.private_payments_unrestored(), + private_store_read: dashpay.private_contact_requests_read(), + unresolved_private_payment: dashpay.private_payments_unresolved(), + }) + } + + /// Whether any of the wallet's identities was loaded with a private + /// contact request payment that may be unresolved and no restoration has + /// restored it since. + pub(crate) async fn any_private_payments_unrestored(&self) -> bool { + let wm = self.wallet_manager.read().await; + wm.get_wallet_info(&self.wallet_id).is_some_and(|info| { + info.identity_manager + .all_identities() + .iter() + .filter_map(|identity| info.identity_manager.managed_identity(&identity.id())) + .any(|managed| managed.dashpay().private_payments_unrestored()) + }) + } + + /// The reservations of the wallet's private payments from an earlier + /// process are restored from the shielded store: release the hold a load + /// set on each identity, and store the record each one changed for a + /// backend that keeps it. + #[cfg(feature = "shielded")] + pub(crate) async fn note_private_payments_restored(&self) -> Result<(), PlatformWalletError> { + let mut wm = self.wallet_manager.write().await; + let info = wm + .get_wallet_info_mut(&self.wallet_id) + .ok_or_else(|| PlatformWalletError::WalletNotFound(hex::encode(self.wallet_id)))?; + let identity_ids: Vec = info + .identity_manager + .all_identities() + .iter() + .map(|identity| identity.id()) + .collect(); + for identity_id in identity_ids { + if let Some(managed) = info.identity_manager.managed_identity_mut(&identity_id) { + if managed.note_private_payments_restored() + && self.persister.persists_private_contact_scan_state() + { + store_private_position( + &self.persister, + identity_id, + managed.private_contact_requests_position_record(), + ); + } + } + } + Ok(()) + } + /// Release the pending private requests whose payment the shielded /// coordinator settled as never executing, for an entry a definitive /// rejection or a pruned anchor left unappendable @@ -806,7 +987,17 @@ impl DashPayView<'_, B> { .collect(); for identity_id in identity_ids { if let Some(managed) = info.identity_manager.managed_identity_mut(&identity_id) { + let unresolved = managed.dashpay().private_payments_unresolved(); managed.release_rejected_private_replies(&rejected); + if self.persister.persists_private_contact_scan_state() + && managed.dashpay().private_payments_unresolved() != unresolved + { + store_private_position( + &self.persister, + identity_id, + managed.private_contact_requests_position_record(), + ); + } } } Ok(()) @@ -965,14 +1156,21 @@ impl DashPayView<'_, B> { "the provider sealed an entry shorter than its commitment".to_string(), ) })?; - self.with_managed(sender_identity_id, |managed| { - managed.note_private_reply_commitment( - *recipient_identity_id, - reservation.attempt, - commitment, - ) - }) - .await?; + let marker = self + .with_managed(sender_identity_id, |managed| { + managed.note_private_reply_commitment( + *recipient_identity_id, + reservation.attempt, + commitment, + ); + managed.private_contact_requests_position_record() + }) + .await?; + // Before anything is paid, the identity is marked as holding a private + // payment that may be unresolved: a process reopening the wallet before + // the payment's reservation is restored, or in a build that cannot + // restore it, holds its public requests until then. + self.store_private_payment_marker_before_paying(sender_identity_id, marker)?; // What the record read back must carry, computed before paying. let shared_secret = crypto .ecdh_shared_secret(&sender_enc_path, &recipient_enc_pubkey) @@ -1527,6 +1725,7 @@ impl DashPayView<'_, B> { } } if stored { + let unresolved = managed.dashpay().private_payments_unresolved(); // A pending private request is settled once its // entry's record is reconciled: the request tracked // to the recipient is that record, carries its @@ -1551,7 +1750,13 @@ impl DashPayView<'_, B> { let before = managed.dashpay().private_contact_requests_position(); identity.advance(managed, read.next_position); let after = managed.dashpay().private_contact_requests_position(); - if persists_scan_state && after != before { + // Stored once it moved, or once the last private + // payment it held settled. + if persists_scan_state + && (after != before + || managed.dashpay().private_payments_unresolved() + != unresolved) + { store_private_position( &self.persister, identity_id, @@ -1841,11 +2046,10 @@ impl DashPayView<'_, B> { mod tests { use super::*; use crate::changeset::{ - ClientStartState, IdentityManagerStartState, PersistenceCapabilities, PersistenceError, - PlatformWalletChangeSet, PlatformWalletPersistence, + ClientStartState, ClientWalletStartState, IdentityManagerStartState, + PersistenceCapabilities, PersistenceError, PlatformWalletChangeSet, + PlatformWalletPersistence, }; - #[cfg(feature = "shielded")] - use crate::changeset::{ClientWalletStartState, ShieldedSyncStartState}; use crate::events::{EventHandler, PlatformEventHandler}; use crate::wallet::identity::network::identity_auth_derivation_path_for_type; use crate::wallet::identity::network::SeedCryptoProvider; @@ -2228,29 +2432,51 @@ mod tests { sdk } - /// A backend handing back, on every load, a wallet as [`snapshot`] took - /// it, with the viewing key its shielded account 0 bound with: the - /// relaunch of a wallet that bound a shielded account in an earlier - /// process. - #[cfg(feature = "shielded")] + /// A backend handing back, on every load, a wallet as [`snapshot`] or + /// [`scan_state_snapshot`] took it: the relaunch of a wallet from an + /// earlier process, with the viewing key its shielded account 0 bound + /// with, when it bound one. It keeps what the relaunched wallet stores. struct SnapshotPersistence { wallet: key_wallet::Wallet, core: key_wallet::wallet::ManagedWalletInfo, identities: BTreeMap, - viewing_key: [u8; 96], + capabilities: PersistenceCapabilities, + #[cfg(feature = "shielded")] + viewing_key: Option<[u8; 96]>, + stored: Mutex>, + } + + impl SnapshotPersistence { + /// Whether the last position record stored for `owner` marks a private + /// payment that may be unresolved, if one was stored. + #[cfg(feature = "shielded")] + fn last_marker(&self, owner: &Identifier) -> Option { + last_marker(&self.stored.lock().expect("stored"), owner) + } + } + + /// Whether the last of `stored` to carry `owner`'s position record marks a + /// private payment that may be unresolved. + fn last_marker(stored: &[PlatformWalletChangeSet], owner: &Identifier) -> Option { + stored + .iter() + .rev() + .filter_map(|changeset| changeset.contacts.as_ref()) + .find_map(|contacts| contacts.private_contact_requests_positions.get(owner)) + .map(|position| position.private_payments_unresolved) } - #[cfg(feature = "shielded")] impl PlatformWalletPersistence for SnapshotPersistence { fn persistence_capabilities(&self) -> PersistenceCapabilities { - PersistenceCapabilities::SHIELDED_FVK_RESTART + self.capabilities } fn store( &self, _wallet_id: WalletId, - _changeset: PlatformWalletChangeSet, + changeset: PlatformWalletChangeSet, ) -> Result<(), PersistenceError> { + self.stored.lock().expect("stored").push(changeset); Ok(()) } @@ -2277,15 +2503,19 @@ mod tests { dashpay_backfill: Default::default(), }, ); - let mut shielded = ShieldedSyncStartState::default(); - shielded - .viewing_keys - .insert(SubwalletId::new(wallet_id, 0), self.viewing_key.to_vec()); - Ok(ClientStartState { + #[allow(unused_mut)] + let mut state = ClientStartState { wallets, - shielded, ..Default::default() - }) + }; + #[cfg(feature = "shielded")] + if let Some(viewing_key) = self.viewing_key { + state + .shielded + .viewing_keys + .insert(SubwalletId::new(wallet_id, 0), viewing_key.to_vec()); + } + Ok(state) } } @@ -2313,11 +2543,277 @@ mod tests { wallet: wm.get_wallet(&wallet_id).expect("the wallet").clone(), core: info.core_wallet.clone(), identities, - viewing_key: OrchardKeySet::from_seed(&seed(phrase), Network::Testnet, 0) - .expect("shielded keys") - .viewing_keys() - .to_fvk_bytes(), + capabilities: PersistenceCapabilities::SHIELDED_FVK_RESTART, + viewing_key: Some( + OrchardKeySet::from_seed(&seed(phrase), Network::Testnet, 0) + .expect("shielded keys") + .viewing_keys() + .to_fvk_bytes(), + ), + stored: Mutex::default(), + } + } + + /// `wallet`, with its identity `owner`, as a backend keeping the private + /// contact request scan state keeps it, with no shielded viewing key: the + /// identity's position record marks a private payment that may be + /// unresolved when `unresolved`, as a process that paid for one and ended + /// before it resolved stored it. + async fn scan_state_snapshot( + wallet: &PlatformWallet, + owner: &Identifier, + unresolved: bool, + ) -> SnapshotPersistence { + let iw = wallet.identity(); + let wm = iw.wallet_manager.read().await; + let wallet_id = wallet.wallet_id(); + let info = wm.get_wallet_info(&wallet_id).expect("wallet info"); + let mut managed = info + .identity_manager + .managed_identity(owner) + .expect("the owner") + .clone(); + managed.apply_private_contact_requests_position(PrivateContactRequestsPosition { + private_payments_unresolved: unresolved, + ..managed.private_contact_requests_position_record() + }); + SnapshotPersistence { + wallet: wm.get_wallet(&wallet_id).expect("the wallet").clone(), + core: info.core_wallet.clone(), + identities: BTreeMap::from([(0, managed)]), + capabilities: PersistenceCapabilities::PRIVATE_CONTACT_REQUEST_SCAN_STATE, + #[cfg(feature = "shielded")] + viewing_key: None, + stored: Mutex::default(), + } + } + + /// A process that paid for a private contact request and ended before the + /// payment resolved stored the identity's position record marking it, in a + /// build with the `shielded` feature: reloaded, in any build, the identity + /// sends and accepts no public contact request, with nothing read, signed + /// or sent, as no reservation holds the pair the payment is for. A build + /// with the `shielded` feature releases it once the shielded store is + /// configured, which restores the reservations and stores the record + /// unmarked; no private sync does, and a build without it keeps it. + #[tokio::test] + async fn should_hold_an_identity_reloaded_marking_an_unresolved_private_payment() { + let alice = identity(ALICE_PHRASE, ALICE); + let bob = identity(BOB_PHRASE, BOB); + let alice_crypto = SeedCryptoProvider::from_seed(seed(ALICE_PHRASE), Network::Testnet); + let alice_signer = signer(ALICE_PHRASE, &alice); + let incoming = public_request(&bob, &alice, 3); + let (_before_manager, before) = wallet(ALICE_PHRASE, &alice, &[&alice, &bob], vec![]).await; + add_incoming(&before, ALICE, &incoming).await; + let kept = Arc::new(scan_state_snapshot(&before, &alice.id(), true).await); + + let manager = PlatformWalletManager::new( + Arc::new(dashpay_sdk(&[&alice, &bob], vec![], PlatformVersion::latest(), None).await), + Arc::clone(&kept), + Arc::new(NoopEventHandler), + ); + manager + .load_from_persistor() + .await + .expect("the relaunch loads"); + let restarted = manager + .get_wallet(&before.wallet_id()) + .await + .expect("the relaunched wallet"); + assert!(restarted.private_payments_held().await); + + let public = restarted + .identity() + .dashpay() + .send_contact_request_with_external_signer( + &alice.id(), + &bob.id(), + None, + AutoAcceptProofSource::None, + &alice_signer, + &alice_crypto, + ) + .await; + assert!( + matches!( + public, + Err(PlatformWalletError::PrivateContactPaymentsNotRestored) + ), + "{public:?}" + ); + let accepted = restarted + .identity() + .dashpay() + .accept_contact_request_replying_in_kind( + &incoming, + None, + &alice_signer, + &alice_crypto, + no_payment, + ) + .await; + assert!( + matches!( + accepted, + Err(PlatformWalletError::PrivateContactPaymentsNotRestored) + ), + "{accepted:?}" + ); + + // A private sync reads the store, and restores nothing. + let view = restarted.identity().dashpay(); + view.sync_private_contact_requests(&alice_crypto, 4) + .await + .expect("the private sync"); + assert_eq!( + view.private_contact_readiness(&alice.id()) + .await + .expect("the readiness"), + PrivateContactReadiness { + private_payments_restored: false, + private_store_read: true, + unresolved_private_payment: true, + } + ); + assert!(restarted.private_payments_held().await, "still held"); + + #[cfg(feature = "shielded")] + { + let dir = tempfile::tempdir().expect("temporary shielded store"); + manager + .configure_shielded(dir.path().join("tree.sqlite")) + .await + .expect("the shielded store is configured"); + assert!(!restarted.private_payments_held().await, "restored"); + assert_eq!(kept.last_marker(&alice.id()), Some(false)); + assert_eq!( + view.private_contact_readiness(&alice.id()) + .await + .expect("the readiness"), + PrivateContactReadiness { + private_payments_restored: true, + private_store_read: true, + unresolved_private_payment: false, + } + ); } + #[cfg(not(feature = "shielded"))] + assert!( + restarted.private_payments_held().await, + "no shielded store restores it here" + ); + } + + /// A private payment is marked unresolved in its identity's position + /// record before it is paid, for a backend keeping the scan state, and + /// unmarked once its entry is read back; one released before anything was + /// paid is unmarked too. + #[tokio::test] + async fn should_mark_a_private_payment_unresolved_until_it_resolves() { + let alice = identity(ALICE_PHRASE, ALICE); + let bob = identity(BOB_PHRASE, BOB); + let alice_crypto = SeedCryptoProvider::from_seed(seed(ALICE_PHRASE), Network::Testnet); + let alice_signer = signer(ALICE_PHRASE, &alice); + let attesting = || { + RecordingPersistence::attesting( + PersistenceCapabilities::PRIVATE_CONTACT_REQUEST_SCAN_STATE, + ) + }; + let marker = |persistence: &RecordingPersistence| { + last_marker(&persistence.stored.lock().expect("stored"), &alice.id()) + }; + + let persistence = attesting(); + let (_manager, alice_wallet) = wallet_on( + Arc::clone(&persistence), + ALICE_PHRASE, + &alice, + &[&alice, &bob], + vec![], + ) + .await; + let marked_when_paid = Arc::new(AtomicBool::new(false)); + let kept = Arc::new(Mutex::new(Vec::new())); + let pay = { + let paying = append_entry(&alice_wallet, 0, vec![], &kept); + let (persistence, marked, alice_id) = ( + Arc::clone(&persistence), + Arc::clone(&marked_when_paid), + alice.id(), + ); + move |entry: BinaryData, started: PaymentStarted| { + let unresolved = + last_marker(&persistence.stored.lock().expect("stored"), &alice_id); + marked.store(unresolved == Some(true), Ordering::SeqCst); + paying(entry, started) + } + }; + alice_wallet + .identity() + .dashpay() + .send_private_contact_request( + &alice.id(), + &bob.id(), + None, + AutoAcceptProofSource::None, + None, + &alice_signer, + &alice_crypto, + pay, + ) + .await + .expect("paid and read back"); + assert!( + marked_when_paid.load(Ordering::SeqCst), + "marked before paying" + ); + assert_eq!(marker(&persistence), Some(false), "unmarked once read back"); + + let persistence = attesting(); + let (_manager, alice_wallet) = wallet_on( + Arc::clone(&persistence), + ALICE_PHRASE, + &alice, + &[&alice, &bob], + vec![], + ) + .await; + let marked_when_paid = Arc::new(AtomicBool::new(false)); + let failing = { + let (persistence, marked, alice_id) = ( + Arc::clone(&persistence), + Arc::clone(&marked_when_paid), + alice.id(), + ); + move |_: BinaryData, _: PaymentStarted| { + let unresolved = + last_marker(&persistence.stored.lock().expect("stored"), &alice_id); + marked.store(unresolved == Some(true), Ordering::SeqCst); + std::future::ready(Err::( + PlatformWalletError::InvalidParameter("no notes to pay with".to_string()), + )) + } + }; + let refused = alice_wallet + .identity() + .dashpay() + .send_private_contact_request( + &alice.id(), + &bob.id(), + None, + AutoAcceptProofSource::None, + None, + &alice_signer, + &alice_crypto, + failing, + ) + .await; + assert!(refused.is_err(), "{refused:?}"); + assert!( + marked_when_paid.load(Ordering::SeqCst), + "marked before paying" + ); + assert_eq!(marker(&persistence), Some(false), "unmarked once released"); } /// The page of DashPay's private store from `start`, holding `entries`. @@ -3900,6 +4396,7 @@ mod tests { managed.apply_private_contact_requests_position(PrivateContactRequestsPosition { position: 1, key_fingerprint, + private_payments_unresolved: false, }); managed.apply_private_shielded_address(alice.id(), address); } @@ -3973,6 +4470,7 @@ mod tests { .apply_private_contact_requests_position(PrivateContactRequestsPosition { position: 7, key_fingerprint: [0; 32], + private_payments_unresolved: false, }); } view.ignore_contact_sender(&bob.id(), &alice.id()) @@ -4034,6 +4532,7 @@ mod tests { loaded.apply_private_contact_requests_position(PrivateContactRequestsPosition { position: 5, key_fingerprint, + private_payments_unresolved: false, }); loaded.apply_private_shielded_address(alice.id(), [0x55; 43]); let mut start_state = IdentityManagerStartState::default(); diff --git a/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/contact_requests.rs b/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/contact_requests.rs index 6f71edd231d..e163fe4fb37 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/contact_requests.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/contact_requests.rs @@ -847,6 +847,18 @@ impl ManagedIdentity { ) { self.dashpay.private_contact_requests_position = position.position; self.dashpay.private_scan_keys = Some(position.key_fingerprint); + self.dashpay.private_payments_unrestored |= position.private_payments_unresolved; + } + + /// The reservations of this identity's private payments from an earlier + /// process are restored from the shielded store: release the hold a load + /// set ([`DashPayState::private_payments_unrestored`]). Returns whether it + /// was set, so the caller stores the record it changed. + /// + /// [`DashPayState::private_payments_unrestored`]: super::DashPayState::private_payments_unrestored + #[cfg(feature = "shielded")] + pub(crate) fn note_private_payments_restored(&mut self) -> bool { + std::mem::take(&mut self.dashpay.private_payments_unrestored) } /// The fingerprint of the keys a private sync scans DashPay's private @@ -895,14 +907,15 @@ impl ManagedIdentity { self.dashpay.private_contact_requests_read = false; } self.dashpay.private_scan_keys = Some(key_fingerprint); - Some(PrivateContactRequestsPosition { - position: self.dashpay.private_contact_requests_position, - key_fingerprint, - }) + Some(self.private_contact_requests_position_record()) } /// The private read position as a backend stores it: with the - /// fingerprint of the keys it holds for. + /// fingerprint of the keys it holds for, and whether a private payment of + /// this identity may be unresolved + /// ([`DashPayState::private_payments_unresolved`]). + /// + /// [`DashPayState::private_payments_unresolved`]: super::DashPayState::private_payments_unresolved pub(crate) fn private_contact_requests_position_record( &self, ) -> PrivateContactRequestsPosition { @@ -912,6 +925,7 @@ impl ManagedIdentity { .dashpay .private_scan_keys .unwrap_or_else(|| self.private_scan_key_fingerprint()), + private_payments_unresolved: self.dashpay.private_payments_unresolved(), } } @@ -938,6 +952,7 @@ impl ManagedIdentity { self.dashpay.private_contact_requests_position = 0; self.dashpay.private_scan_keys = None; self.dashpay.private_shielded_addresses.clear(); + self.dashpay.private_payments_unrestored = false; } /// Record the shielded address `request`, a private request from diff --git a/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/dashpay.rs b/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/dashpay.rs index b01f6c87c2d..713ad8c1f8c 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/dashpay.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/dashpay.rs @@ -158,6 +158,15 @@ pub struct DashPayState { /// same id cannot release a claim of the new wallet. In memory only. pub(super) public_contact_request_claims: BTreeMap>, + /// Whether this identity was loaded with a private contact request + /// payment that may be unresolved + /// (`PrivateContactRequestsPosition::private_payments_unresolved`) and no + /// restoration from the shielded store has restored its reservations + /// since. While set, the identity sends and accepts no public contact + /// request, as no reservation holds the pair its payment is for. In + /// memory, from what a load restored. + pub(super) private_payments_unrestored: bool, + /// Lowest Platform-assigned `$createdAtCoreBlockHeight` among OUR sent /// `contactRequest` docs to each recipient seen by a sweep this process. /// @@ -333,6 +342,22 @@ impl DashPayState { self.pending_private_replies.contains_key(contact) } + /// Whether this identity was loaded with a private contact request payment + /// that may be unresolved, and no restoration from the shielded store has + /// restored its reservations since: its public contact requests are held. + pub fn private_payments_unrestored(&self) -> bool { + self.private_payments_unrestored + } + + /// Whether a private contact request this identity paid for may still be + /// unresolved, as its position record stores it + /// (`PrivateContactRequestsPosition::private_payments_unresolved`): it + /// holds a private request it is paying for or has not read back, or it + /// was loaded with one no restoration has restored since. + pub fn private_payments_unresolved(&self) -> bool { + !self.pending_private_replies.is_empty() || self.private_payments_unrestored + } + /// The shielded address kept for `contact`, whichever way the tracked /// requests went; [`Self::private_contact`] gives it only while the /// contact's request to us came privately. diff --git a/packages/rs-platform-wallet/src/wallet/platform_wallet.rs b/packages/rs-platform-wallet/src/wallet/platform_wallet.rs index efe5e86219e..5f3f29e45f0 100644 --- a/packages/rs-platform-wallet/src/wallet/platform_wallet.rs +++ b/packages/rs-platform-wallet/src/wallet/platform_wallet.rs @@ -1814,7 +1814,8 @@ impl PlatformWallet { /// wallet the manager holds, and by its load for the wallets it loads once /// the store is configured: no keys are needed. Once it succeeds, the /// wallet's public contact requests, which a load holds for a wallet that - /// bound a shielded account before + /// bound a shielded account before, and for an identity whose position + /// record marks a private payment that may be unresolved /// ([`PlatformWalletError::PrivateContactPaymentsNotRestored`]), go /// ahead. /// @@ -1853,11 +1854,30 @@ impl PlatformWallet { .restore_private_payments(&payments) .await?; // After the restored reservations are in place: a public send that - // sees the hold gone sees them. + // sees the holds gone sees them. The identities' holds first, which + // their position records stored, then the wallet's. + self.identity + .dashpay() + .note_private_payments_restored() + .await?; self.persister.private_payments_restored(); Ok(restored) } + /// Whether this wallet's public contact requests wait for its private + /// payments from an earlier process to be restored + /// ([`Self::restore_private_contact_payments`]): it bound a shielded account + /// before, or one of its identities was loaded marking a private payment + /// that may be unresolved. + pub async fn private_payments_held(&self) -> bool { + self.persister.private_payments_unrestored() + || self + .identity + .dashpay() + .any_private_payments_unrestored() + .await + } + /// The entries of the private contact requests whose payment from this /// wallet's notes `coordinator`'s store holds as unconfirmed. #[cfg(feature = "shielded")] From 91678f5e4da864663d0ee0373b44d1c88234e839 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 9 Nov 2026 13:17:48 +0700 Subject: [PATCH 082/117] feat(platform-wallet-ffi): report unrestored private payments by their own code, with the wallet's readiness `PrivateContactPaymentsNotRestored`, a wallet-wide hold, reached hosts as 71, the code of one contact's pending request. Hosts cleared a 71 after a private sync and enabled sending again, although no private sync lifts the hold, so the next submission was refused once more. It now has its own code, 72 (`ErrorPrivateContactPaymentsNotRestored`), next to 71 in the registry. `platform_wallet_get_private_contact_readiness` reads `DashPayView::private_contact_readiness` for one of a wallet's identities into `PrivateContactReadinessFFI`: whether its private payments are restored, whether its private store was read to its end since load, and whether its unresolved-payment marker is set, so hosts show the wallet's state instead of reconstructing it. Co-Authored-By: Claude Opus 5.5 --- .../ERROR_CODE_REGISTRY.md | 7 +- .../src/contact_request.rs | 183 +++++++++++++++++- packages/rs-platform-wallet-ffi/src/error.rs | 36 ++-- 3 files changed, 207 insertions(+), 19 deletions(-) diff --git a/packages/rs-platform-wallet-ffi/ERROR_CODE_REGISTRY.md b/packages/rs-platform-wallet-ffi/ERROR_CODE_REGISTRY.md index 6fc98045611..b34bb150a6c 100644 --- a/packages/rs-platform-wallet-ffi/ERROR_CODE_REGISTRY.md +++ b/packages/rs-platform-wallet-ffi/ERROR_CODE_REGISTRY.md @@ -114,7 +114,7 @@ These are shipped ABI. Do not renumber. | 98 | `NotFound` | Sentinel — `Option` returned as an error | | 99 | `ErrorUnknown` | Sentinel — unmapped/flattened errors | -**Next allocatable integer: 72**: 27–71 are all claimed (27, 29, 31, 34–42 +**Next allocatable integer: 73**: 27–72 are all claimed (27, 29, 31, 34–42 and 46 merged; 43–45 proposed by active #4313 at head `0302b188ab`; 47 and 48 proposed by active #4356 (47 renumbered from 42, 48 from 43 — see their rows below); 49–54 proposed by active #4586 (the persister @@ -127,7 +127,7 @@ renumbered from 59 when #5322 took that number on `v5.1-dev`); 61–67 held by open branches with no row here yet (61 `ErrorAssetLockRecipientMismatch`, 62 both `ErrorShieldedClaimBindingMismatch` and `ErrorIdentityRevisionMismatch`, 63–66 the invitation-claim preflight, 67 `ErrorShieldedTreeInconsistent`); 68 -to 71 proposed for DashPay private contact requests; 28, 30, +to 72 proposed for DashPay private contact requests; 28, 30, 32 and 33 reserved). **28, 30, 32 and 33 are RESERVED, not free**: 28 and 30 were vacated when the reservation trio moved to 34–36; 32 and 33 lapsed when their in-repo owners @@ -184,7 +184,8 @@ Fork-era numbers remain in the collision history, which is immutable record. | 68 | `ErrorPrivateContactRequestNeedsPrivateReply` | DashPay private contact requests (FFI and Swift) | Proposed. The contact requests with that identity came or went privately (protocol version 15), so a `contactRequest` document to it, in reply or as a rotation, would publish the relationship: the public accept refuses a reply, and the public send refuses a request or a rotation. Nothing was sent; the host accepts with `platform_wallet_manager_accept_contact_request_replying_in_kind` (which picks the reply itself, so a host calling it does not route on 68) or `platform_wallet_manager_accept_private_contact_request`, or sends with `platform_wallet_manager_send_private_contact_request`. Taken above 61–67, which open branches already hold. Rust and Swift (raw case, `init(ffi:)` arm, typed `PlatformWalletError.privateContactRequestNeedsPrivateReply` and an `ErrorHandlingTests` pin) carry it; Kotlin falls through to `PlatformWallet.Generic`, which keeps the integer | | 69 | `ErrorShieldedBuildRequired` | DashPay private contact requests (FFI, Swift and Kotlin) | Proposed. The operation needs the shielded pool and the library was built without the `shielded` feature. Today only `platform_wallet_manager_accept_contact_request_replying_in_kind`, exported in every build, returns it, for a request that came privately: its private reply is paid from the pool. Nothing was sent; a public or adopted reply works on such a build. Not retryable on the same build. No existing code meant "shielded unsupported" (`ErrorWalletOperation` with a not-configured message is a runtime state of a shielded build), so it takes the next free number. Swift (raw case, `init(ffi:)` arm, typed `PlatformWalletError.shieldedBuildRequired`, `ErrorHandlingTests` pin) and Kotlin (`DashSdkError.PlatformWallet.ShieldedBuildRequired`, `DashSdkErrorTest` pin) type it | | 70 | `ErrorProtocolVersionNotActive` | DashPay private contact requests (FFI, Swift and Kotlin) | Proposed. The SDK refused a request without sending it (`dash_sdk::Error::ProtocolVersionNotActive`), because the network is proven at a protocol version below the one that serves it or the SDK is pinned below it: for example the private contact request sync on a network before protocol version 15. Expected on such a network, so a host may stay quiet about it; it lifts when the network upgrades. Mapped by the blanket conversion for every operation that propagates the SDK error. A refusal that may lift sooner (`is_transient_protocol_version_refusal`: the network's version could not be confirmed) keeps the catch-all `ErrorUnknown`, as before. Previously both fell to `ErrorUnknown`. Swift (`PlatformWalletError.protocolVersionNotActive`) and Kotlin (`DashSdkError.PlatformWallet.ProtocolVersionNotActive`) type it | -| 71 | `ErrorPrivateContactRequestPending` | DashPay private contact requests (FFI, Swift and Kotlin) | Proposed. Maps `PlatformWalletError::PrivateContactRequestPending`: a private contact request to that identity is in flight (another private send or reply reserved it before sealing), or was paid for and a short read-back sync did not find the entry in the store yet; or the identity's private store could not be read to its end before its first private send or accept since load, which the wallet reads first so that a reply paid for in an earlier process is adopted rather than paid for again. Nothing new was paid; a later private contact request sync, or a retry, settles it. Pending, not failed: the host must not submit it again. Returned by the private send, the private accept and the in-kind accept; the shielded spend contract (`map_spend_result`) keeps the code rather than flattening it to `ErrorWalletOperation`. Also maps `PlatformWalletError::PrivateContactPaymentsNotRestored`, returned with nothing signed or broadcast by the public send, the public accept and the in-kind accept of a wallet loaded after binding a shielded account in an earlier process, until its unconfirmed private payments are restored from the shielded store: configuring the shielded store after the load restores them with no keys, as binding the wallet's shielded account does; a retry after either goes ahead. Swift (`PlatformWalletError.privateContactRequestPending`, `ErrorHandlingTests` pin) and Kotlin (`DashSdkError.PlatformWallet.PrivateContactRequestPending`, `DashSdkErrorTest` pin) type it, and both example apps keep the submission pending | +| 71 | `ErrorPrivateContactRequestPending` | DashPay private contact requests (FFI, Swift and Kotlin) | Proposed. Maps `PlatformWalletError::PrivateContactRequestPending`: a private contact request to that identity is in flight (another private send or reply reserved it before sealing), or was paid for and a short read-back sync did not find the entry in the store yet; or the identity's private store could not be read to its end before its first private send or accept since load, which the wallet reads first so that a reply paid for in an earlier process is adopted rather than paid for again. Nothing new was paid; a later private contact request sync, or a retry, settles it. Pending, not failed: the host must not submit it again. Returned by the private send, the private accept and the in-kind accept; the shielded spend contract (`map_spend_result`) keeps the code rather than flattening it to `ErrorWalletOperation`. Swift (`PlatformWalletError.privateContactRequestPending`, `ErrorHandlingTests` pin) and Kotlin (`DashSdkError.PlatformWallet.PrivateContactRequestPending`, `DashSdkErrorTest` pin) type it, and both example apps keep the submission pending | +| 72 | `ErrorPrivateContactPaymentsNotRestored` | DashPay private contact requests (FFI, Swift and Kotlin) | Proposed. Maps `PlatformWalletError::PrivateContactPaymentsNotRestored`: wallet readiness, not one contact's pending request (71). The wallet was loaded after binding a shielded account in an earlier process, or the identity was loaded with its private scan state marking a private payment that may be unresolved, so its unconfirmed private payments may hold pairs no reservation shows yet. The public send, the public accept and the in-kind accept are refused with nothing signed or broadcast until they are restored: configuring the shielded store restores them with no keys, as binding the wallet's shielded account does; a private sync does not, and a build without the `shielded` feature never does. `platform_wallet_get_private_contact_readiness` reads the state, so a host shows it rather than inferring it. Swift and Kotlin must type it, as for 71, and treat it as held until the readiness reads restored | **Code 31 left this table on 2026-08-04.** `ErrorSigningKeyUnavailable` sat here as #4183's proposal until #4183 merged (`189a3abb1c`); it is now in the merged diff --git a/packages/rs-platform-wallet-ffi/src/contact_request.rs b/packages/rs-platform-wallet-ffi/src/contact_request.rs index 433adc5d014..918e289c170 100644 --- a/packages/rs-platform-wallet-ffi/src/contact_request.rs +++ b/packages/rs-platform-wallet-ffi/src/contact_request.rs @@ -4,9 +4,10 @@ use crate::error::*; use crate::handle::*; +use crate::runtime::block_on_worker; use crate::types::*; use crate::{check_ptr, unwrap_option_or_return, unwrap_result_or_return}; -use platform_wallet::ContactRequest; +use platform_wallet::{ContactRequest, PrivateContactReadiness}; // Storage for contact requests lazy_static::lazy_static! { @@ -226,11 +227,191 @@ pub extern "C" fn contact_request_destroy(request_handle: Handle) -> PlatformWal } } +/// What a contact request from one of a wallet's identities waits for, as the +/// wallet decides it (`platform_wallet::PrivateContactReadiness`), for a host +/// to show rather than infer from the refusals it got. +#[repr(C)] +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct PrivateContactReadinessFFI { + /// Whether the wallet's private payments from an earlier process are + /// restored for the identity. While false, its public sends and accepts, + /// the in-kind accept included, are refused with + /// `ErrorPrivateContactPaymentsNotRestored` (72) until the shielded store + /// is configured or the wallet's shielded account bound; no private sync + /// lifts it. A build without the `shielded` feature never does. + pub private_payments_restored: bool, + /// Whether a private sync read DashPay's private contact request store to + /// its end for the identity since load. Until then its first send or + /// accept reads it first, and is refused with + /// `ErrorPrivateContactRequestPending` (71) while that read cannot finish. + pub private_store_read: bool, + /// Whether a private contact request the identity paid for may still be + /// unresolved: the marker its private scan state stores. A contact's own + /// pending request is `managed_identity_get_private_reply_pending`. + pub unresolved_private_payment: bool, +} + +impl From for PrivateContactReadinessFFI { + fn from(readiness: PrivateContactReadiness) -> Self { + Self { + private_payments_restored: readiness.private_payments_restored, + private_store_read: readiness.private_store_read, + unresolved_private_payment: readiness.unresolved_private_payment, + } + } +} + +/// What a contact request from `identity_id`, one of the wallet's identities, +/// waits for, as the wallet decides it, into `out_readiness` +/// ([`PrivateContactReadinessFFI`]): so a host tells "restoring private +/// payments" from "reading the private store" and from a contact's pending +/// request by the wallet's own state. A pure read of live wallet state. +/// +/// `out_readiness` is reset to all false first. An unknown `wallet_handle` is +/// `NotFound`; an identity the wallet does not hold is refused as its lookup +/// is. +/// +/// # Safety +/// - `identity_id` must point to 32 readable bytes. +/// - `out_readiness` must be a valid, writable pointer. +#[no_mangle] +pub unsafe extern "C" fn platform_wallet_get_private_contact_readiness( + wallet_handle: Handle, + identity_id: *const u8, + out_readiness: *mut PrivateContactReadinessFFI, +) -> PlatformWalletFFIResult { + check_ptr!(out_readiness); + unsafe { *out_readiness = PrivateContactReadinessFFI::default() }; + let identity_id = unwrap_result_or_return!(unsafe { read_identifier(identity_id) }); + let option = PLATFORM_WALLET_STORAGE.with_item(wallet_handle, |wallet| { + let identity = wallet.identity().clone(); + block_on_worker(async move { + identity + .dashpay() + .private_contact_readiness(&identity_id) + .await + }) + }); + let result = unwrap_option_or_return!(option); + let readiness = unwrap_result_or_return!(result); + unsafe { *out_readiness = readiness.into() }; + PlatformWalletFFIResult::ok() +} + #[cfg(test)] mod tests { use super::*; use dpp::prelude::Identifier; + /// The readiness of a fresh wallet's identity: nothing held, the store not + /// read yet, no private payment. A null out pointer, an unknown wallet and + /// an identity the wallet does not hold are refused, with the out value + /// reset. + #[test] + fn should_read_a_wallet_identity_s_private_contact_readiness() { + use dpp::identity::identity_public_key::v0::IdentityPublicKeyV0; + use dpp::identity::v0::IdentityV0; + use dpp::identity::{Identity, IdentityPublicKey, KeyType, Purpose, SecurityLevel}; + use dpp::platform_value::BinaryData; + use key_wallet::wallet::initialization::WalletAccountCreationOptions; + + assert_eq!( + unsafe { + platform_wallet_get_private_contact_readiness( + 1, + [0u8; 32].as_ptr(), + std::ptr::null_mut(), + ) + } + .code, + PlatformWalletFFIResultCode::ErrorNullPointer + ); + let mut readiness = PrivateContactReadinessFFI { + private_payments_restored: true, + private_store_read: true, + unresolved_private_payment: true, + }; + let unknown = unsafe { + platform_wallet_get_private_contact_readiness( + Handle::MAX, + [0u8; 32].as_ptr(), + &mut readiness, + ) + }; + assert_eq!(unknown.code, PlatformWalletFFIResultCode::NotFound); + assert_eq!(readiness, PrivateContactReadinessFFI::default(), "reset"); + + let manager = crate::test_fixtures::mock_manager(); + let wallet = crate::runtime::runtime() + .block_on(manager.create_wallet_from_seed_bytes( + key_wallet::Network::Testnet, + &[9; 64], + WalletAccountCreationOptions::Default, + Some(0), + )) + .expect("a wallet"); + let identity_id = Identifier::from([0xB2; 32]); + let key = dpp::secp256k1::PublicKey::from_secret_key( + &dpp::secp256k1::SecretKey::from_secret_bytes([4; 32]).expect("a secret key"), + ); + let identity = Identity::V0(IdentityV0 { + id: identity_id, + public_keys: [( + 1, + IdentityPublicKey::V0(IdentityPublicKeyV0 { + id: 1, + purpose: Purpose::ENCRYPTION, + security_level: SecurityLevel::MEDIUM, + contract_bounds: None, + key_type: KeyType::ECDSA_SECP256K1, + read_only: false, + data: BinaryData::new(key.serialize().to_vec()), + disabled_at: None, + }), + )] + .into_iter() + .collect(), + balance: 0, + revision: 0, + }); + crate::runtime::runtime().block_on(async { + let mut wm = wallet.wallet_manager().write().await; + wm.get_wallet_info_mut(&wallet.wallet_id()) + .expect("the wallet's info") + .identity_manager + .add_identity(identity, 0, wallet.wallet_id(), wallet.persister()) + .expect("the identity"); + }); + let handle = PLATFORM_WALLET_STORAGE.insert(wallet); + + let read = unsafe { + platform_wallet_get_private_contact_readiness( + handle, + identity_id.to_buffer().as_ptr(), + &mut readiness, + ) + }; + assert_eq!(read.code, PlatformWalletFFIResultCode::Success); + assert_eq!( + readiness, + PrivateContactReadinessFFI { + private_payments_restored: true, + private_store_read: false, + unresolved_private_payment: false, + } + ); + let stranger = unsafe { + platform_wallet_get_private_contact_readiness( + handle, + [0xEE; 32].as_ptr(), + &mut readiness, + ) + }; + assert_ne!(stranger.code, PlatformWalletFFIResultCode::Success); + assert_eq!(readiness, PrivateContactReadinessFFI::default(), "reset"); + PLATFORM_WALLET_STORAGE.remove(handle); + } + #[test] fn test_contact_request_getters() { unsafe { diff --git a/packages/rs-platform-wallet-ffi/src/error.rs b/packages/rs-platform-wallet-ffi/src/error.rs index 4724881382d..2fbdd043fe9 100644 --- a/packages/rs-platform-wallet-ffi/src/error.rs +++ b/packages/rs-platform-wallet-ffi/src/error.rs @@ -660,16 +660,21 @@ pub enum PlatformWalletFFIResultCode { /// private contact request sync /// (`platform_wallet_sync_private_contact_requests`) or a retry settles /// it. - /// - /// Also maps `PlatformWalletError::PrivateContactPaymentsNotRestored`: a - /// wallet loaded after binding a shielded account in an earlier process - /// sends and accepts no public contact request, nothing signed or - /// broadcast, until its unconfirmed private payments are restored from the - /// shielded store. Configuring the shielded store after the load restores - /// them with no keys; so does binding the wallet's shielded account. Retry - /// after either. ErrorPrivateContactRequestPending = 71, + /// Maps `PlatformWalletError::PrivateContactPaymentsNotRestored`: the + /// wallet, not one contact, is held. It was loaded after binding a + /// shielded account in an earlier process, or the identity was loaded with + /// its private scan state marking a private payment that may be + /// unresolved, so its unconfirmed private payments may hold pairs no + /// reservation shows yet: no public contact request is sent or accepted, + /// nothing signed or broadcast, until they are restored from the shielded + /// store. Configuring the shielded store restores them with no keys; so + /// does binding the wallet's shielded account. A private sync does not. + /// `platform_wallet_get_private_contact_readiness` reads whether they are + /// restored. A build without the `shielded` feature never restores them. + ErrorPrivateContactPaymentsNotRestored = 72, + /// The named thing does not exist. /// /// Originally (and still mostly) the code for every `Option` returned as an @@ -1119,10 +1124,10 @@ impl From for PlatformWalletFFIResult { PlatformWalletError::PrivateContactRequestPending(..) => { PlatformWalletFFIResultCode::ErrorPrivateContactRequestPending } - // A loaded wallet's private payments are not restored yet: the - // pair may be held, so the host retries as for a pending one. + // The wallet's private payments from an earlier process are not + // restored yet: wallet readiness, not one contact's pending request. PlatformWalletError::PrivateContactPaymentsNotRestored => { - PlatformWalletFFIResultCode::ErrorPrivateContactRequestPending + PlatformWalletFFIResultCode::ErrorPrivateContactPaymentsNotRestored } // A signer failure can also reach this blanket impl wrapped as // `PlatformWalletError::Sdk(dash_sdk::Error::Protocol(..))` (any @@ -2837,15 +2842,16 @@ mod private_contact_request_error_tests { } /// A public contact request a loaded wallet refuses until its private - /// payments are restored reaches the host as pending, to retry once the - /// shielded store is configured. + /// payments are restored reaches the host by its own code, not as a + /// contact's pending request. #[test] - fn should_report_unrestored_private_payments_as_pending() { + fn should_report_unrestored_private_payments_by_their_own_code() { let result = PlatformWalletFFIResult::from(PlatformWalletError::PrivateContactPaymentsNotRestored); assert_eq!( result.code, - PlatformWalletFFIResultCode::ErrorPrivateContactRequestPending + PlatformWalletFFIResultCode::ErrorPrivateContactPaymentsNotRestored ); + assert_eq!(result.code as u32, 72); } } From ecf621d5c4f2f0cfe21ae01d3f7ec1f9274faa75 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 9 Nov 2026 13:29:13 +0700 Subject: [PATCH 083/117] docs(wasm-sdk): say that a registration keeps a held contract of a higher version `WasmTrustedContext::register_data_contract` said a registered contract replaces the one the cache held. The inner provider now keeps a held contract of a higher version, which later lookups resolve. Co-Authored-By: Claude Opus 5.5 --- packages/wasm-sdk/src/context_provider.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/wasm-sdk/src/context_provider.rs b/packages/wasm-sdk/src/context_provider.rs index 86f2ae98d55..820f2bce2a6 100644 --- a/packages/wasm-sdk/src/context_provider.rs +++ b/packages/wasm-sdk/src/context_provider.rs @@ -118,7 +118,8 @@ impl ContextProvider for WasmTrustedContext { /// Forwarded to the inner known-contracts cache, so a contract the SDK registers (the /// DPNS contract a name registration was built against, whose confirmations decode under - /// it) replaces the one the cache held. + /// it) replaces the one the cache held, unless the held one has a higher version: that + /// one is kept, and later lookups resolve it. fn register_data_contract(&self, contract: Arc) { self.inner.register_data_contract(contract) } From 0dc274c9e8eb1f1fcc18b1288b29a5a42d7d0b60 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 9 Nov 2026 13:29:13 +0700 Subject: [PATCH 084/117] fix(sdk): ask the protocol gate before refusing a private document send An unpinned SDK below protocol version 15 refreshed its version before a private document send, and refused when it was still below. With proofs off the refresh asks nothing, so the send was refused without asking the network, though the gate's probe asks for a proof whatever the proof setting; and the refresh ignored the send's request settings, so a stalled epoch query could wait and retry longer than the caller set. The send now asks the gate first, under the caller's request settings. Its probe raises an unpinned SDK's version, and it lets the send through only once a verified answer shows the network serves version 15 and the SDK speaks it; otherwise it refuses with the structured activation error, the probe's failure as its cause. The version check and refresh stay for an SDK that enforces no gate (a mock), and the refresh now runs under the caller's settings too. Co-Authored-By: Claude Opus 5.5 --- ...vate_document_create_from_shielded_pool.rs | 32 ++++---- packages/rs-sdk/src/platform/types/epoch.rs | 17 ++-- packages/rs-sdk/src/sdk.rs | 18 ++-- .../rs-sdk/src/sdk/protocol_gate_tests.rs | 82 +++++++++++++++++++ 4 files changed, 120 insertions(+), 29 deletions(-) diff --git a/packages/rs-sdk/src/platform/transition/private_document_create_from_shielded_pool.rs b/packages/rs-sdk/src/platform/transition/private_document_create_from_shielded_pool.rs index bc5df336703..95baef28a3d 100644 --- a/packages/rs-sdk/src/platform/transition/private_document_create_from_shielded_pool.rs +++ b/packages/rs-sdk/src/platform/transition/private_document_create_from_shielded_pool.rs @@ -112,14 +112,25 @@ impl PrivateDocumentCreateFromShieldedPool for Sdk { bundle: OrchardBundleParams, settings: Option, ) -> Result { - // An unpinned SDK starts below protocol version 15, whose limits admit no entries: the - // network's version is established before the transition is built against it. One - // still below it after that refuses with the structured activation error, caused by - // the refresh's failure when it failed, so a caller can tell a refusal that may lift - // at once from one the network's version explains. + // The network's version is established before the transition is built against it, as + // limits below protocol version 15 admit no entries, and under the caller's request + // settings. The gate asks the network with one proved query, whatever this SDK's proof + // setting, which raises an unpinned SDK's version, and sends only once a verified answer + // shows the network serves the transition and this SDK speaks it, as a read of the store + // is (the broadcast checks again, for a transition built elsewhere). Otherwise it refuses + // with the structured activation error, the probe's failure as its cause when it failed, + // so a caller can tell a refusal that may lift at once from one the network's version + // or a pin explains. let requirement = private_document_create_requirement(); + let request_settings = settings.map(|settings| settings.request_settings); + self.ensure_network_serves(Some(requirement), request_settings) + .await?; + // A mock SDK enforces no gate: its own version decides, refreshed first when it starts + // below, the refresh's failure as the refusal's cause. if self.protocol_version_number() < requirement.protocol_version { - let (version, failure) = self.refresh_protocol_version_or_failure().await; + let (version, failure) = self + .refresh_protocol_version_or_failure(request_settings) + .await; if version < requirement.protocol_version { return Err(self.protocol_version_not_active( requirement.protocol_version, @@ -129,15 +140,6 @@ impl PrivateDocumentCreateFromShieldedPool for Sdk { )); } } - // The SDK's own version does not show the network's: one seeded or pinned at 15 passes - // the check above on a network still at 14. The transition is sent only once a verified - // answer shows the network serves it, as a read of the store is (the broadcast checks - // again, for a transition built elsewhere). - self.ensure_network_serves( - Some(requirement), - settings.map(|settings| settings.request_settings), - ) - .await?; let state_transition = self.private_document_create_from_shielded_pool_transition( data_contract_id, document_type_name, diff --git a/packages/rs-sdk/src/platform/types/epoch.rs b/packages/rs-sdk/src/platform/types/epoch.rs index b74180f49ed..fc5d78ce421 100644 --- a/packages/rs-sdk/src/platform/types/epoch.rs +++ b/packages/rs-sdk/src/platform/types/epoch.rs @@ -13,6 +13,7 @@ use crate::{ platform::{Fetch, FetchMany, LimitQuery, Query}, Error, Sdk, }; +use rs_dapi_client::RequestSettings; use std::collections::BTreeMap; /// Epoch type used in the SDK. @@ -33,7 +34,7 @@ impl FetchCurrent for ExtendedEpochInfo { /// See [`fetch_current_with_metadata_and_proof`](ExtendedEpochInfo::fetch_current_with_metadata_and_proof) /// for how the current epoch is selected and authenticated. async fn fetch_current(sdk: &Sdk) -> Result { - let (epoch, _, _) = resolve_current_epoch(sdk).await?; + let (epoch, _, _) = resolve_current_epoch(sdk, None).await?; Ok(epoch) } @@ -43,7 +44,7 @@ impl FetchCurrent for ExtendedEpochInfo { /// See [`fetch_current_with_metadata_and_proof`](ExtendedEpochInfo::fetch_current_with_metadata_and_proof) /// for how the current epoch is selected and authenticated. async fn fetch_current_with_metadata(sdk: &Sdk) -> Result<(Self, ResponseMetadata), Error> { - let (epoch, metadata, _) = resolve_current_epoch(sdk).await?; + let (epoch, metadata, _) = resolve_current_epoch(sdk, None).await?; Ok((epoch, metadata)) } @@ -89,19 +90,21 @@ impl FetchCurrent for ExtendedEpochInfo { async fn fetch_current_with_metadata_and_proof( sdk: &Sdk, ) -> Result<(Self, ResponseMetadata, Proof), Error> { - resolve_current_epoch(sdk).await + resolve_current_epoch(sdk, None).await } } -/// Fetch the newest started epoch, proving that it is in fact the newest. +/// Fetch the newest started epoch, proving that it is in fact the newest, with +/// both queries under `settings` (the SDK's own when `None`). /// /// See [`ExtendedEpochInfo::fetch_current_with_metadata_and_proof`] for the /// two-step protocol and its trust boundary. -async fn resolve_current_epoch( +pub(crate) async fn resolve_current_epoch( sdk: &Sdk, + settings: Option, ) -> Result<(ExtendedEpochInfo, ResponseMetadata, Proof), Error> { let (_, probe_metadata) = - ExtendedEpochInfo::fetch_with_metadata(sdk, current_epoch_probe_query(), None).await?; + ExtendedEpochInfo::fetch_with_metadata(sdk, current_epoch_probe_query(), settings).await?; let hint = epoch_hint_from_metadata(&probe_metadata); let mut candidate = hint; @@ -110,7 +113,7 @@ async fn resolve_current_epoch( let (epochs, metadata, proof) = ExtendedEpochInfo::fetch_many_with_metadata_and_proof( sdk, current_epoch_confirmation_query(candidate), - None, + settings, ) .await?; diff --git a/packages/rs-sdk/src/sdk.rs b/packages/rs-sdk/src/sdk.rs index 0c6051fb434..c379f226b0e 100644 --- a/packages/rs-sdk/src/sdk.rs +++ b/packages/rs-sdk/src/sdk.rs @@ -5,8 +5,8 @@ use crate::internal_cache::NonceCache; use crate::mock::MockResponse; #[cfg(feature = "mocks")] use crate::mock::{provider::GrpcContextProvider, MockDashPlatformSdk}; -use crate::platform::fetch_current_no_parameters::FetchCurrent; use crate::platform::transition::put_settings::PutSettings; +use crate::platform::types::epoch::resolve_current_epoch; use crate::platform::Identifier; use arc_swap::ArcSwapOption; use dapi_grpc::mock::Mockable; @@ -19,7 +19,6 @@ use dash_context_provider::ContextProvider; use dash_context_provider::MockContextProvider; use dpp::bincode; use dpp::bincode::error::DecodeError; -use dpp::block::extended_epoch_info::ExtendedEpochInfo; use dpp::dashcore::Network; use dpp::platform_serialization::PLATFORM_BINCODE_CONFIG; use dpp::prelude::IdentityNonce; @@ -762,19 +761,24 @@ impl Sdk { /// Returns the SDK's protocol version number after the (possible) ratchet. /// /// [`SdkBuilder::with_version`]: SdkBuilder::with_version + /// [`ExtendedEpochInfo::fetch_current`]: crate::platform::fetch_current_no_parameters::FetchCurrent::fetch_current pub async fn refresh_protocol_version(&self) -> Result { - Ok(self.refresh_protocol_version_or_failure().await.0) + Ok(self.refresh_protocol_version_or_failure(None).await.0) } - /// [`Self::refresh_protocol_version`], with the fetch's failure when it failed: why the - /// version it leaves may not show the network's, for a caller that refuses on it. - pub(crate) async fn refresh_protocol_version_or_failure(&self) -> (u32, Option) { + /// [`Self::refresh_protocol_version`], under the caller's request `settings` (the SDK's + /// own when `None`), with the fetch's failure when it failed: why the version it leaves + /// may not show the network's, for a caller that refuses on it. + pub(crate) async fn refresh_protocol_version_or_failure( + &self, + settings: Option, + ) -> (u32, Option) { if !self.prove() { return (self.protocol_version_number(), None); } let mut failure = None; if !self.version_pinned { - if let Err(error) = ExtendedEpochInfo::fetch_current(self).await { + if let Err(error) = resolve_current_epoch(self, settings).await { tracing::warn!( target: "dash_sdk::protocol_version", %error, diff --git a/packages/rs-sdk/src/sdk/protocol_gate_tests.rs b/packages/rs-sdk/src/sdk/protocol_gate_tests.rs index 74edcf2ed7a..0af86b6d48e 100644 --- a/packages/rs-sdk/src/sdk/protocol_gate_tests.rs +++ b/packages/rs-sdk/src/sdk/protocol_gate_tests.rs @@ -29,6 +29,7 @@ use crate::platform::identity_keys_remaining_budgets::IdentityKeysRemainingBudge use crate::platform::private_document_entries::PrivateDocumentEntries; use crate::platform::transition::broadcast::BroadcastStateTransition; use crate::platform::transition::private_document_create_from_shielded_pool::PrivateDocumentCreateFromShieldedPool; +use crate::platform::transition::put_settings::PutSettings; use crate::platform::types::epoch::current_epoch_probe_query; use crate::platform::types::identity::{ IdentityRequest, IdentityResponse, NonUniquePublicKeyHashQuery, @@ -2508,6 +2509,87 @@ async fn should_refuse_a_private_document_send_until_the_network_is_verified_at_ } } +/// The bundle of a private document send that the gate refuses before it is built. +fn unbuilt_bundle() -> OrchardBundleParams { + OrchardBundleParams { + actions: vec![], + anchor: [7; 32], + proof: vec![], + binding_signature: [9; 64], + } +} + +/// An unpinned SDK below protocol version 15 that asks for no proofs still learns the +/// network's version before refusing a private document send: the gate's probe asks for a +/// proof whatever the SDK's setting, and the refusal is of the network proven at 14, not of +/// a version the SDK never asked. +#[tokio::test] +async fn should_probe_before_refusing_a_private_document_send_without_proofs() { + let sdk = sdk_with_proofs(V14, false, 0, false); + answer_probe(&sdk, "pv14").await; + let result = sdk + .private_document_create_from_shielded_pool( + Identifier::new([4; 32]), + "privateContactRequest".to_string(), + vec![BinaryData::new(vec![1; 589])], + 114_140_000, + unbuilt_bundle(), + None, + ) + .await; + match result { + Err(Error::ProtocolVersionNotActive { + required: PROTOCOL_VERSION_15, + network: V14, + pinned: false, + .. + }) => {} + other => panic!("expected the network proven at 14, got {other:?}"), + } + assert_eq!(probes(&sdk).await, 1, "the network was asked"); +} + +/// A private document send learns the network's version under the caller's request +/// settings: one attempt, banning no node for an answer it cannot verify, as the caller +/// asked. +#[tokio::test] +async fn should_learn_the_version_for_a_private_document_send_under_the_caller_settings() { + let sdk = sdk_with_provider(V14, false, 0, true, NoQuorumKeys); + answer_probe(&sdk, "pv14").await; + let settings = PutSettings { + request_settings: RequestSettings { + ban_failed_address: Some(false), + ..once() + }, + ..Default::default() + }; + let result = sdk + .private_document_create_from_shielded_pool( + Identifier::new([4; 32]), + "privateContactRequest".to_string(), + vec![BinaryData::new(vec![1; 589])], + 114_140_000, + unbuilt_bundle(), + Some(settings), + ) + .await; + assert!( + matches!( + &result, + Err(Error::ProtocolVersionNotActive { cause: Some(_), .. }) + ), + "{result:?}" + ); + assert!(result + .as_ref() + .is_err_and(Error::is_transient_protocol_version_refusal)); + assert_eq!(probes(&sdk).await, 1, "no retries, as the caller asked"); + assert!( + !sdk.address_list().is_banned(&node()), + "the caller asked not to ban failed nodes" + ); +} + /// A private document transition built elsewhere and sent through the generic broadcast is /// gated as the dedicated send is: from an SDK at protocol version 15, seeded or pinned, on a /// network a verified answer shows at 14, it is refused locally instead of broadcast to be From 9b8589a1f56322f66e7669a5d41555964812fba8 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 9 Nov 2026 13:35:59 +0700 Subject: [PATCH 085/117] test(drive-abci): export a protocol 15 epoch probe for the SDK's gate tests The protocol gate vectors proved a network at 13 or 14, or at a version newer than any build. The private document requests need 15, so the SDK could not show a proved answer at 15 lifting a refusal. The exporter now also writes `epoch-probe-pv15.bin`, the genesis epoch proved by a protocol 15 state and signed at 15, and the vector check verifies it under the rules of 13, 14 and 15. Re-running the exporter rewrites the other vectors byte for byte. Co-Authored-By: Claude Opus 5.5 --- packages/rs-drive-abci/tests/protocol_gate.rs | 11 ++++++----- .../tests/vectors/protocol-gate/README.md | 4 ++-- .../vectors/protocol-gate/epoch-probe-pv15.bin | Bin 0 -> 1524 bytes 3 files changed, 8 insertions(+), 7 deletions(-) create mode 100644 packages/rs-drive-proof-verifier/tests/vectors/protocol-gate/epoch-probe-pv15.bin diff --git a/packages/rs-drive-abci/tests/protocol_gate.rs b/packages/rs-drive-abci/tests/protocol_gate.rs index 61c4740ba8d..12869bc1475 100644 --- a/packages/rs-drive-abci/tests/protocol_gate.rs +++ b/packages/rs-drive-abci/tests/protocol_gate.rs @@ -395,9 +395,10 @@ fn should_export_protocol_gate_sdk_fixtures() { Provider.get_quorum_public_key(106, [2; 32], 0).unwrap(), ) .unwrap(); - // The versions on either side of protocol 14, fixed so that re-running on a later - // branch rewrites the same files the SDK tests read. - for signed in [13, 14] { + // The versions on either side of protocol 14, and protocol 15, which the private + // document requests need, fixed so that re-running on a later branch rewrites the same + // files the SDK tests read. + for signed in [13, 14, 15] { let answer = Node::new(signed).epoch_probe(); assert_eq!(verified_epoch_probe(answer.clone(), signed), signed); std::fs::write( @@ -455,11 +456,11 @@ fn should_export_protocol_gate_sdk_fixtures() { #[test] fn should_keep_the_recorded_protocol_gate_vectors_verifiable() { let read = |name: &str| std::fs::read(directory().join(name)).unwrap(); - for signed in [13, 14] { + for signed in [13, 14, 15] { let answer = GetEpochsInfoResponse::decode(read(&format!("epoch-probe-pv{signed}.bin")).as_slice()) .unwrap(); - for rules_version in [13, 14] { + for rules_version in [13, 14, 15] { assert_eq!(verified_epoch_probe(answer.clone(), rules_version), signed); } } diff --git a/packages/rs-drive-proof-verifier/tests/vectors/protocol-gate/README.md b/packages/rs-drive-proof-verifier/tests/vectors/protocol-gate/README.md index 47937ca43d2..f42abd04426 100644 --- a/packages/rs-drive-proof-verifier/tests/vectors/protocol-gate/README.md +++ b/packages/rs-drive-proof-verifier/tests/vectors/protocol-gate/README.md @@ -12,9 +12,9 @@ the network runs a protocol version that serves it. When it does not know that y asks for the genesis epoch first. `rs-sdk`'s protocol gate tests replay these answers through the normal proof and signature verifier: -- `epoch-probe-pv13.bin`, `epoch-probe-pv14.bin`: the genesis epoch +- `epoch-probe-pv13.bin`, `epoch-probe-pv14.bin`, `epoch-probe-pv15.bin`: the genesis epoch (`GetEpochsInfoRequestV0 { start_epoch: Some(0), count: 1, ascending: true, prove: true }`) - proved by a node at genesis state of protocol 13 or 14, signed at that version. + proved by a node at genesis state of protocol 13, 14 or 15, signed at that version. - `epoch-probe-newer.bin`: the same answer from a protocol 14 state, signed as protocol version 1000, far above any real version, so the SDK always reads it with the newest rules it knows. diff --git a/packages/rs-drive-proof-verifier/tests/vectors/protocol-gate/epoch-probe-pv15.bin b/packages/rs-drive-proof-verifier/tests/vectors/protocol-gate/epoch-probe-pv15.bin new file mode 100644 index 0000000000000000000000000000000000000000..b84f9079d7f7ad59f6cf25fd8d3b822431af79a1 GIT binary patch literal 1524 zcma*m`%_YR7zXh3J?9_@Cg+$gW#wY7Yi5eXrrk`ub~DQo?dA$4qE_C(%gAb!qh>}I zBQ;&Haq!%5o9&BS38xI;t~ zz~~qRc}3MVB=`JEB_3hV*98V#61ZLZeJ-c=-K_keS~gq8!T=?-yI!}o>>(Prp-|_# zL7GFj?kDK1TFY$Xr6gqy32+<^OH5hR(w9vqrvLm!D~)0bbs zz#|aq9m+geS^43+uDoKxp!jX--RXgO0_$<*)y=1M92^*LnnL_h7(kF23P36ZIXe5q zU~W*mnfiTG*%}q zIip017`FB+*A=imMwfMCMhu&ZaU(`d#a}n0reauBrq^1yXrp$wF$mP~C$BLeW(~~q9!eNCx4UeSUEUoYu z{!mG;+_{mOdP>@(DjkrSIpF*-1Osb=n^xyI!J_EKqUV|Q%DGSVUrx3EOSKMv^Bky? z)Iyx_wL|U%AY9PLI$qdOWSJv-@6r;&AX}Do-D?J0rbalcX}Kpd`VnKxh!6@v5Ea6p zuWfQLVaf<$Qz2q1L`{QMbod*w{qy-mdkOO*dzDV3LL3cTgjdd>3+M?_|04LBvN9c|EzP~$-{oNihx5i1(xMAZ?*p9 zoPDc;`8lp?hh`e@(bK^~I`WOU$JyGE$QYnYx=wx3Fc(l4Q=@po7*`QrD2nHcT$eEa E1E@Y^1ONa4 literal 0 HcmV?d00001 From 50658b51f37fc899fd5ddeeae6e8fb25d3834882 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 9 Nov 2026 13:35:59 +0700 Subject: [PATCH 086/117] fix(sdk): establish protocol 15 through the gate for DashPay's private requests too DashPay's private contact request scan and seal resolved their contract after `require_protocol_version`, which only refreshes the SDK's version: with proofs off the refresh asks nothing, so an unpinned SDK below 15 failed with a generic error without asking the network, although the gate's probe asks for a proof whatever the proof setting. The send's order is now one helper, `Sdk::establish_protocol_requirement`: the gate first, under the caller's request settings, then, only for an SDK that enforces no gate (a mock) and is still below, a refresh under the same settings, then the structured refusal. The send and DashPay's contract resolution both use it, so the scan and seal refuse below 15 with `ProtocolVersionNotActive` rather than a generic error. `require_protocol_version` is unchanged for the features that use it. Co-Authored-By: Claude Opus 5.5 --- .../dashpay/private_contact_request.rs | 44 +++++++---- .../src/platform/private_document_entries.rs | 22 +++--- ...vate_document_create_from_shielded_pool.rs | 39 +++------ packages/rs-sdk/src/sdk/protocol_gate.rs | 32 ++++++++ .../rs-sdk/src/sdk/protocol_gate_tests.rs | 79 +++++++++++++++++++ 5 files changed, 162 insertions(+), 54 deletions(-) diff --git a/packages/rs-sdk/src/platform/dashpay/private_contact_request.rs b/packages/rs-sdk/src/platform/dashpay/private_contact_request.rs index a2158105196..09e5feb9ca9 100644 --- a/packages/rs-sdk/src/platform/dashpay/private_contact_request.rs +++ b/packages/rs-sdk/src/platform/dashpay/private_contact_request.rs @@ -24,6 +24,7 @@ use super::contact_request::{recipient_key_purpose_is_valid, sender_key_purpose_is_valid}; use crate::error::StaleNodeError; +use crate::platform::private_document_entries::private_document_entries_requirement; use crate::platform::private_document_scan::{ PrivateDocumentScan, PrivateDocumentScanKeys, ReceivedPrivateDocumentEntry, SentPrivateDocumentEntry, @@ -49,7 +50,6 @@ use dpp::secp256k1::rand::rngs::StdRng; use dpp::secp256k1::rand::{RngCore, SeedableRng}; use dpp::secp256k1::{PublicKey, SecretKey}; use dpp::util::hash::ripemd160_sha256; -use dpp::version::feature_initial_protocol_versions::PRIVATE_DOCUMENT_CREATE_FROM_SHIELDED_POOL_INITIAL_PROTOCOL_VERSION; use dpp::version::PlatformVersion; use futures::{StreamExt, TryStreamExt}; use platform_encryption::{ @@ -1391,14 +1391,13 @@ impl Sdk { /// The DashPay contract, with its `privateContactRequest` type, as the context provider /// holds it ([`Sdk::fetch_private_document_contract`]): a page's proof is verified against /// the contract the provider gives. The network's protocol version is established as 15 or - /// later first, as an unpinned SDK starts below it and would otherwise resolve a DashPay - /// without the type. + /// later first (`Sdk::establish_protocol_requirement`), as an unpinned SDK starts below it + /// and would otherwise resolve a DashPay without the type: the gate's probe asks with a + /// proof whatever this SDK's proof setting, and below 15 the call is refused with + /// [`Error::ProtocolVersionNotActive`]. async fn fetch_private_contact_request_contract(&self) -> Result, Error> { - self.require_protocol_version( - PRIVATE_DOCUMENT_CREATE_FROM_SHIELDED_POOL_INITIAL_PROTOCOL_VERSION, - "DashPay private contact requests", - ) - .await?; + self.establish_protocol_requirement(private_document_entries_requirement(), None) + .await?; let dashpay_id = self.get_dashpay_contract_id()?; self.fetch_private_document_contract(dashpay_id, PRIVATE_CONTACT_REQUEST_DOCUMENT_TYPE_NAME) .await @@ -3170,7 +3169,8 @@ mod tests { } /// Below protocol version 15 the SDK refuses private contact requests before resolving - /// DashPay, whose older versions have no private type. + /// DashPay, whose older versions have no private type: an SDK pinned there with the + /// structured activation error, naming the pin. #[cfg(feature = "mocks")] #[tokio::test] async fn should_refuse_private_contact_requests_below_protocol_version_15() { @@ -3183,10 +3183,15 @@ mod tests { .await .expect_err("protocol version 14"); assert!( - error - .to_string() - .contains("exists from protocol version 15"), - "{error}" + matches!( + error, + Error::ProtocolVersionNotActive { + required: 15, + pinned: true, + .. + } + ), + "{error:?}" ); let signer = TransitionSigner(BTreeMap::new()); let error = sdk @@ -3202,10 +3207,15 @@ mod tests { .await .expect_err("protocol version 14"); assert!( - error - .to_string() - .contains("exists from protocol version 15"), - "{error}" + matches!( + error, + Error::ProtocolVersionNotActive { + required: 15, + pinned: true, + .. + } + ), + "{error:?}" ); } diff --git a/packages/rs-sdk/src/platform/private_document_entries.rs b/packages/rs-sdk/src/platform/private_document_entries.rs index bac639d3e91..ac01135b1ce 100644 --- a/packages/rs-sdk/src/platform/private_document_entries.rs +++ b/packages/rs-sdk/src/platform/private_document_entries.rs @@ -90,6 +90,16 @@ impl FetchUnproved for PrivateDocumentEntries { /// The gRPC method a page of a private document type's store is read with. const PRIVATE_DOCUMENT_ENTRIES_METHOD: &str = "get_private_document_entries"; +/// What reading a private document type's store needs: protocol version 15, which +/// introduced private types. +pub(crate) fn private_document_entries_requirement() -> ProtocolRequirement { + ProtocolRequirement { + protocol_version: PRIVATE_DOCUMENT_CREATE_FROM_SHIELDED_POOL_INITIAL_PROTOCOL_VERSION, + method: PRIVATE_DOCUMENT_ENTRIES_METHOD, + feature: None, + } +} + /// Whether `contract` declares `document_type_name` as a private type. fn declares_private_type(contract: &DataContract, document_type_name: &str) -> bool { contract @@ -120,16 +130,8 @@ impl Sdk { data_contract_id: Identifier, document_type_name: &str, ) -> Result, Error> { - self.ensure_network_serves( - Some(ProtocolRequirement { - protocol_version: - PRIVATE_DOCUMENT_CREATE_FROM_SHIELDED_POOL_INITIAL_PROTOCOL_VERSION, - method: PRIVATE_DOCUMENT_ENTRIES_METHOD, - feature: None, - }), - None, - ) - .await?; + self.ensure_network_serves(Some(private_document_entries_requirement()), None) + .await?; let provider = self.context_provider(); if let Some(provider) = &provider { if let Some(held) = provider.get_data_contract(&data_contract_id, self.version())? { diff --git a/packages/rs-sdk/src/platform/transition/private_document_create_from_shielded_pool.rs b/packages/rs-sdk/src/platform/transition/private_document_create_from_shielded_pool.rs index 95baef28a3d..ed1b48a0cca 100644 --- a/packages/rs-sdk/src/platform/transition/private_document_create_from_shielded_pool.rs +++ b/packages/rs-sdk/src/platform/transition/private_document_create_from_shielded_pool.rs @@ -113,33 +113,18 @@ impl PrivateDocumentCreateFromShieldedPool for Sdk { settings: Option, ) -> Result { // The network's version is established before the transition is built against it, as - // limits below protocol version 15 admit no entries, and under the caller's request - // settings. The gate asks the network with one proved query, whatever this SDK's proof - // setting, which raises an unpinned SDK's version, and sends only once a verified answer - // shows the network serves the transition and this SDK speaks it, as a read of the store - // is (the broadcast checks again, for a transition built elsewhere). Otherwise it refuses - // with the structured activation error, the probe's failure as its cause when it failed, - // so a caller can tell a refusal that may lift at once from one the network's version - // or a pin explains. - let requirement = private_document_create_requirement(); - let request_settings = settings.map(|settings| settings.request_settings); - self.ensure_network_serves(Some(requirement), request_settings) - .await?; - // A mock SDK enforces no gate: its own version decides, refreshed first when it starts - // below, the refresh's failure as the refusal's cause. - if self.protocol_version_number() < requirement.protocol_version { - let (version, failure) = self - .refresh_protocol_version_or_failure(request_settings) - .await; - if version < requirement.protocol_version { - return Err(self.protocol_version_not_active( - requirement.protocol_version, - requirement.method, - requirement.feature, - failure, - )); - } - } + // limits below protocol version 15 admit no entries, under the caller's request settings + // (`Sdk::establish_protocol_requirement`). The transition is sent only once a verified + // answer shows the network serves it and this SDK speaks it, as a read of the store is + // (the broadcast checks again, for a transition built elsewhere); otherwise the send is + // refused with the structured activation error, the probe's failure as its cause when it + // failed, so a caller can tell a refusal that may lift at once from one the network's + // version or a pin explains. + self.establish_protocol_requirement( + private_document_create_requirement(), + settings.map(|settings| settings.request_settings), + ) + .await?; let state_transition = self.private_document_create_from_shielded_pool_transition( data_contract_id, document_type_name, diff --git a/packages/rs-sdk/src/sdk/protocol_gate.rs b/packages/rs-sdk/src/sdk/protocol_gate.rs index c3c11119068..59d39c18c26 100644 --- a/packages/rs-sdk/src/sdk/protocol_gate.rs +++ b/packages/rs-sdk/src/sdk/protocol_gate.rs @@ -374,6 +374,38 @@ impl Sdk { }) } + /// Establish, before a request built against the network's protocol version, that the + /// network serves `requirement` and this SDK speaks it. The gate decides first, under the + /// caller's `settings` ([`Self::ensure_network_serves`]): its probe asks with a proof + /// whatever this SDK's proof setting and raises an unpinned SDK's version, and it refuses + /// with [`Error::ProtocolVersionNotActive`], the probe's failure as its cause when it + /// failed. An SDK that enforces no gate (a mock) is checked on its own version instead, + /// refreshed first when it is below, under the same settings, the refresh's failure as the + /// refusal's cause. + pub(crate) async fn establish_protocol_requirement( + &self, + requirement: ProtocolRequirement, + settings: Option, + ) -> Result<(), Error> { + self.ensure_network_serves(Some(requirement), settings) + .await?; + if self.enforces_protocol_gate() + || self.protocol_version_number() >= requirement.protocol_version + { + return Ok(()); + } + let (version, failure) = self.refresh_protocol_version_or_failure(settings).await; + if version < requirement.protocol_version { + return Err(self.protocol_version_not_active( + requirement.protocol_version, + requirement.method, + requirement.feature, + failure, + )); + } + Ok(()) + } + /// Probe for `required` ([`Self::probe_network_protocol_version`]) and return why the /// probe failed, if it did, unless another call is probing: then wait for its answer and /// decide from it, as the call that probed does. diff --git a/packages/rs-sdk/src/sdk/protocol_gate_tests.rs b/packages/rs-sdk/src/sdk/protocol_gate_tests.rs index 0af86b6d48e..7755c8efc58 100644 --- a/packages/rs-sdk/src/sdk/protocol_gate_tests.rs +++ b/packages/rs-sdk/src/sdk/protocol_gate_tests.rs @@ -27,6 +27,7 @@ use crate::platform::data_contracts_latest_versions::{ use crate::platform::documents::document_query::RankingDirection; use crate::platform::identity_keys_remaining_budgets::IdentityKeysRemainingBudgets; use crate::platform::private_document_entries::PrivateDocumentEntries; +use crate::platform::private_document_scan::PrivateDocumentScanKeys; use crate::platform::transition::broadcast::BroadcastStateTransition; use crate::platform::transition::private_document_create_from_shielded_pool::PrivateDocumentCreateFromShieldedPool; use crate::platform::transition::put_settings::PutSettings; @@ -2590,6 +2591,84 @@ async fn should_learn_the_version_for_a_private_document_send_under_the_caller_s ); } +/// An unpinned SDK below protocol version 15 that asks for no proofs learns, from the gate's +/// proved probe, that the network runs 15, and sends a private document: the send is +/// broadcast, which the mock, answering no broadcast, fails, rather than refused for its +/// version. +#[tokio::test] +async fn should_send_a_private_document_once_a_probe_proves_15_without_proofs() { + let sdk = sdk_with_proofs(V14, false, 0, false); + answer_probe(&sdk, "pv15").await; + let action = SerializedAction { + nullifier: [1; 32], + rk: [2; 32], + cmx: [3; 32], + encrypted_note: vec![4; 216], + cv_net: [5; 32], + spend_auth_sig: [6; 64], + }; + let result = sdk + .private_document_create_from_shielded_pool( + Identifier::new([4; 32]), + "privateContactRequest".to_string(), + vec![BinaryData::new(vec![1; 589])], + 114_140_000, + OrchardBundleParams { + actions: vec![action.clone(), action], + anchor: [7; 32], + proof: vec![8; 64], + binding_signature: [9; 64], + }, + None, + ) + .await; + let error = result.expect_err("the mock answers no broadcast"); + assert!( + format!("{error:?}").contains("BroadcastStateTransitionRequest"), + "broadcast: {error:?}" + ); + assert_eq!(probes(&sdk).await, 1); + assert_eq!(sdk.protocol_version_number(), PROTOCOL_VERSION_15); +} + +/// DashPay's private contact requests, read or sealed, establish the network's protocol +/// version as the send does: an unpinned SDK below 15 that asks for no proofs learns from the +/// gate's proved probe that the network runs 15 and goes on to resolve DashPay at it, which +/// it then fetches, and, asking for no proofs, refuses to fetch unproved, where it was refused +/// for its version before. An SDK pinned below 15 is refused without asking. +#[tokio::test] +async fn should_resolve_dashpay_for_private_contact_requests_once_a_probe_proves_15() { + let sdk = sdk_with_proofs(V14, false, 0, false); + answer_probe(&sdk, "pv15").await; + let error = sdk + .scan_private_contact_requests(0, &PrivateDocumentScanKeys::new()) + .await + .expect_err("DashPay is not fetched unproved"); + assert!( + matches!(&error, Error::Config(message) if message.contains("enable proofs")), + "DashPay is resolved: {error:?}" + ); + assert_eq!(probes(&sdk).await, 1); + assert_eq!(sdk.protocol_version_number(), PROTOCOL_VERSION_15); + + let pinned = sdk_with_proofs(V14, true, 0, false); + let refused = pinned + .scan_private_contact_requests(0, &PrivateDocumentScanKeys::new()) + .await; + assert!( + matches!( + refused, + Err(Error::ProtocolVersionNotActive { + required: PROTOCOL_VERSION_15, + pinned: true, + .. + }) + ), + "{refused:?}" + ); + assert_eq!(probes(&pinned).await, 0); +} + /// A private document transition built elsewhere and sent through the generic broadcast is /// gated as the dedicated send is: from an SDK at protocol version 15, seeded or pinned, on a /// network a verified answer shows at 14, it is refused locally instead of broadcast to be From 3deebd50a5db7488f2047204948761d591f38045 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 9 Nov 2026 15:34:14 +0700 Subject: [PATCH 087/117] fix(platform-wallet): fail a private payment restore closed on a saved association that does not read Reopening the file shielded store read a saved payment's association with the private contact request it pays for as absent both when the column held SQL NULL and when it held a value that did not decode, or did not read. The record's signed transition was still restored, but the payment dropped out of `private_contact_redrives_for_wallet`, so the restoration succeeded, cleared the wallet's and identities' holds, and reserved nothing for the pair while the payment could still execute. SQL NULL stays the legacy record with no association. Any other value that does not read back is now kept as unreadable with its record, and listing the wallet's payments fails with `RecoveryCorrupted`, so the restoration fails and the holds stay; a damaged record retained without being loaded that carries an association fails it too. The record keeps its retained-and-skipped handling, and an unreadable association goes with its record when the payment resolves. Co-Authored-By: Claude Opus 5.5 --- .../network/private_contact_requests.rs | 118 ++++++++++ .../src/wallet/shielded/file_store.rs | 221 ++++++++++++++++-- 2 files changed, 317 insertions(+), 22 deletions(-) diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/private_contact_requests.rs b/packages/rs-platform-wallet/src/wallet/identity/network/private_contact_requests.rs index b46754f9e93..2fca86d3900 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/private_contact_requests.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/private_contact_requests.rs @@ -2816,6 +2816,124 @@ mod tests { assert_eq!(marker(&persistence), Some(false), "unmarked once released"); } + /// A saved private payment whose transition reads back but whose + /// association with the request it pays for does not leaves the relaunched + /// wallet held: the restoration from the shielded store fails rather than + /// lose the pair it holds, and no public contact request goes to anyone. + #[cfg(feature = "shielded")] + #[tokio::test] + async fn should_stay_held_when_a_saved_payment_s_association_does_not_read() { + let alice = identity(ALICE_PHRASE, ALICE); + let bob = identity(BOB_PHRASE, BOB); + let bob_crypto = SeedCryptoProvider::from_seed(seed(BOB_PHRASE), Network::Testnet); + let bob_signer = signer(BOB_PHRASE, &bob); + let incoming = public_request(&alice, &bob, 3); + let dir = tempfile::tempdir().expect("temporary shielded store"); + let db_path = dir.path().join("tree.sqlite"); + + let store = FileBackedShieldedStore::open_path(&db_path, 100).expect("open the store"); + let before_coordinator = Arc::new(NetworkShieldedCoordinator::new( + Arc::new(dash_sdk::Sdk::new_mock()), + Network::Testnet, + db_path.clone(), + store, + )); + let (_before_manager, before) = wallet(BOB_PHRASE, &bob, &[&alice, &bob], vec![]).await; + add_incoming(&before, BOB, &incoming).await; + let kept = Arc::new(snapshot(&before, &bob.id(), BOB_PHRASE).await); + let subwallet = SubwalletId::new(before.wallet_id(), 0); + let pay = { + let store = Arc::clone(before_coordinator.store()); + let (sender, recipient) = (bob.id(), alice.id()); + move |entry: BinaryData, started: PaymentStarted| async move { + let transition = private_contact_request_payment(entry); + let payment = PrivateContactPayment { + sender: sender.to_buffer(), + recipient: recipient.to_buffer(), + account_reference: started + .account_reference() + .expect("the send names its request"), + }; + arm_redrive_before_broadcast( + &store, + subwallet, + Some([0x77; 32]), + [7; 32], + vec![[1; 32]], + &transition, + "private document create from shielded pool", + Some(payment), + Some(&started), + ) + .await?; + Err::(PlatformWalletError::ShieldedSpendUnconfirmed { + operation: "private document create", + reason: "the result wait timed out".to_string(), + }) + } + }; + let paid = before + .identity() + .dashpay() + .accept_private_contact_request(&incoming, None, &bob_signer, &bob_crypto, pay) + .await; + assert!( + matches!( + paid, + Err(PlatformWalletError::ShieldedSpendUnconfirmed { .. }) + ), + "{paid:?}" + ); + drop(before_coordinator); + rusqlite::Connection::open(&db_path) + .expect("the store's file") + .execute( + "UPDATE shielded_pending_spends SET private_contact_payment = x'01'", + [], + ) + .expect("the association is damaged"); + + let restarted_manager = PlatformWalletManager::new( + Arc::new(dashpay_sdk(&[&alice, &bob], vec![], PlatformVersion::latest(), None).await), + kept, + Arc::new(NoopEventHandler), + ); + restarted_manager + .load_from_persistor() + .await + .expect("the relaunch loads"); + restarted_manager + .configure_shielded(&db_path) + .await + .expect("the shielded store opens"); + let restarted = restarted_manager + .get_wallet(&before.wallet_id()) + .await + .expect("the relaunched wallet"); + assert!(restarted.private_payments_held().await, "still held"); + for recipient in [alice.id(), Identifier::from([0xCC; 32])] { + let public = restarted + .identity() + .dashpay() + .send_contact_request_with_external_signer( + &bob.id(), + &recipient, + None, + AutoAcceptProofSource::None, + &bob_signer, + &bob_crypto, + ) + .await; + assert!( + matches!( + public, + Err(PlatformWalletError::PrivateContactPaymentsNotRestored) + ), + "{recipient}: {public:?}" + ); + } + } + /// The page of DashPay's private store from `start`, holding `entries`. fn store_page(start: u64, entries: Vec>) -> PrivateDocumentEntries { let total_count = start + entries.len() as u64; diff --git a/packages/rs-platform-wallet/src/wallet/shielded/file_store.rs b/packages/rs-platform-wallet/src/wallet/shielded/file_store.rs index 4068eb3dea4..21fcb44e447 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/file_store.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/file_store.rs @@ -11,7 +11,7 @@ //! tree (or, when the host persister is wired up, restored from //! SwiftData before sync runs). -use std::collections::BTreeMap; +use std::collections::{BTreeMap, BTreeSet}; use std::error::Error as StdError; use std::fmt; use std::path::{Path, PathBuf}; @@ -112,8 +112,25 @@ pub struct FileBackedShieldedStore { /// The private contact request each armed record pays for, by subwallet /// and activity id, as the `private_contact_payment` column keeps it. /// Read joined with the live records, so an entry whose record was - /// dropped is passed over. - private_contact_payments: BTreeMap<(SubwalletId, [u8; 32]), PrivateContactPayment>, + /// dropped is passed over. A record with no association (SQL NULL, as + /// one saved before associations were kept) has no entry. + private_contact_payments: BTreeMap<(SubwalletId, [u8; 32]), SavedPrivateContactPayment>, + /// The wallets of damaged records retained but not loaded that carry a + /// private contact request association, `None` for one whose wallet does + /// not read either: the payment they stand for cannot be restored, so + /// restoring a covered wallet's reservations fails + /// ([`ShieldedStore::private_contact_redrives_for_wallet`]). + unloaded_private_contact_payments: BTreeSet>, +} + +/// The private contact request association a saved record carries. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum SavedPrivateContactPayment { + /// The association, as it was saved. + Readable(PrivateContactPayment), + /// A non-null association that does not decode, or a column that does + /// not read: restoring the record's wallet fails, so its holds stay. + Unreadable, } impl FileBackedShieldedStore { @@ -161,8 +178,10 @@ impl FileBackedShieldedStore { } match payment { Some(payment) => { - self.private_contact_payments - .insert((id, redrive.activity_id), payment); + self.private_contact_payments.insert( + (id, redrive.activity_id), + SavedPrivateContactPayment::Readable(payment), + ); } None => { self.private_contact_payments @@ -297,6 +316,7 @@ impl FileBackedShieldedStore { subwallets: BTreeMap::new(), pending_conn: Mutex::new(pending_conn), private_contact_payments: BTreeMap::new(), + unloaded_private_contact_payments: BTreeSet::new(), }; store.rehydrate_pending_spends()?; Ok(store) @@ -326,12 +346,25 @@ impl FileBackedShieldedStore { // A malformed st_bytes value remains unclassifiable and must // fail closed. let classifiable_st_bytes = row.get::<_, Vec>(5).ok(); - // A damaged association only loses the restore from it. - let private_contact_payment = row - .get::<_, Option>>(9) + // The wallet, read on its own too, for a damaged record that is + // retained without being loaded. + let readable_wallet_id = row + .get::<_, Vec>(0) .ok() - .flatten() - .and_then(|bytes| PrivateContactPayment::from_bytes(&bytes)); + .and_then(|bytes| <[u8; 32]>::try_from(bytes.as_slice()).ok()); + // SQL NULL is a record with no association. Anything else that + // does not read back fails restoring the wallet's reservations + // rather than losing the pair it holds. + let private_contact_payment = match row.get::<_, Option>>(9) { + Ok(None) => None, + Ok(Some(bytes)) => Some( + PrivateContactPayment::from_bytes(&bytes) + .map_or(SavedPrivateContactPayment::Unreadable, |payment| { + SavedPrivateContactPayment::Readable(payment) + }), + ), + Err(_) => Some(SavedPrivateContactPayment::Unreadable), + }; let decoded: rusqlite::Result<_> = (|| { Ok(( row.get::<_, Vec>(0)?, @@ -345,12 +378,17 @@ impl FileBackedShieldedStore { row.get::<_, i64>(8)?, )) })(); - Ok((classifiable_st_bytes, private_contact_payment, decoded)) + Ok(( + classifiable_st_bytes, + readable_wallet_id, + private_contact_payment, + decoded, + )) }) .map_err(|e| FileShieldedStoreError::Storage(format!("query rehydrate: {e}")))?; for row in rows { - let (classifiable_st_bytes, private_contact_payment, decoded) = - row.map_err(|e| FileShieldedStoreError::RecoveryCorrupted { + let (classifiable_st_bytes, readable_wallet_id, private_contact_payment, decoded) = row + .map_err(|e| FileShieldedStoreError::RecoveryCorrupted { account_index: None, reason: format!("cannot decode recovery row: {e}"), })?; @@ -376,6 +414,10 @@ impl FileBackedShieldedStore { error = %error, "retaining but skipping a damaged shielded note-spend recovery row" ); + if private_contact_payment.is_some() { + self.unloaded_private_contact_payments + .insert(readable_wallet_id); + } continue; } return Err(FileShieldedStoreError::RecoveryCorrupted { @@ -449,6 +491,10 @@ impl FileBackedShieldedStore { reason = %error, "retaining but skipping a damaged shielded note-spend recovery row" ); + if private_contact_payment.is_some() { + self.unloaded_private_contact_payments + .insert(readable_wallet_id); + } continue; } return Err(error); @@ -722,19 +768,48 @@ impl ShieldedStore for FileBackedShieldedStore { self.arm_redrive_paying_for(id, redrive, Some(payment)) } + /// Fails with [`FileShieldedStoreError::RecoveryCorrupted`] when a live + /// record of the wallet carries an association that does not read back, + /// or a damaged record of it retained without being loaded carries one: + /// the pair that payment holds could not be restored, so the restoration + /// fails and the wallet's holds stay. fn private_contact_redrives_for_wallet( &self, wallet_id: WalletId, ) -> Result, Self::Error> { - Ok(self - .pending_redrives_for_wallet(wallet_id)? - .into_iter() - .filter_map(|(id, redrive)| { - self.private_contact_payments - .get(&(id, redrive.activity_id)) - .map(|payment| (redrive, *payment)) - }) - .collect()) + if self + .unloaded_private_contact_payments + .contains(&Some(wallet_id)) + || self.unloaded_private_contact_payments.contains(&None) + { + return Err(FileShieldedStoreError::RecoveryCorrupted { + account_index: None, + reason: "a damaged saved payment of the wallet carries a private contact request \ + association" + .to_string(), + }); + } + let mut payments = Vec::new(); + for (id, redrive) in self.pending_redrives_for_wallet(wallet_id)? { + match self + .private_contact_payments + .get(&(id, redrive.activity_id)) + { + None => {} + Some(SavedPrivateContactPayment::Readable(payment)) => { + payments.push((redrive, *payment)); + } + Some(SavedPrivateContactPayment::Unreadable) => { + return Err(FileShieldedStoreError::RecoveryCorrupted { + account_index: Some(id.account_index), + reason: "the private contact request a saved payment pays for does not \ + decode" + .to_string(), + }); + } + } + } + Ok(payments) } fn pending_redrives(&self, id: SubwalletId) -> Result, Self::Error> { @@ -993,6 +1068,8 @@ impl ShieldedStore for FileBackedShieldedStore { self.subwallets.retain(|id, _| id.wallet_id != wallet_id); self.private_contact_payments .retain(|(id, _), _| id.wallet_id != wallet_id); + self.unloaded_private_contact_payments + .remove(&Some(wallet_id)); Ok(()) } @@ -1042,6 +1119,7 @@ impl ShieldedStore for FileBackedShieldedStore { } self.subwallets.clear(); self.private_contact_payments.clear(); + self.unloaded_private_contact_payments.clear(); Ok(()) } @@ -1110,6 +1188,7 @@ impl ShieldedStore for FileBackedShieldedStore { #[cfg(test)] mod tests { use super::*; + use dpp::state_transition::shielded_transfer_transition::v0::ShieldedTransferTransitionV0; /// Unique temp path for a test tree (no `tempfile` dev-dep). fn temp_tree_path(tag: &str) -> std::path::PathBuf { @@ -1459,6 +1538,104 @@ mod tests { } } + /// A saved payment's private contact request association that does not + /// read back fails restoring its wallet's reservations, rather than losing + /// the pair it holds: a non-null value that does not decode on a record + /// that loads, which is kept, and one carried by a damaged note-spend + /// record retained without being loaded. SQL NULL stays a record with no + /// association, and the wallet restores again once the record goes. + #[test] + fn should_fail_restoring_a_wallet_whose_saved_association_does_not_read() { + let path = temp_tree_path("unreadable_private_contact_payment"); + let wallet_id = [0x6A; 32]; + let id = SubwalletId::new(wallet_id, 0); + let note_transition: StateTransition = ShieldedTransferTransitionV0 { + actions: vec![], + value_balance: 1_000, + anchor: [0x52; 32], + proof: vec![], + binding_signature: [0; 64], + } + .into(); + let record = PendingRedrive { + activity_id: [0x11; 32], + anchor: [0x52; 32], + nullifiers: vec![[0x13; 32]], + st_bytes: note_transition.serialize_to_bytes().unwrap(), + attempts: 0, + identity_nonce_finalized: false, + identity_user_abandoned: false, + }; + let payment = PrivateContactPayment { + sender: [0xB1; 32], + recipient: [0xA1; 32], + account_reference: 5, + }; + let arm = || { + let mut store = FileBackedShieldedStore::open_path(&path, 100).unwrap(); + store + .arm_private_contact_redrive(id, record.clone(), payment) + .unwrap(); + }; + let set = |assignment: &str| { + rusqlite::Connection::open(&path) + .unwrap() + .execute( + &format!("UPDATE shielded_pending_spends SET {assignment}"), + [], + ) + .unwrap(); + }; + let unreadable = |store: &FileBackedShieldedStore| { + matches!( + store.private_contact_redrives_for_wallet(wallet_id), + Err(FileShieldedStoreError::RecoveryCorrupted { .. }) + ) + }; + + arm(); + set("private_contact_payment = x'01'"); + let mut store = FileBackedShieldedStore::open_path(&path, 100).unwrap(); + assert!(unreadable(&store), "an association that does not decode"); + assert_eq!(store.pending_redrives(id).unwrap(), vec![record.clone()]); + assert!(store + .private_contact_redrives_for_wallet([0x6B; 32]) + .unwrap() + .is_empty()); + store.clear_redrive(id, &record.activity_id).unwrap(); + assert!( + store + .private_contact_redrives_for_wallet(wallet_id) + .unwrap() + .is_empty(), + "the record went" + ); + drop(store); + + arm(); + set("anchor = x'01'"); + let store = FileBackedShieldedStore::open_path(&path, 100).unwrap(); + assert!(store.pending_redrives(id).unwrap().is_empty(), "not loaded"); + assert!(unreadable(&store), "a damaged record carrying one"); + drop(store); + + set( + "anchor = x'5252525252525252525252525252525252525252525252525252525252525252', \ + private_contact_payment = NULL", + ); + let store = FileBackedShieldedStore::open_path(&path, 100).unwrap(); + assert_eq!(store.pending_redrives(id).unwrap(), vec![record]); + assert!( + store + .private_contact_redrives_for_wallet(wallet_id) + .unwrap() + .is_empty(), + "no association" + ); + drop(store); + let _ = std::fs::remove_file(path); + } + #[test] fn should_skip_only_damaged_rows_proven_to_spend_shielded_notes() { use dpp::state_transition::shielded_transfer_transition::v0::ShieldedTransferTransitionV0; From 8cdeadfae0ffa90103fb885c825acdaada69cc70 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 9 Nov 2026 15:34:14 +0700 Subject: [PATCH 088/117] fix(platform-wallet): match a recovered private request's recipient key as a curve point Recovering a private contact request the wallet's identity sent compared the 33-byte compressed key the entry was sealed to with each recipient key's stored bytes. An ECDSA_SECP256K1 identity key may hold the same point uncompressed, which DPP accepts and the sealer reads, so such a request was passed over and the read position moved past it: a sender restored from seed lost the evidence that the requests went privately, and could publish a public request to that recipient. Each ECDSA_SECP256K1 key of a purpose an entry can be sealed to is now parsed and compared as a point; other keys are not matched. Co-Authored-By: Claude Opus 5.5 --- .../network/private_contact_requests.rs | 105 +++++++++++++++++- 1 file changed, 103 insertions(+), 2 deletions(-) diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/private_contact_requests.rs b/packages/rs-platform-wallet/src/wallet/identity/network/private_contact_requests.rs index 2fca86d3900..757b6f440af 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/private_contact_requests.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/private_contact_requests.rs @@ -1599,7 +1599,10 @@ impl DashPayView<'_, B> { if unreadable_recipients.contains(&sent.recipient_id) { continue; } - let recipient_key = sent.recipient_public_key.serialize(); + // The recipient's key the entry was sealed to, compared as + // a curve point: a key may hold the point compressed or + // uncompressed, and the sealer reads both. Only the keys an + // entry can be sealed to are matched. let recipient_key_id = recipients .get(&sent.recipient_id) .and_then(|proved| proved.identity.as_ref()) @@ -1607,7 +1610,12 @@ impl DashPayView<'_, B> { recipient .public_keys() .iter() - .find(|(_, key)| key.data().as_slice() == recipient_key.as_slice()) + .find(|(_, key)| { + key.key_type() == KeyType::ECDSA_SECP256K1 + && recipient_key_purpose_is_valid(key.purpose()) + && PublicKey::from_slice(key.data().as_slice()) + .is_ok_and(|point| point == sent.recipient_public_key) + }) .map(|(key_id, _)| *key_id) }); let Some(recipient_key_id) = recipient_key_id else { @@ -2072,6 +2080,7 @@ mod tests { use dash_sdk::platform::ContextProvider; use dpp::address_funds::AddressWitness; use dpp::data_contract::{DataContract, TokenConfiguration}; + use dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeySettersV0; use dpp::identity::identity_public_key::v0::IdentityPublicKeyV0; use dpp::identity::v0::IdentityV0; use dpp::identity::{IdentityPublicKey, SecurityLevel}; @@ -2816,6 +2825,98 @@ mod tests { assert_eq!(marker(&persistence), Some(false), "unmarked once released"); } + /// `identity` with each of its `ECDSA_SECP256K1` keys holding its point + /// uncompressed, a 65-byte encoding DPP accepts and the sealer reads. + fn with_uncompressed_keys(identity: &Identity) -> Identity { + Identity::V0(IdentityV0 { + id: identity.id(), + public_keys: identity + .public_keys() + .iter() + .map(|(id, key)| { + let mut key = key.clone(); + if key.key_type() == KeyType::ECDSA_SECP256K1 { + let point = + PublicKey::from_slice(key.data().as_slice()).expect("a valid point"); + key.set_data(BinaryData::new(point.serialize_uncompressed().to_vec())); + } + (*id, key) + }) + .collect(), + balance: identity.balance(), + revision: identity.revision(), + }) + } + + /// A request sealed to a recipient key holding its point uncompressed is + /// recovered from the store by its sender restored from seed, as one sealed + /// to a compressed key is: the key is matched as a curve point. A public + /// send to that recipient is then refused, the requests having gone + /// privately. + #[tokio::test] + async fn should_recover_a_request_sealed_to_an_uncompressed_recipient_key() { + let alice = identity(ALICE_PHRASE, ALICE); + let bob = with_uncompressed_keys(&identity(BOB_PHRASE, BOB)); + let alice_crypto = SeedCryptoProvider::from_seed(seed(ALICE_PHRASE), Network::Testnet); + let alice_signer = signer(ALICE_PHRASE, &alice); + let (_manager, alice_wallet) = wallet(ALICE_PHRASE, &alice, &[&alice, &bob], vec![]).await; + let kept = Arc::new(Mutex::new(Vec::new())); + let sent = alice_wallet + .identity() + .dashpay() + .send_private_contact_request( + &alice.id(), + &bob.id(), + None, + AutoAcceptProofSource::None, + None, + &alice_signer, + &alice_crypto, + keep_entry(&kept), + ) + .await; + assert!( + matches!( + sent, + Err(PlatformWalletError::PrivateContactRequestPending(_)) + ), + "paid, not read back: {sent:?}" + ); + let entry = kept.lock().expect("kept").remove(0); + + let (_restored_manager, restored) = + wallet(ALICE_PHRASE, &alice, &[&alice, &bob], vec![entry.to_vec()]).await; + let view = restored.identity().dashpay(); + view.sync_private_contact_requests(&alice_crypto, 4) + .await + .expect("the store is read"); + managed(&restored, ALICE, |managed| { + assert!( + managed.dashpay().outgoing_request(&bob.id()).is_some(), + "recovered" + ); + }) + .await; + let public = view + .send_contact_request_with_external_signer( + &alice.id(), + &bob.id(), + None, + AutoAcceptProofSource::None, + &alice_signer, + &alice_crypto, + ) + .await; + assert!( + matches!( + public, + Err(PlatformWalletError::PrivateContactRequestNeedsPrivateReply(recipient)) + if recipient == bob.id() + ), + "{public:?}" + ); + } + /// A saved private payment whose transition reads back but whose /// association with the request it pays for does not leaves the relaunched /// wallet held: the restoration from the shielded store fails rather than From 8260a1cbc0872d771671587186440fb779b3f714 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 9 Nov 2026 15:34:27 +0700 Subject: [PATCH 089/117] fix(platform-wallet): keep a private payment's hold durable, required and through an ambiguous broadcast Three gaps let a reopened or still-running wallet publish a pair whose private contact request payment could still execute. A backend that did not keep the private contact request scan state took a private payment with no recovery marker, and a buffered one could lose the marker it stored. A private send or accept is now refused with `PrivateContactRecoveryUnsupported` (FFI code 73) before anything is sealed or paid unless the backend keeps that state, and the marker is flushed before paying when the backend does not commit a store inline; a failed store or flush refuses the payment. A clear is not flushed: losing one only holds the identity after a restart. A load that drops the scan state of a backend no longer attesting it keeps a restored hold. Keeping the scan state now also takes atomic changesets (`PersistenceCapabilities::PRIVATE_CONTACT_SCAN_STATE_RESTART`): an un-ignore stores the sender's removal and the read-position rewind in one changeset, and a backend committing one without the other would read on past the requests passed over while the sender was ignored. A backend declaring the scan state alone keeps none of it. A spend whose broadcast failed with `Unavailable`, which can follow delivery, released the private request's pair with its notes. It now fails with `ShieldedBroadcastMaybeDelivered` (FFI code 16, as a failed broadcast); its notes are still released, as the nullifier set keeps funds safe, but the pair stays held and its marker set, as the entry may still be appended. Co-Authored-By: Claude Opus 5.5 --- .../ERROR_CODE_REGISTRY.md | 5 +- packages/rs-platform-wallet-ffi/src/error.rs | 39 +- .../src/shielded_send.rs | 17 +- packages/rs-platform-wallet/README.md | 4 +- .../src/broadcast_outcome.rs | 34 ++ .../changeset/identity_manager_start_state.rs | 3 +- .../src/changeset/persistence_capabilities.rs | 15 + packages/rs-platform-wallet/src/error.rs | 29 ++ .../rs-platform-wallet/src/manager/load.rs | 7 +- .../network/private_contact_requests.rs | 493 +++++++++++++++++- .../managed_identity/contact_requests.rs | 9 +- .../src/wallet/persister.rs | 2 +- .../src/wallet/shielded/operations.rs | 42 +- 13 files changed, 662 insertions(+), 37 deletions(-) diff --git a/packages/rs-platform-wallet-ffi/ERROR_CODE_REGISTRY.md b/packages/rs-platform-wallet-ffi/ERROR_CODE_REGISTRY.md index b34bb150a6c..b1cda68de23 100644 --- a/packages/rs-platform-wallet-ffi/ERROR_CODE_REGISTRY.md +++ b/packages/rs-platform-wallet-ffi/ERROR_CODE_REGISTRY.md @@ -114,7 +114,7 @@ These are shipped ABI. Do not renumber. | 98 | `NotFound` | Sentinel — `Option` returned as an error | | 99 | `ErrorUnknown` | Sentinel — unmapped/flattened errors | -**Next allocatable integer: 73**: 27–72 are all claimed (27, 29, 31, 34–42 +**Next allocatable integer: 74**: 27–73 are all claimed (27, 29, 31, 34–42 and 46 merged; 43–45 proposed by active #4313 at head `0302b188ab`; 47 and 48 proposed by active #4356 (47 renumbered from 42, 48 from 43 — see their rows below); 49–54 proposed by active #4586 (the persister @@ -127,7 +127,7 @@ renumbered from 59 when #5322 took that number on `v5.1-dev`); 61–67 held by open branches with no row here yet (61 `ErrorAssetLockRecipientMismatch`, 62 both `ErrorShieldedClaimBindingMismatch` and `ErrorIdentityRevisionMismatch`, 63–66 the invitation-claim preflight, 67 `ErrorShieldedTreeInconsistent`); 68 -to 72 proposed for DashPay private contact requests; 28, 30, +to 73 proposed for DashPay private contact requests; 28, 30, 32 and 33 reserved). **28, 30, 32 and 33 are RESERVED, not free**: 28 and 30 were vacated when the reservation trio moved to 34–36; 32 and 33 lapsed when their in-repo owners @@ -186,6 +186,7 @@ Fork-era numbers remain in the collision history, which is immutable record. | 70 | `ErrorProtocolVersionNotActive` | DashPay private contact requests (FFI, Swift and Kotlin) | Proposed. The SDK refused a request without sending it (`dash_sdk::Error::ProtocolVersionNotActive`), because the network is proven at a protocol version below the one that serves it or the SDK is pinned below it: for example the private contact request sync on a network before protocol version 15. Expected on such a network, so a host may stay quiet about it; it lifts when the network upgrades. Mapped by the blanket conversion for every operation that propagates the SDK error. A refusal that may lift sooner (`is_transient_protocol_version_refusal`: the network's version could not be confirmed) keeps the catch-all `ErrorUnknown`, as before. Previously both fell to `ErrorUnknown`. Swift (`PlatformWalletError.protocolVersionNotActive`) and Kotlin (`DashSdkError.PlatformWallet.ProtocolVersionNotActive`) type it | | 71 | `ErrorPrivateContactRequestPending` | DashPay private contact requests (FFI, Swift and Kotlin) | Proposed. Maps `PlatformWalletError::PrivateContactRequestPending`: a private contact request to that identity is in flight (another private send or reply reserved it before sealing), or was paid for and a short read-back sync did not find the entry in the store yet; or the identity's private store could not be read to its end before its first private send or accept since load, which the wallet reads first so that a reply paid for in an earlier process is adopted rather than paid for again. Nothing new was paid; a later private contact request sync, or a retry, settles it. Pending, not failed: the host must not submit it again. Returned by the private send, the private accept and the in-kind accept; the shielded spend contract (`map_spend_result`) keeps the code rather than flattening it to `ErrorWalletOperation`. Swift (`PlatformWalletError.privateContactRequestPending`, `ErrorHandlingTests` pin) and Kotlin (`DashSdkError.PlatformWallet.PrivateContactRequestPending`, `DashSdkErrorTest` pin) type it, and both example apps keep the submission pending | | 72 | `ErrorPrivateContactPaymentsNotRestored` | DashPay private contact requests (FFI, Swift and Kotlin) | Proposed. Maps `PlatformWalletError::PrivateContactPaymentsNotRestored`: wallet readiness, not one contact's pending request (71). The wallet was loaded after binding a shielded account in an earlier process, or the identity was loaded with its private scan state marking a private payment that may be unresolved, so its unconfirmed private payments may hold pairs no reservation shows yet. The public send, the public accept and the in-kind accept are refused with nothing signed or broadcast until they are restored: configuring the shielded store restores them with no keys, as binding the wallet's shielded account does; a private sync does not, and a build without the `shielded` feature never does. `platform_wallet_get_private_contact_readiness` reads the state, so a host shows it rather than inferring it. Swift and Kotlin must type it, as for 71, and treat it as held until the readiness reads restored | +| 73 | `ErrorPrivateContactRecoveryUnsupported` | DashPay private contact requests (FFI, Swift and Kotlin) | Proposed. Maps `PlatformWalletError::PrivateContactRecoveryUnsupported`: the persister does not declare `PRIVATE_CONTACT_REQUEST_SCAN_STATE` (bit 14) together with `ATOMIC_CHANGESETS` (bit 0, with the changeset begin and end callbacks wired), so the private send, the private accept and the in-kind accept's private reply are refused before anything is sealed or paid. The payment's recovery marker could not survive a restart, and a process reopening the wallet without the `shielded` feature, or before the shielded store is configured, could publish the pair while the payment may still execute. Not retryable as it is: a host that persists the private contact request scan state in changesets it commits whole declares both. Swift and Kotlin must type it | **Code 31 left this table on 2026-08-04.** `ErrorSigningKeyUnavailable` sat here as #4183's proposal until #4183 merged (`189a3abb1c`); it is now in the merged diff --git a/packages/rs-platform-wallet-ffi/src/error.rs b/packages/rs-platform-wallet-ffi/src/error.rs index 2fbdd043fe9..da5e788ca6a 100644 --- a/packages/rs-platform-wallet-ffi/src/error.rs +++ b/packages/rs-platform-wallet-ffi/src/error.rs @@ -675,6 +675,17 @@ pub enum PlatformWalletFFIResultCode { /// restored. A build without the `shielded` feature never restores them. ErrorPrivateContactPaymentsNotRestored = 72, + /// Maps `PlatformWalletError::PrivateContactRecoveryUnsupported`: the + /// persister does not declare that it keeps the private contact request + /// scan state (`PRIVATE_CONTACT_REQUEST_SCAN_STATE`, bit 14) in atomic + /// changesets (`ATOMIC_CHANGESETS`, bit 0, with the changeset begin and end + /// callbacks wired), so a private contact request is not paid for, nothing + /// sealed or paid. Its recovery + /// marker could not survive a restart, and a process reopening the wallet + /// could then publish the pair while the payment may still execute. Not + /// retryable as it is: a host persisting the scan state declares both. + ErrorPrivateContactRecoveryUnsupported = 73, + /// The named thing does not exist. /// /// Originally (and still mostly) the code for every `Option` returned as an @@ -963,7 +974,8 @@ impl From for PlatformWalletFFIResult { // slot-holding contract. A blanket conversion can't write // `out_identity_id` (it has no out-param), so the most it can do is // keep the typed code alive — which is what these arms guarantee. - PlatformWalletError::ShieldedBroadcastFailed(..) => { + PlatformWalletError::ShieldedBroadcastFailed(..) + | PlatformWalletError::ShieldedBroadcastMaybeDelivered(..) => { PlatformWalletFFIResultCode::ErrorShieldedBroadcastFailed } PlatformWalletError::ShieldedBroadcastUnconfirmed { .. } => { @@ -1129,6 +1141,10 @@ impl From for PlatformWalletFFIResult { PlatformWalletError::PrivateContactPaymentsNotRestored => { PlatformWalletFFIResultCode::ErrorPrivateContactPaymentsNotRestored } + // The persister cannot keep a private payment's recovery marker. + PlatformWalletError::PrivateContactRecoveryUnsupported => { + PlatformWalletFFIResultCode::ErrorPrivateContactRecoveryUnsupported + } // A signer failure can also reach this blanket impl wrapped as // `PlatformWalletError::Sdk(dash_sdk::Error::Protocol(..))` (any // wallet operation that propagates the SDK error via `?`). The @@ -2854,4 +2870,25 @@ mod private_contact_request_error_tests { ); assert_eq!(result.code as u32, 72); } + + /// A private payment over a persister that cannot keep its recovery marker + /// reaches the host by its own code, as does a spend whose broadcast may + /// have been delivered by the broadcast-failed one. + #[test] + fn should_report_unsupported_private_recovery_by_its_own_code() { + let result = + PlatformWalletFFIResult::from(PlatformWalletError::PrivateContactRecoveryUnsupported); + assert_eq!( + result.code, + PlatformWalletFFIResultCode::ErrorPrivateContactRecoveryUnsupported + ); + assert_eq!(result.code as u32, 73); + let delivered = PlatformWalletFFIResult::from( + PlatformWalletError::ShieldedBroadcastMaybeDelivered("unavailable".to_string()), + ); + assert_eq!( + delivered.code, + PlatformWalletFFIResultCode::ErrorShieldedBroadcastFailed + ); + } } diff --git a/packages/rs-platform-wallet-ffi/src/shielded_send.rs b/packages/rs-platform-wallet-ffi/src/shielded_send.rs index 04ccfc420d2..5becdafb0fa 100644 --- a/packages/rs-platform-wallet-ffi/src/shielded_send.rs +++ b/packages/rs-platform-wallet-ffi/src/shielded_send.rs @@ -635,12 +635,17 @@ pub(crate) fn map_spend_result( PlatformWalletFFIResultCode::ErrorShieldedNoRecordedAnchor, format!("Wallet is still syncing to a confirmed state — try again shortly. ({e})"), ), - // Definitive failure: the transition was not executed and the notes - // were released; the host may retry. - Err(e @ PlatformWalletError::ShieldedBroadcastFailed(_)) => PlatformWalletFFIResult::err( - PlatformWalletFFIResultCode::ErrorShieldedBroadcastFailed, - format!("{operation} failed: {e}"), - ), + // Definitive failure: the notes were released; the host may retry. A + // broadcast that may have been delivered reads the same for the spend, + // while a private contact request it paid for stays pending (71 on a + // retry, `managed_identity_get_private_reply_pending`). + Err(e @ PlatformWalletError::ShieldedBroadcastFailed(_)) + | Err(e @ PlatformWalletError::ShieldedBroadcastMaybeDelivered(_)) => { + PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorShieldedBroadcastFailed, + format!("{operation} failed: {e}"), + ) + } // Definitively failed on an address-nonce race (a shield spends platform // address funds; a shield reserves no notes). Its own code carries the // safe-to-retry contract AND lets the host recognize the self-healing diff --git a/packages/rs-platform-wallet/README.md b/packages/rs-platform-wallet/README.md index c501bd885f3..36e70893800 100644 --- a/packages/rs-platform-wallet/README.md +++ b/packages/rs-platform-wallet/README.md @@ -126,7 +126,9 @@ Startup errors distinguish damaged recovery metadata or undecodable signed bytes A private contact request's payment that is unconfirmed when the process ends stays in the shielded store with the request it pays for. Until its reservation is restored, nothing shows that the pair is taken: the request's entry is not in DashPay's store until the payment executes. So a reloaded wallet sends and accepts no public contact request while such a payment may be unrestored, and returns `PrivateContactPaymentsNotRestored` (FFI code 72) with nothing read, signed or broadcast. The in-kind accept is held the same way. Two things hold it: - In a build with the `shielded` feature, `load_from_persistor` holds a wallet that bound a shielded account before (its persisted viewing key shows it). A wallet with no persisted viewing key never bound one, since a bind persists the key before installing anything and refuses when it cannot. -- In every build, an identity whose private scan state marks a private payment that may be unresolved (`PrivateContactRequestsPosition::private_payments_unresolved`) is held. The wallet stores the marker before such a payment may reach the network, for a backend attesting `PRIVATE_CONTACT_REQUEST_SCAN_STATE`, and stores it cleared once the identity holds no private request it is paying for or has not read back. +- In every build, an identity whose private scan state marks a private payment that may be unresolved (`PrivateContactRequestsPosition::private_payments_unresolved`) is held. The wallet stores the marker durably before such a payment may reach the network, flushing a backend that does not commit a store inline, and stores it cleared once the identity holds no private request it is paying for or has not read back. A loaded hold is kept even when the backend no longer attests the scan state. + +A private contact request is therefore paid for only over a backend attesting `PRIVATE_CONTACT_REQUEST_SCAN_STATE` together with `ATOMIC_CHANGESETS` (`PersistenceCapabilities::PRIVATE_CONTACT_SCAN_STATE_RESTART`), as the scan state is written in changesets that must commit whole: any other refuses the private send and the private accept with `PrivateContactRecoveryUnsupported` (FFI code 73) before anything is sealed or paid, and a marker that cannot be stored or flushed refuses the payment too. A payment whose broadcast may have been delivered (`ShieldedBroadcastMaybeDelivered`, an `Unavailable` answer) keeps its pair held and its marker set, though its notes are released. A saved payment whose association with the request it pays for does not read back fails the restoration, so the holds stay. `PlatformWalletManager::configure_shielded` restores the payments of every held wallet from the store, with no keys, and a load after it restores those it loads. Binding a wallet's shielded account restores its own. A private sync does not. Hosts that use shielded support call `configure_shielded` right after the load. A host that opens no shielded store this session, and any build without the `shielded` feature, keeps those wallets' and identities' public contact requests refused: the private payment may still execute, and a public request would publish the pair the private one kept off chain. `DashPayView::private_contact_readiness` (FFI `platform_wallet_get_private_contact_readiness`) reads whether the payments are restored, whether the private store was read to its end since load, and whether the marker is set, so a host shows the wallet's state rather than inferring it from refusals. diff --git a/packages/rs-platform-wallet/src/broadcast_outcome.rs b/packages/rs-platform-wallet/src/broadcast_outcome.rs index 82648b66799..0bcce5f4c5d 100644 --- a/packages/rs-platform-wallet/src/broadcast_outcome.rs +++ b/packages/rs-platform-wallet/src/broadcast_outcome.rs @@ -105,3 +105,37 @@ pub(crate) fn broadcast_definitely_failed(e: &dash_sdk::Error) -> bool { _ => false, } } + +/// Whether a `broadcast()` failure [`broadcast_definitely_failed`] counts as +/// definite may still have delivered the transition: an `Unavailable` +/// status, which can follow delivery or hide an earlier attempt's, as noted +/// there. The spend's notes are released all the same, as the on-chain +/// nullifier set keeps funds safe, but what the transition does may still +/// happen: a private contact request's entry may still be appended, so its +/// pair stays held. A consensus verdict, a rejection code and an empty +/// address list prove the transition was not admitted. +#[cfg(feature = "shielded")] +pub(crate) fn broadcast_may_have_been_delivered(e: &dash_sdk::Error) -> bool { + use dash_sdk::dapi_client::transport::TransportError; + use dash_sdk::dapi_client::DapiClientError; + use dash_sdk::dapi_grpc::tonic::Code; + + fn unavailable(t: &TransportError) -> bool { + let TransportError::Grpc(status) = t; + status.code() == Code::Unavailable + } + + if carries_consensus_rejection(e) { + return false; + } + match e { + dash_sdk::Error::DapiClientError(DapiClientError::Transport(t)) => unavailable(t), + dash_sdk::Error::DapiClientError(DapiClientError::NoAvailableAddressesToRetry(t)) => { + unavailable(t) + } + dash_sdk::Error::NoAvailableAddressesToRetry(inner) => { + broadcast_may_have_been_delivered(inner) + } + _ => false, + } +} diff --git a/packages/rs-platform-wallet/src/changeset/identity_manager_start_state.rs b/packages/rs-platform-wallet/src/changeset/identity_manager_start_state.rs index 850edff799e..f4f993c3463 100644 --- a/packages/rs-platform-wallet/src/changeset/identity_manager_start_state.rs +++ b/packages/rs-platform-wallet/src/changeset/identity_manager_start_state.rs @@ -44,7 +44,8 @@ impl IdentityManagerStartState { /// load does for a backend that does not attest /// [`PRIVATE_CONTACT_REQUEST_SCAN_STATE`](crate::changeset::PersistenceCapabilities::PRIVATE_CONTACT_REQUEST_SCAN_STATE): /// nothing keeps such a backend's copy current, so the store is read from - /// its start. + /// its start. A restored hold on an unresolved private payment is kept + /// (`ManagedIdentity::forget_private_contact_scan_state`). pub(crate) fn forget_private_contact_scan_state(&mut self) { for managed in self .wallet_identities diff --git a/packages/rs-platform-wallet/src/changeset/persistence_capabilities.rs b/packages/rs-platform-wallet/src/changeset/persistence_capabilities.rs index 21d07be3706..9a2913f0792 100644 --- a/packages/rs-platform-wallet/src/changeset/persistence_capabilities.rs +++ b/packages/rs-platform-wallet/src/changeset/persistence_capabilities.rs @@ -122,6 +122,11 @@ impl PersistenceCapabilities { /// reads the store from where it stopped. Without this bit nothing of /// it is written, a restored value is dropped, and each launch reads the /// store from its start. + /// + /// Honoured only together with [`Self::ATOMIC_CHANGESETS`] + /// ([`Self::PRIVATE_CONTACT_SCAN_STATE_RESTART`]): the scan state is written + /// in changesets that must commit whole, so a backend declaring this bit + /// alone is treated as keeping none of it. pub const PRIVATE_CONTACT_REQUEST_SCAN_STATE: Self = Self(1 << 14); /// Index of the highest bit declared above. It lives here, beside the @@ -144,6 +149,16 @@ impl PersistenceCapabilities { pub const SHIELDED_FVK_RESTART: Self = Self(Self::ATOMIC_CHANGESETS.0 | Self::SHIELDED_VIEWING_KEYS.0); + /// Capabilities required for the private contact request scan state to + /// round-trip, and so for a private contact request to be paid for: the + /// state itself, and atomic changesets. An un-ignore stores the sender's + /// removal and the read-position rewind it brings in one changeset; a + /// backend committing the removal without the rewind would, after a + /// restart, read on past the requests passed over while the sender was + /// ignored, and nothing would read them again. + pub const PRIVATE_CONTACT_SCAN_STATE_RESTART: Self = + Self(Self::ATOMIC_CHANGESETS.0 | Self::PRIVATE_CONTACT_REQUEST_SCAN_STATE.0); + /// Capabilities required to durably reconcile an asset-lock status and /// restore that exact row after process restart. pub const ASSET_LOCK_RECONCILIATION: Self = diff --git a/packages/rs-platform-wallet/src/error.rs b/packages/rs-platform-wallet/src/error.rs index ff8c2536086..11a8c3afd8e 100644 --- a/packages/rs-platform-wallet/src/error.rs +++ b/packages/rs-platform-wallet/src/error.rs @@ -696,6 +696,25 @@ pub enum PlatformWalletError { )] PrivateContactPaymentsNotRestored, + /// A private contact request is paid for only with a persister that keeps + /// its recovery marker durably + /// (`PrivateContactRequestsPosition::private_payments_unresolved`), which + /// it declares with + /// [`PersistenceCapabilities::PRIVATE_CONTACT_SCAN_STATE_RESTART`](crate::changeset::PersistenceCapabilities::PRIVATE_CONTACT_SCAN_STATE_RESTART), + /// the scan state and atomic changesets: + /// without it, a process reopening the wallet, in a build without the + /// `shielded` feature or before the shielded store is configured, could + /// not tell that the payment may still execute, and would publish the + /// pair publicly. Refused before anything is sealed or paid. A host + /// persisting the private contact request scan state in changesets it + /// commits whole declares both. + #[error( + "private contact requests are paid for only with a persister that keeps the private \ + contact request scan state; declare PRIVATE_CONTACT_REQUEST_SCAN_STATE and \ + ATOMIC_CHANGESETS" + )] + PrivateContactRecoveryUnsupported, + /// No DPNS `domain` document exists for the requested name (exact /// normalized-label lookup came back empty). Distinct from /// [`Self::InvalidParameter`]: the input was well-formed, the name @@ -1002,6 +1021,16 @@ pub enum PlatformWalletError { #[error("Shielded broadcast failed: {0}")] ShieldedBroadcastFailed(String), + /// A shielded spend's broadcast failed in a way that does not prove the + /// transition was never delivered: an `Unavailable` status, which can + /// follow delivery. Its notes are released, as for + /// [`Self::ShieldedBroadcastFailed`], since the on-chain nullifier set + /// keeps funds safe, but the transition may still execute: a private + /// contact request it pays for keeps its pair held, as for one paid for + /// and not read back. + #[error("Shielded broadcast failed, but may have been delivered: {0}")] + ShieldedBroadcastMaybeDelivered(String), + /// The shielded identity-create transition was **broadcast and accepted by the relay**, but the /// SDK could not confirm its execution result (the result-proof fetch/verify failed — e.g. a /// transient DAPI/proof error, not a platform rejection). The identity with `identity_id` may diff --git a/packages/rs-platform-wallet/src/manager/load.rs b/packages/rs-platform-wallet/src/manager/load.rs index b5f43aa7c1b..8e2148b8a7f 100644 --- a/packages/rs-platform-wallet/src/manager/load.rs +++ b/packages/rs-platform-wallet/src/manager/load.rs @@ -260,12 +260,13 @@ impl PlatformWalletManager

{ generation: Arc::clone(&generation), identity_manager: { // A backend that does not attest the private contact - // request scan state keeps no current copy of it: the - // store is read from its start. + // request scan state, with the atomic changesets it needs, + // keeps no current copy of it: the store is read from its + // start. if !self .persister .persistence_capabilities() - .contains(PersistenceCapabilities::PRIVATE_CONTACT_REQUEST_SCAN_STATE) + .contains(PersistenceCapabilities::PRIVATE_CONTACT_SCAN_STATE_RESTART) { identity_manager.forget_private_contact_scan_state(); } diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/private_contact_requests.rs b/packages/rs-platform-wallet/src/wallet/identity/network/private_contact_requests.rs index 757b6f440af..a4104a87a28 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/private_contact_requests.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/private_contact_requests.rs @@ -69,7 +69,7 @@ use super::contact_requests::{ use super::signing_key::first_available; use super::{dashpay_contract, DashPayView, IdentityWallet}; use crate::broadcaster::TransactionBroadcaster; -use crate::changeset::{ContactChangeSet, PrivateContactRequestsPosition}; +use crate::changeset::{ContactChangeSet, PersistenceError, PrivateContactRequestsPosition}; use crate::error::PlatformWalletError; use crate::wallet::identity::types::dashpay::contact_request::ContactRequest; use crate::wallet::identity::types::dashpay::established_contact::EstablishedContact; @@ -646,6 +646,11 @@ impl DashPayView<'_, B> { F: FnOnce(BinaryData, PaymentStarted) -> Fut + Send, Fut: Future> + Send, { + // Before anything is read, sealed or paid: a payment no restart could + // see would leave its pair unheld. + if !self.persister.persists_private_contact_scan_state() { + return Err(PlatformWalletError::PrivateContactRecoveryUnsupported); + } self.read_private_store_before_sending(sender_identity_id, recipient_identity_id, crypto) .await?; self.release_rejected_private_requests().await?; @@ -691,11 +696,14 @@ impl DashPayView<'_, B> { pay, ) .await; - // A payment that may have executed keeps the request pending. + // A payment that may have executed keeps the request pending, its + // marker set: one whose broadcast may have been delivered too, though + // its notes were released, as its entry may still be appended. let keep_pending = matches!( &sent, Err(PlatformWalletError::PrivateContactRequestPending(_) - | PlatformWalletError::ShieldedSpendUnconfirmed { .. }) + | PlatformWalletError::ShieldedSpendUnconfirmed { .. } + | PlatformWalletError::ShieldedBroadcastMaybeDelivered(_)) ); if keep_pending { reservation.keep(); @@ -847,27 +855,39 @@ impl DashPayView<'_, B> { } /// Store `owner`'s position record, marking it as holding a private payment - /// that may be unresolved, before anything is paid, for a backend that - /// keeps it. A failed store refuses the send, with nothing paid: a payment - /// a restart could not see would leave the pair unheld. + /// that may be unresolved, durably, before anything is paid: flushed when + /// the backend does not commit a store inline. A backend that does not + /// keep the marker refuses the payment + /// ([`PlatformWalletError::PrivateContactRecoveryUnsupported`]), and a + /// failed store or flush refuses it too, with nothing paid: a payment a + /// restart could not see would leave the pair unheld. + /// + /// Clearing the marker once the payment resolves is not flushed: a clear + /// that is lost only holds the identity after a restart until a + /// restoration clears it, which fails closed. fn store_private_payment_marker_before_paying( &self, owner: &Identifier, marker: PrivateContactRequestsPosition, ) -> Result<(), PlatformWalletError> { if !self.persister.persists_private_contact_scan_state() { - return Ok(()); + return Err(PlatformWalletError::PrivateContactRecoveryUnsupported); } let mut changeset = ContactChangeSet::default(); changeset .private_contact_requests_positions .insert(*owner, marker); - self.persister.store(changeset.into()).map_err(|error| { + let not_kept = |error: PersistenceError| { PlatformWalletError::Persistence(format!( - "the private contact request's unresolved payment was not stored, so it is not \ - paid for: {error}" + "the private contact request's unresolved payment was not stored durably, so it \ + is not paid for: {error}" )) - }) + }; + self.persister.store(changeset.into()).map_err(not_kept)?; + if !self.persister.store_commits_inline() { + self.persister.flush().map_err(not_kept)?; + } + Ok(()) } /// Whether `identity_id` was loaded with a private contact request payment @@ -2114,9 +2134,21 @@ mod tests { const DECRYPTION_KEY_ID: KeyID = 2; #[derive(Default)] + /// A backend that keeps nothing, declaring that it keeps the private + /// contact request scan state and commits each store inline, so private + /// payments, which need their recovery marker kept, run over it. The tests + /// using it never reload what it kept. struct NoPersistence; impl PlatformWalletPersistence for NoPersistence { + fn persistence_capabilities(&self) -> PersistenceCapabilities { + PersistenceCapabilities::PRIVATE_CONTACT_SCAN_STATE_RESTART + } + + fn store_commits_inline(&self) -> bool { + true + } + fn store( &self, _wallet_id: WalletId, @@ -2590,7 +2622,7 @@ mod tests { wallet: wm.get_wallet(&wallet_id).expect("the wallet").clone(), core: info.core_wallet.clone(), identities: BTreeMap::from([(0, managed)]), - capabilities: PersistenceCapabilities::PRIVATE_CONTACT_REQUEST_SCAN_STATE, + capabilities: PersistenceCapabilities::PRIVATE_CONTACT_SCAN_STATE_RESTART, #[cfg(feature = "shielded")] viewing_key: None, stored: Mutex::default(), @@ -2725,7 +2757,7 @@ mod tests { let alice_signer = signer(ALICE_PHRASE, &alice); let attesting = || { RecordingPersistence::attesting( - PersistenceCapabilities::PRIVATE_CONTACT_REQUEST_SCAN_STATE, + PersistenceCapabilities::PRIVATE_CONTACT_SCAN_STATE_RESTART, ) }; let marker = |persistence: &RecordingPersistence| { @@ -2825,6 +2857,335 @@ mod tests { assert_eq!(marker(&persistence), Some(false), "unmarked once released"); } + /// A backend that buffers what is stored until a flush makes it durable, + /// declaring the private contact request scan state. Its flushes fail + /// once `flush_fails` is set. + #[derive(Default)] + struct BufferedPersistence { + buffered: Mutex>, + durable: Mutex>, + flush_fails: AtomicBool, + } + + impl PlatformWalletPersistence for BufferedPersistence { + fn persistence_capabilities(&self) -> PersistenceCapabilities { + PersistenceCapabilities::PRIVATE_CONTACT_SCAN_STATE_RESTART + } + + fn store( + &self, + _wallet_id: WalletId, + changeset: PlatformWalletChangeSet, + ) -> Result<(), PersistenceError> { + self.buffered.lock().expect("buffered").push(changeset); + Ok(()) + } + + fn flush(&self, _wallet_id: WalletId) -> Result<(), PersistenceError> { + if self.flush_fails.load(Ordering::SeqCst) { + return Err(PersistenceError::backend("the disk is full")); + } + let mut buffered = self.buffered.lock().expect("buffered"); + self.durable + .lock() + .expect("durable") + .extend(buffered.drain(..)); + Ok(()) + } + + fn load(&self) -> Result { + Ok(ClientStartState::default()) + } + } + + /// A backend that hands back an identity's position record marking an + /// unresolved private payment without attesting the scan state any more, + /// as one whose host stopped declaring it, has its scan state read again + /// from the start, and keeps the hold: the payment may still execute. + #[tokio::test] + async fn should_keep_a_restored_hold_for_a_backend_that_no_longer_attests_the_scan_state() { + let alice = identity(ALICE_PHRASE, ALICE); + let bob = identity(BOB_PHRASE, BOB); + let (_before_manager, before) = wallet(ALICE_PHRASE, &alice, &[&alice, &bob], vec![]).await; + let mut kept = scan_state_snapshot(&before, &alice.id(), true).await; + kept.capabilities = PersistenceCapabilities::NONE; + let manager = PlatformWalletManager::new( + Arc::new(dashpay_sdk(&[&alice, &bob], vec![], PlatformVersion::latest(), None).await), + Arc::new(kept), + Arc::new(NoopEventHandler), + ); + manager + .load_from_persistor() + .await + .expect("the relaunch loads"); + let restarted = manager + .get_wallet(&before.wallet_id()) + .await + .expect("the relaunched wallet"); + assert!(restarted.private_payments_held().await, "held"); + managed(&restarted, ALICE, |managed| { + assert_eq!( + managed.dashpay().private_contact_requests_position(), + 0, + "read again from the start" + ); + }) + .await; + } + + /// A private payment is refused before anything is sealed or paid over a + /// backend that does not keep the private contact request scan state, as + /// its recovery marker could not survive a restart; nothing stays + /// reserved. + #[tokio::test] + async fn should_refuse_a_private_payment_over_a_backend_that_keeps_no_marker() { + let alice = identity(ALICE_PHRASE, ALICE); + let bob = identity(BOB_PHRASE, BOB); + let alice_crypto = SeedCryptoProvider::from_seed(seed(ALICE_PHRASE), Network::Testnet); + let alice_signer = signer(ALICE_PHRASE, &alice); + let (_manager, alice_wallet) = wallet_on( + RecordingPersistence::attesting(PersistenceCapabilities::NONE), + ALICE_PHRASE, + &alice, + &[&alice, &bob], + vec![], + ) + .await; + let refused = alice_wallet + .identity() + .dashpay() + .send_private_contact_request( + &alice.id(), + &bob.id(), + None, + AutoAcceptProofSource::None, + None, + &alice_signer, + &alice_crypto, + no_payment, + ) + .await; + assert!( + matches!( + refused, + Err(PlatformWalletError::PrivateContactRecoveryUnsupported) + ), + "{refused:?}" + ); + assert!(!pending_to(&alice_wallet, ALICE, &bob).await); + } + + /// Over a backend that buffers its stores, a private payment's marker is + /// flushed before it is paid: a flush that fails refuses the payment, with + /// nothing paid, and one that succeeds leaves the marker durable before + /// `pay` runs, so a process reopening the wallet, in a build without the + /// `shielded` feature too, holds the identity. + #[tokio::test] + async fn should_make_a_private_payment_s_marker_durable_before_paying() { + let alice = identity(ALICE_PHRASE, ALICE); + let bob = identity(BOB_PHRASE, BOB); + let alice_crypto = SeedCryptoProvider::from_seed(seed(ALICE_PHRASE), Network::Testnet); + let alice_signer = signer(ALICE_PHRASE, &alice); + + let failing = Arc::new(BufferedPersistence::default()); + let (_manager, alice_wallet) = wallet_on( + Arc::clone(&failing), + ALICE_PHRASE, + &alice, + &[&alice, &bob], + vec![], + ) + .await; + failing.flush_fails.store(true, Ordering::SeqCst); + let refused = alice_wallet + .identity() + .dashpay() + .send_private_contact_request( + &alice.id(), + &bob.id(), + None, + AutoAcceptProofSource::None, + None, + &alice_signer, + &alice_crypto, + no_payment, + ) + .await; + assert!( + matches!(&refused, Err(PlatformWalletError::Persistence(message)) + if message.contains("not stored durably")), + "{refused:?}" + ); + assert!(!pending_to(&alice_wallet, ALICE, &bob).await); + + let buffered = Arc::new(BufferedPersistence::default()); + let (_manager, alice_wallet) = wallet_on( + Arc::clone(&buffered), + ALICE_PHRASE, + &alice, + &[&alice, &bob], + vec![], + ) + .await; + let durable_when_paid = Arc::new(AtomicBool::new(false)); + let unconfirmed = { + let (buffered, durable, alice_id) = ( + Arc::clone(&buffered), + Arc::clone(&durable_when_paid), + alice.id(), + ); + move |_: BinaryData, started: PaymentStarted| { + let marker = last_marker(&buffered.durable.lock().expect("durable"), &alice_id); + durable.store(marker == Some(true), Ordering::SeqCst); + started.mark(); + std::future::ready(Err::( + PlatformWalletError::ShieldedSpendUnconfirmed { + operation: "private document create", + reason: "the process ends here".to_string(), + }, + )) + } + }; + let sent = alice_wallet + .identity() + .dashpay() + .send_private_contact_request( + &alice.id(), + &bob.id(), + None, + AutoAcceptProofSource::None, + None, + &alice_signer, + &alice_crypto, + unconfirmed, + ) + .await; + assert!( + matches!( + sent, + Err(PlatformWalletError::ShieldedSpendUnconfirmed { .. }) + ), + "{sent:?}" + ); + assert!( + durable_when_paid.load(Ordering::SeqCst), + "durable before paying" + ); + + // The process ends with only what was flushed: reopened from it. + let unresolved = + last_marker(&buffered.durable.lock().expect("durable"), &alice.id()) == Some(true); + let kept = Arc::new(scan_state_snapshot(&alice_wallet, &alice.id(), unresolved).await); + let manager = PlatformWalletManager::new( + Arc::new(dashpay_sdk(&[&alice, &bob], vec![], PlatformVersion::latest(), None).await), + kept, + Arc::new(NoopEventHandler), + ); + manager + .load_from_persistor() + .await + .expect("the relaunch loads"); + let reopened = manager + .get_wallet(&alice_wallet.wallet_id()) + .await + .expect("the relaunched wallet"); + assert!(reopened.private_payments_held().await, "held"); + let public = reopened + .identity() + .dashpay() + .send_contact_request_with_external_signer( + &alice.id(), + &bob.id(), + None, + AutoAcceptProofSource::None, + &alice_signer, + &alice_crypto, + ) + .await; + assert!( + matches!( + public, + Err(PlatformWalletError::PrivateContactPaymentsNotRestored) + ), + "{public:?}" + ); + } + + /// A private payment whose broadcast may have been delivered, an + /// `Unavailable` answer after the node may have admitted it, keeps its + /// pair held and its marker set, though its notes are released: its entry + /// may still be appended. A public send to that recipient is refused. + #[tokio::test] + async fn should_keep_the_pair_held_when_a_payment_may_have_been_delivered() { + let alice = identity(ALICE_PHRASE, ALICE); + let bob = identity(BOB_PHRASE, BOB); + let alice_crypto = SeedCryptoProvider::from_seed(seed(ALICE_PHRASE), Network::Testnet); + let alice_signer = signer(ALICE_PHRASE, &alice); + let persistence = RecordingPersistence::attesting( + PersistenceCapabilities::PRIVATE_CONTACT_SCAN_STATE_RESTART, + ); + let (_manager, alice_wallet) = wallet_on( + Arc::clone(&persistence), + ALICE_PHRASE, + &alice, + &[&alice, &bob], + vec![], + ) + .await; + let view = alice_wallet.identity().dashpay(); + let delivered = |_: BinaryData, started: PaymentStarted| { + started.mark(); + std::future::ready(Err::( + PlatformWalletError::ShieldedBroadcastMaybeDelivered( + "status: Unavailable".to_string(), + ), + )) + }; + let sent = view + .send_private_contact_request( + &alice.id(), + &bob.id(), + None, + AutoAcceptProofSource::None, + None, + &alice_signer, + &alice_crypto, + delivered, + ) + .await; + assert!( + matches!( + sent, + Err(PlatformWalletError::ShieldedBroadcastMaybeDelivered(_)) + ), + "{sent:?}" + ); + assert!(pending_to(&alice_wallet, ALICE, &bob).await, "held"); + assert_eq!( + last_marker(&persistence.stored.lock().expect("stored"), &alice.id()), + Some(true), + "marked" + ); + let public = view + .send_contact_request_with_external_signer( + &alice.id(), + &bob.id(), + None, + AutoAcceptProofSource::None, + &alice_signer, + &alice_crypto, + ) + .await; + assert!( + matches!( + public, + Err(PlatformWalletError::PrivateContactRequestPending(recipient)) + if recipient == bob.id() + ), + "{public:?}" + ); + } + /// `identity` with each of its `ECDSA_SECP256K1` keys holding its point /// uncompressed, a 65-byte encoding DPP accepts and the sealer reads. fn with_uncompressed_keys(identity: &Identity) -> Identity { @@ -4541,7 +4902,7 @@ mod tests { let address = [0x55; 43]; let (entry, _) = alice_to_bob(address).await; let persistence = RecordingPersistence::attesting( - PersistenceCapabilities::PRIVATE_CONTACT_REQUEST_SCAN_STATE, + PersistenceCapabilities::PRIVATE_CONTACT_SCAN_STATE_RESTART, ); let (_manager, bob_wallet) = wallet_on( Arc::clone(&persistence), @@ -4597,7 +4958,7 @@ mod tests { let address = [0x55; 43]; let (entry, received) = alice_to_bob(address).await; let persistence = RecordingPersistence::attesting( - PersistenceCapabilities::PRIVATE_CONTACT_REQUEST_SCAN_STATE, + PersistenceCapabilities::PRIVATE_CONTACT_SCAN_STATE_RESTART, ); let (_manager, restored) = wallet_on(persistence, BOB_PHRASE, &bob, &[&alice, &bob], vec![entry]).await; @@ -4661,13 +5022,14 @@ mod tests { } /// Un-ignoring a sender rewinds the private read position, and a backend - /// attesting the scan state stores the rewind. + /// attesting the scan state, with atomic changesets, stores the rewind in + /// the changeset that stores the sender's removal, to commit together. #[tokio::test] async fn should_store_a_rewound_position_when_a_sender_is_unignored() { let alice = identity(ALICE_PHRASE, ALICE); let bob = identity(BOB_PHRASE, BOB); let persistence = RecordingPersistence::attesting( - PersistenceCapabilities::PRIVATE_CONTACT_REQUEST_SCAN_STATE, + PersistenceCapabilities::PRIVATE_CONTACT_SCAN_STATE_RESTART, ); let (_manager, bob_wallet) = wallet_on( Arc::clone(&persistence), @@ -4769,6 +5131,103 @@ mod tests { ); } + /// A backend declaring the scan state without atomic changesets, which an + /// un-ignore's removal and rewind need to commit together, is treated as + /// keeping none of it: it gets none of the writes, a load drops the + /// position it restored, and a private payment, whose marker it could not + /// keep, is refused. + #[tokio::test] + async fn should_keep_no_scan_state_for_a_backend_without_atomic_changesets() { + let alice = identity(ALICE_PHRASE, ALICE); + let bob = identity(BOB_PHRASE, BOB); + let bob_crypto = SeedCryptoProvider::from_seed(seed(BOB_PHRASE), Network::Testnet); + let bob_signer = signer(BOB_PHRASE, &bob); + let (entry, _) = alice_to_bob([0x55; 43]).await; + let persistence = RecordingPersistence::attesting( + PersistenceCapabilities::PRIVATE_CONTACT_REQUEST_SCAN_STATE, + ); + let (_manager, bob_wallet) = wallet_on( + Arc::clone(&persistence), + BOB_PHRASE, + &bob, + &[&alice, &bob], + vec![entry], + ) + .await; + let view = bob_wallet.identity().dashpay(); + view.sync_private_contact_requests(&bob_crypto, 4) + .await + .expect("Bob reads the store"); + view.ignore_contact_sender(&bob.id(), &alice.id()) + .await + .expect("ignored"); + view.unignore_contact_sender(&bob.id(), &alice.id()) + .await + .expect("un-ignored"); + assert!(persistence.contacts().iter().all(|changeset| { + changeset.private_contact_requests_positions.is_empty() + && changeset.private_shielded_addresses.is_empty() + })); + let refused = view + .send_private_contact_request( + &bob.id(), + &alice.id(), + None, + AutoAcceptProofSource::None, + None, + &bob_signer, + &bob_crypto, + no_payment, + ) + .await; + assert!( + matches!( + refused, + Err(PlatformWalletError::PrivateContactRecoveryUnsupported) + ), + "{refused:?}" + ); + + // The un-ignore rewound the cursor: read on again, still storing nothing. + view.sync_private_contact_requests(&bob_crypto, 4) + .await + .expect("Bob reads the store again"); + assert!(persistence + .contacts() + .iter() + .all(|changeset| changeset.private_contact_requests_positions.is_empty())); + let mut kept = scan_state_snapshot(&bob_wallet, &bob.id(), false).await; + kept.capabilities = PersistenceCapabilities::PRIVATE_CONTACT_REQUEST_SCAN_STATE; + assert_eq!( + kept.identities[&0] + .dashpay() + .private_contact_requests_position(), + 1, + "the backend hands a position back" + ); + let manager = PlatformWalletManager::new( + Arc::new(dashpay_sdk(&[&alice, &bob], vec![], PlatformVersion::latest(), None).await), + Arc::new(kept), + Arc::new(NoopEventHandler), + ); + manager + .load_from_persistor() + .await + .expect("the relaunch loads"); + let reloaded = manager + .get_wallet(&bob_wallet.wallet_id()) + .await + .expect("the relaunched wallet"); + managed(&reloaded, BOB, |managed| { + assert_eq!( + managed.dashpay().private_contact_requests_position(), + 0, + "dropped" + ); + }) + .await; + } + /// A hook a [`HookedCrypto`] runs before one of its calls. type Hook = Arc std::pin::Pin + Send>> + Send + Sync>; diff --git a/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/contact_requests.rs b/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/contact_requests.rs index e163fe4fb37..9a58e04b675 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/contact_requests.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/contact_requests.rs @@ -947,12 +947,17 @@ impl ManagedIdentity { /// Drop the private contact request scan state a load restored, for a /// backend that does not attest it persists it: the store is read from /// its start, and the addresses come back with the requests that carry - /// them. + /// them. A restored hold on an unresolved private payment + /// ([`DashPayState::private_payments_unrestored`]) is kept: a backend that + /// handed one back kept it when it attested the capability, and dropping + /// it would let the identity publish the pair while the payment may still + /// execute. Kept, it holds until a restoration clears it. + /// + /// [`DashPayState::private_payments_unrestored`]: super::DashPayState::private_payments_unrestored pub(crate) fn forget_private_contact_scan_state(&mut self) { self.dashpay.private_contact_requests_position = 0; self.dashpay.private_scan_keys = None; self.dashpay.private_shielded_addresses.clear(); - self.dashpay.private_payments_unrestored = false; } /// Record the shielded address `request`, a private request from diff --git a/packages/rs-platform-wallet/src/wallet/persister.rs b/packages/rs-platform-wallet/src/wallet/persister.rs index 0e20c4516d6..fd8100b3a93 100644 --- a/packages/rs-platform-wallet/src/wallet/persister.rs +++ b/packages/rs-platform-wallet/src/wallet/persister.rs @@ -242,7 +242,7 @@ impl WalletPersister { /// ([`PersistenceCapabilities::PRIVATE_CONTACT_REQUEST_SCAN_STATE`]). pub(crate) fn persists_private_contact_scan_state(&self) -> bool { self.persistence_capabilities() - .contains(PersistenceCapabilities::PRIVATE_CONTACT_REQUEST_SCAN_STATE) + .contains(PersistenceCapabilities::PRIVATE_CONTACT_SCAN_STATE_RESTART) } pub(crate) fn load(&self) -> Result { diff --git a/packages/rs-platform-wallet/src/wallet/shielded/operations.rs b/packages/rs-platform-wallet/src/wallet/shielded/operations.rs index 776faeb7d8b..031e4acdb42 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/operations.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/operations.rs @@ -31,7 +31,9 @@ use super::note_selection::{ use super::store::{ PendingRedrive, PrivateContactPayment, ShieldedNote, ShieldedStore, SubwalletId, }; -use crate::broadcast_outcome::{broadcast_definitely_failed, carries_consensus_rejection}; +use crate::broadcast_outcome::{ + broadcast_definitely_failed, broadcast_may_have_been_delivered, carries_consensus_rejection, +}; use crate::changeset::{PlatformWalletChangeSet, ShieldedChangeSet}; use crate::error::{preserve_signer_key_unavailable_or, PlatformWalletError}; use crate::wallet::identity::network::PaymentStarted; @@ -3870,9 +3872,18 @@ async fn broadcast_shielded_spend( ) -> Result<(StateTransitionProofResult, ResponseMetadata), PlatformWalletError> { match state_transition.broadcast(sdk, None).await { Ok(()) => {} + // The notes are released either way; whether the transition may + // still have been delivered is kept for what it pays for. Err(e) if broadcast_definitely_failed(&e) => { - return Err(crate::error::promote_address_nonce_error(&e) - .unwrap_or_else(|| PlatformWalletError::ShieldedBroadcastFailed(e.to_string()))); + return Err( + crate::error::promote_address_nonce_error(&e).unwrap_or_else(|| { + if broadcast_may_have_been_delivered(&e) { + PlatformWalletError::ShieldedBroadcastMaybeDelivered(e.to_string()) + } else { + PlatformWalletError::ShieldedBroadcastFailed(e.to_string()) + } + }), + ); } Err(e) => { warn!( @@ -5020,6 +5031,31 @@ mod classify_spend_wait_failure_tests { ))); } + /// Of the failures counted as definite, only `Unavailable` may still have + /// been delivered: a consensus verdict, a rejection code and an empty + /// address list prove the transition was not admitted. The notes are + /// released for all of them alike. + #[test] + fn should_tell_a_broadcast_that_may_have_been_delivered_from_a_refused_one() { + use dash_sdk::dapi_client::DapiClientError; + use dash_sdk::dapi_grpc::tonic::Code; + assert!(broadcast_may_have_been_delivered(&grpc_err( + Code::Unavailable + ))); + assert!(broadcast_may_have_been_delivered( + &dash_sdk::Error::NoAvailableAddressesToRetry(Box::new(grpc_err(Code::Unavailable))) + )); + for refused in [ + grpc_err(Code::InvalidArgument), + grpc_err(Code::ResourceExhausted), + consensus_metadata_rejection(), + dash_sdk::Error::DapiClientError(DapiClientError::NoAvailableAddresses), + ] { + assert!(broadcast_definitely_failed(&refused), "{refused:?}"); + assert!(!broadcast_may_have_been_delivered(&refused), "{refused:?}"); + } + } + /// No-response shapes of the `broadcast()` call itself are NOT /// definitive: the dapi-client retries across addresses, so the request /// may have been delivered to a node whose ACK was lost. The caller From 84c2b96abc5728ae42deeb7e8a76c252fefb30bd Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 9 Nov 2026 16:12:53 +0700 Subject: [PATCH 090/117] feat(platform-version): double a private document store's chunk target to 1 MiB A private store's chunk power is clamp(floor(log2(target / stored entry size)), 4, 11), committed into the store when it is created. At a 512 KiB target DashPay's privateContactRequest store (589-byte entries, 605 bytes stored) took chunk power 9: 512 entries, about 302 KiB a page, half the bytes of a 2048-note shielded chunk (2048 x 312 bytes, about 624 KiB). SYSTEM_LIMITS_V5.private_document_store_target_chunk_bytes is now 1 MiB: DashPay's pages hold 1024 entries (about 605 KiB), so reading its store takes half the requests, and 4096-byte entries take 128 to a chunk instead of 64. Protocol version 15 is unreleased, so the table is edited in place and no store exists under the old target. Drive's tests follow the new sizes: - the empty store, the layout and the chunk power tests expect power 10 for 601-byte stored entries and 7 for 4112-byte ones; - the paging fixture's entry count is a u16, so the paging test still covers two compacted chunks and a buffered tail at 128 to a chunk; - the changed-limits test compares 1 MiB with 2 MiB; - the fixed-fee test appends 134 entries per size instead of 70, so it still crosses a compaction at 4096-byte entries. Co-Authored-By: Claude Opus 5.5 --- .../query/private_document_entries/v0/mod.rs | 2 +- .../rs-drive/src/drive/document/layout.rs | 4 +- .../document/private_document_store/mod.rs | 80 ++++++++++--------- ...nt_create_from_shielded_pool_transition.rs | 10 +-- .../src/version/system_limits/mod.rs | 2 +- .../src/version/system_limits/v5.rs | 4 +- .../rs-platform-version/src/version/v15.rs | 2 +- 7 files changed, 55 insertions(+), 49 deletions(-) diff --git a/packages/rs-drive-abci/src/query/private_document_entries/v0/mod.rs b/packages/rs-drive-abci/src/query/private_document_entries/v0/mod.rs index 42ff7d83788..aab5a0133f8 100644 --- a/packages/rs-drive-abci/src/query/private_document_entries/v0/mod.rs +++ b/packages/rs-drive-abci/src/query/private_document_entries/v0/mod.rs @@ -162,7 +162,7 @@ mod tests { use std::sync::Arc; /// Platform at the latest protocol version with the contract declaring the private - /// `secret` type (512-byte entries, so 512 entries to a chunk of its store) and three + /// `secret` type (512-byte entries, so 1024 entries to a chunk of its store) and three /// entries appended to that store, entry `i` behind the header of block `10 + i` at time /// `500 * i` and filled with byte `i`. fn platform_with_private_entries() -> ( diff --git a/packages/rs-drive/src/drive/document/layout.rs b/packages/rs-drive/src/drive/document/layout.rs index 2621fe4fbe9..8660f7d388f 100644 --- a/packages/rs-drive/src/drive/document/layout.rs +++ b/packages/rs-drive/src/drive/document/layout.rs @@ -1536,7 +1536,7 @@ mod tests { assert_eq!(store.role, LayoutRole::PrimaryKey); assert_eq!( store.element.kind, - LayoutElementKind::Tree(TreeType::PrivateDocumentStore(9)) + LayoutElementKind::Tree(TreeType::PrivateDocumentStore(10)) ); assert!(store.children.is_empty()); assert_eq!( @@ -1544,7 +1544,7 @@ mod tests { vec![LayoutNote::PrivateDocumentStore { entry_size: 585, stored_entry_size: 601, - chunk_power: 9, + chunk_power: 10, }] ); diff --git a/packages/rs-drive/src/drive/document/private_document_store/mod.rs b/packages/rs-drive/src/drive/document/private_document_store/mod.rs index 5a9db3e9eab..38179be0bec 100644 --- a/packages/rs-drive/src/drive/document/private_document_store/mod.rs +++ b/packages/rs-drive/src/drive/document/private_document_store/mod.rs @@ -462,8 +462,8 @@ mod tests { fn assert_holds_an_empty_store_of_585_byte_entries(drive: &Drive) { let element = element_at_key_zero(drive, "secret"); assert!( - matches!(element, Element::PrivateDocumentStore(0, 601, 9, _)), - "expected an empty store of 16 + 585 byte entries at chunk power 9, got {element:?}" + matches!(element, Element::PrivateDocumentStore(0, 601, 10, _)), + "expected an empty store of 16 + 585 byte entries at chunk power 10, got {element:?}" ); } @@ -696,9 +696,10 @@ mod tests { } /// A drive holding the contract with the private `secret` type of 4096-byte entries, - /// whose store keeps 64 entries to a chunk, and `count` entries appended to it, entry `i` - /// behind the header of block `100 + i` at time `1_000 * i` and filled with byte `i`. - fn drive_with_private_entries(count: u8) -> (Drive, DataContract) { + /// whose store keeps 128 entries to a chunk, and `count` entries appended to it, entry `i` + /// behind the header of block `100 + i` at time `1_000 * i` and filled with byte `i` (its + /// low byte past 255: the header keeps the entries distinct). + fn drive_with_private_entries(count: u16) -> (Drive, DataContract) { let drive = setup_drive_with_initial_state_structure(None); let platform_version = PlatformVersion::latest(); let contract = contract_with_entry_size(1, true, 4096); @@ -727,7 +728,7 @@ mod tests { encode_stored_private_document_entry( 100 + u64::from(i), 1_000 * u64::from(i), - &[i; 4096], + &[entry_byte(i); 4096], ), None, &platform_version.drive.grove_version, @@ -738,13 +739,18 @@ mod tests { (drive, contract) } - fn expected_entries(positions: Range) -> Vec { + /// The byte entry `i` of [`drive_with_private_entries`] is filled with: its low byte. + fn entry_byte(i: u16) -> u8 { + i.to_le_bytes()[0] + } + + fn expected_entries(positions: Range) -> Vec { positions .map(|i| PrivateDocumentEntry { position: u64::from(i), block_height: 100 + u64::from(i), block_time_ms: 1_000 * u64::from(i), - data: vec![i; 4096], + data: vec![entry_byte(i); 4096], }) .collect() } @@ -782,34 +788,34 @@ mod tests { .expect("the root hash"); assert_eq!(proved_root_hash, root_hash, "proved from {start}"); assert_eq!(proved_page, page, "proved from {start}"); - // Both carry the chunk power the store committed: 64 entries to a chunk. - assert_eq!(proved_page.chunk_power, Some(6), "proved from {start}"); + // Both carry the chunk power the store committed: 128 entries to a chunk. + assert_eq!(proved_page.chunk_power, Some(7), "proved from {start}"); page } - /// Pages of a store of 140 entries, 64 to a chunk (two compacted chunks and 12 buffered + /// Pages of a store of 268 entries, 128 to a chunk (two compacted chunks and 12 buffered /// entries): each reads from its start to the end of its chunk, in a compacted chunk, in /// the second one, in the buffered tail, and past the end; each read and each proof agree, /// and a proof derives the drive's root hash. #[test] fn should_read_and_prove_pages_of_a_private_type_store() { - let (drive, contract) = drive_with_private_entries(140); + let (drive, contract) = drive_with_private_entries(268); let platform_version = PlatformVersion::latest(); for (start, expected) in [ - (0u64, expected_entries(0..64)), - (10, expected_entries(10..64)), - (63, expected_entries(63..64)), - (64, expected_entries(64..128)), - (100, expected_entries(100..128)), - (128, expected_entries(128..140)), - (133, expected_entries(133..140)), - (140, vec![]), - (200, vec![]), + (0u64, expected_entries(0..128)), + (10, expected_entries(10..128)), + (127, expected_entries(127..128)), + (128, expected_entries(128..256)), + (200, expected_entries(200..256)), + (256, expected_entries(256..268)), + (261, expected_entries(261..268)), + (268, vec![]), + (300, vec![]), ] { let page = read_and_prove(&drive, &contract, start, platform_version); assert_eq!(page.entries, expected, "read from {start}"); - assert_eq!(page.total_count, 140); + assert_eq!(page.total_count, 268); } } @@ -819,12 +825,12 @@ mod tests { fn should_read_and_prove_an_empty_store() { let (drive, contract) = drive_with_private_entries(0); let platform_version = PlatformVersion::latest(); - for start in [0, 5, 64] { + for start in [0, 5, 128] { let page = read_and_prove(&drive, &contract, start, platform_version); assert_eq!( page, PrivateDocumentEntries { - chunk_power: Some(6), + chunk_power: Some(7), ..Default::default() }, "from {start}" @@ -833,34 +839,34 @@ mod tests { } /// A store keeps the chunks its contract created it with. Under limits that would size a - /// new store of the same entries at 128 entries to a chunk, a store of 64 to a chunk still + /// new store of the same entries at 256 entries to a chunk, a store of 128 to a chunk still /// pages to its own chunk ends, and a proof made under either limits verifies under the /// other. #[test] fn should_page_a_store_by_its_committed_chunk_power_under_changed_limits() { - let (drive, contract) = drive_with_private_entries(70); + let (drive, contract) = drive_with_private_entries(140); let platform_version = PlatformVersion::latest(); let mut changed_limits = platform_version.clone(); changed_limits .system_limits - .private_document_store_target_chunk_bytes = Some(1024 * 1024); - // 16 + 4096 = 4112 bytes: 255 entries fit 1 MiB, 2^7 = 128 + .private_document_store_target_chunk_bytes = Some(2 * 1024 * 1024); + // 16 + 4096 = 4112 bytes: 510 entries fit 2 MiB, 2^8 = 256 assert_eq!( private_document_store_chunk_power(4112, &changed_limits).unwrap(), - 7 + 8 ); let secret = contract .document_type_for_name("secret") .expect("the secret type"); for (start, expected) in [ - (0u64, expected_entries(0..64)), - (32, expected_entries(32..64)), - (64, expected_entries(64..70)), + (0u64, expected_entries(0..128)), + (32, expected_entries(32..128)), + (128, expected_entries(128..140)), ] { let page = read_and_prove(&drive, &contract, start, &changed_limits); assert_eq!(page.entries, expected, "read from {start}"); - assert_eq!(page.total_count, 70); + assert_eq!(page.total_count, 140); for (prover, verifier) in [ (&changed_limits, platform_version), @@ -1009,15 +1015,15 @@ mod tests { #[test] fn should_keep_a_compacted_chunk_near_the_target() { let platform_version = PlatformVersion::latest(); - // 16 + 585 = 601 bytes: 872 entries fit, 2^9 = 512 is the largest power below + // 16 + 585 = 601 bytes: 1744 entries fit, 2^10 = 1024 is the largest power below assert_eq!( private_document_store_chunk_power(601, platform_version).unwrap(), - 9 + 10 ); - // 16 + 4096 = 4112 bytes: 127 entries fit, 2^6 = 64 + // 16 + 4096 = 4112 bytes: 255 entries fit, 2^7 = 128 assert_eq!( private_document_store_chunk_power(4112, platform_version).unwrap(), - 6 + 7 ); } diff --git a/packages/rs-drive/src/state_transition_action/action_convert_to_operations/shielded/private_document_create_from_shielded_pool_transition.rs b/packages/rs-drive/src/state_transition_action/action_convert_to_operations/shielded/private_document_create_from_shielded_pool_transition.rs index 1f85911d3d6..f4a04593825 100644 --- a/packages/rs-drive/src/state_transition_action/action_convert_to_operations/shielded/private_document_create_from_shielded_pool_transition.rs +++ b/packages/rs-drive/src/state_transition_action/action_convert_to_operations/shielded/private_document_create_from_shielded_pool_transition.rs @@ -274,8 +274,8 @@ mod tests { #[test] fn should_read_the_chunk_power_of_a_store_taking_the_entries_and_none_otherwise() { let (drive, _, chunk_power) = drive_with_private_type(4096); - // 4096 + 16 bytes to an entry keeps 64 of them near the 512 KiB target. - assert_eq!(chunk_power, 6); + // 4096 + 16 bytes to an entry keeps 128 of them near the 1 MiB target. + assert_eq!(chunk_power, 7); let platform_version = PlatformVersion::latest(); for (type_name, entry_size) in [("secret", 4095), ("missing", 4096)] { assert_eq!( @@ -297,7 +297,7 @@ mod tests { /// The fixed fee of a private document create, which the pool pays, covers what its /// operations meter, and its entries' storage share covers the entries' metered storage, so /// the pool-paid split (`storage = min(metered storage, fee)`) never zeroes the proposer's - /// share. Checked for an entry size at every chunk power a store can take (11 down to 6), + /// share. Checked for an entry size at every chunk power a store can take (11 down to 7), /// for one entry and for the most a transition carries, over appends that cross a chunk's /// compaction. Every batch is estimated before it is applied: the estimate succeeds, with the /// store described at its committed chunk power, and the entries' estimate covers what their @@ -321,12 +321,12 @@ mod tests { let transaction = drive.grove.start_transaction(); let entry_storage_allowance = (entry_size as u64 + private_document.entry_overhead_bytes) * per_byte; - // 70 appends: at entry size 4096 (chunk power 6, 64 entries to a chunk) they cross a + // 134 appends: at entry size 4096 (chunk power 7, 128 entries to a chunk) they cross a // chunk's compaction. GroveDB charges every append of a store the same, the // compacting one included, so the smaller sizes' appends meter what any of theirs // does. let mut appended = 0u64; - for entry_count in [1, max_entries, max_entries, 1, 1] { + for entry_count in [1, max_entries, max_entries, max_entries, max_entries, 1, 1] { nonce = nonce.wrapping_add(1); // Distinct entries, as basic structure requires. let entries: Vec> = (0..entry_count) diff --git a/packages/rs-platform-version/src/version/system_limits/mod.rs b/packages/rs-platform-version/src/version/system_limits/mod.rs index 154e507895b..31dd769f9cc 100644 --- a/packages/rs-platform-version/src/version/system_limits/mod.rs +++ b/packages/rs-platform-version/src/version/system_limits/mod.rs @@ -45,7 +45,7 @@ pub struct SystemLimits { /// this does not count. pub max_private_document_entry_size: Option, /// The bytes a compacted chunk of a private document store is kept near (protocol version - /// 15): 512 KiB from `SYSTEM_LIMITS_V5`, `None` in the earlier tables, which admit no private + /// 15): 1 MiB from `SYSTEM_LIMITS_V5`, `None` in the earlier tables, which admit no private /// document type. Read with the two chunk power bounds below by Drive's /// `insert_empty_private_document_store_operation` 0 when a contract creates a private /// type's store; the chunk power it picks is committed into the store, so a store keeps the diff --git a/packages/rs-platform-version/src/version/system_limits/v5.rs b/packages/rs-platform-version/src/version/system_limits/v5.rs index 8bade129770..aab0cca9966 100644 --- a/packages/rs-platform-version/src/version/system_limits/v5.rs +++ b/packages/rs-platform-version/src/version/system_limits/v5.rs @@ -11,14 +11,14 @@ use crate::version::system_limits::SystemLimits; /// * Private document types: an entry takes at most 4096 bytes /// (`max_private_document_entry_size`, `None` before). Registration refuses a private type /// declaring a larger `entrySize`. A private type's store keeps a compacted chunk near -/// 512 KiB (`private_document_store_target_chunk_bytes`) at a chunk power from 4 to 11 +/// 1 MiB (`private_document_store_target_chunk_bytes`) at a chunk power from 4 to 11 /// (`private_document_store_min_chunk_power`, `private_document_store_max_chunk_power`), all /// `None` before. A `PrivateDocumentCreateFromShieldedPool` appends at most 16 entries /// (`max_private_document_entries_per_transition`, `None` before). pub const SYSTEM_LIMITS_V5: SystemLimits = SystemLimits { max_compiled_contract_size: Some(1_048_576), // changed: contracts stored compiled take at most 1 MiB max_private_document_entry_size: Some(4096), // changed: a private document type's entries take at most 4096 bytes - private_document_store_target_chunk_bytes: Some(512 * 1024), // changed: a private type's store keeps a compacted chunk near 512 KiB + private_document_store_target_chunk_bytes: Some(1024 * 1024), // changed: a private type's store keeps a compacted chunk near 1 MiB private_document_store_min_chunk_power: Some(4), // changed: and its chunk power is at least 4 private_document_store_max_chunk_power: Some(11), // changed: and at most 11 max_private_document_entries_per_transition: Some(16), // changed: a PrivateDocumentCreateFromShieldedPool appends at most 16 entries diff --git a/packages/rs-platform-version/src/version/v15.rs b/packages/rs-platform-version/src/version/v15.rs index 1e9f324468b..313044811ef 100644 --- a/packages/rs-platform-version/src/version/v15.rs +++ b/packages/rs-platform-version/src/version/v15.rs @@ -297,7 +297,7 @@ pub const PROTOCOL_VERSION_15: ProtocolVersion = 15; /// of the primary-key tree (`insert_empty_private_document_store_operation` 0, /// `DRIVE_DOCUMENT_METHOD_VERSIONS_V5`, `None` before); each stored entry is the client's behind a 16-byte header. /// The store's chunk power is the largest keeping a compacted chunk near -/// `private_document_store_target_chunk_bytes` (512 KiB), bounded by +/// `private_document_store_target_chunk_bytes` (1 MiB), bounded by /// `private_document_store_min_chunk_power` and `private_document_store_max_chunk_power` /// (4 and 11), all in `SYSTEM_LIMITS_V5`; it is committed into the store. Document /// queries refuse a private type; `getPrivateDocumentEntries` reads and proves a page of From 27e4173d3b0bf852f4e86e32658c6bff63ac7064 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 9 Nov 2026 17:08:53 +0700 Subject: [PATCH 091/117] fix(platform-wallet): fail a private payment restore on a saved transition that does not read A saved private contact request payment whose association reads but whose signed transition does not decode, is not a private document create to DashPay's privateContactRequest store, carries no entry or carries an entry shorter than its commitment was skipped by the restore. The restore then released the wallet and identity holds with no reservation for that pair, so a public request to the same contact could be sent while the private payment might still land. The restore now reads each associated payment strictly and fails with ShieldedRecoveryCorrupted, leaving every hold in place. Unassociated legacy records keep the lenient read they had. Co-Authored-By: Claude Opus 5.5 --- .../network/private_contact_requests.rs | 207 +++++++++--------- .../src/wallet/platform_wallet.rs | 100 +++++++-- 2 files changed, 187 insertions(+), 120 deletions(-) diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/private_contact_requests.rs b/packages/rs-platform-wallet/src/wallet/identity/network/private_contact_requests.rs index a4104a87a28..fc9eebd3163 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/private_contact_requests.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/private_contact_requests.rs @@ -3278,121 +3278,122 @@ mod tests { ); } - /// A saved private payment whose transition reads back but whose - /// association with the request it pays for does not leaves the relaunched + /// A saved private payment that does not read back, its association with + /// the request it pays for or its signed transition, leaves the relaunched /// wallet held: the restoration from the shielded store fails rather than /// lose the pair it holds, and no public contact request goes to anyone. #[cfg(feature = "shielded")] #[tokio::test] - async fn should_stay_held_when_a_saved_payment_s_association_does_not_read() { - let alice = identity(ALICE_PHRASE, ALICE); - let bob = identity(BOB_PHRASE, BOB); - let bob_crypto = SeedCryptoProvider::from_seed(seed(BOB_PHRASE), Network::Testnet); - let bob_signer = signer(BOB_PHRASE, &bob); - let incoming = public_request(&alice, &bob, 3); - let dir = tempfile::tempdir().expect("temporary shielded store"); - let db_path = dir.path().join("tree.sqlite"); - - let store = FileBackedShieldedStore::open_path(&db_path, 100).expect("open the store"); - let before_coordinator = Arc::new(NetworkShieldedCoordinator::new( - Arc::new(dash_sdk::Sdk::new_mock()), - Network::Testnet, - db_path.clone(), - store, - )); - let (_before_manager, before) = wallet(BOB_PHRASE, &bob, &[&alice, &bob], vec![]).await; - add_incoming(&before, BOB, &incoming).await; - let kept = Arc::new(snapshot(&before, &bob.id(), BOB_PHRASE).await); - let subwallet = SubwalletId::new(before.wallet_id(), 0); - let pay = { - let store = Arc::clone(before_coordinator.store()); - let (sender, recipient) = (bob.id(), alice.id()); - move |entry: BinaryData, started: PaymentStarted| async move { - let transition = private_contact_request_payment(entry); - let payment = PrivateContactPayment { - sender: sender.to_buffer(), - recipient: recipient.to_buffer(), - account_reference: started - .account_reference() - .expect("the send names its request"), - }; - arm_redrive_before_broadcast( - &store, - subwallet, - Some([0x77; 32]), - [7; 32], - vec![[1; 32]], - &transition, - "private document create from shielded pool", - Some(payment), - Some(&started), - ) - .await?; - Err::(PlatformWalletError::ShieldedSpendUnconfirmed { - operation: "private document create", - reason: "the result wait timed out".to_string(), - }) - } - }; - let paid = before - .identity() - .dashpay() - .accept_private_contact_request(&incoming, None, &bob_signer, &bob_crypto, pay) - .await; - assert!( - matches!( - paid, - Err(PlatformWalletError::ShieldedSpendUnconfirmed { .. }) - ), - "{paid:?}" - ); - drop(before_coordinator); - rusqlite::Connection::open(&db_path) - .expect("the store's file") - .execute( - "UPDATE shielded_pending_spends SET private_contact_payment = x'01'", - [], - ) - .expect("the association is damaged"); + async fn should_stay_held_when_a_saved_payment_does_not_read() { + for damage in ["private_contact_payment = x'01'", "st_bytes = x'0102'"] { + let alice = identity(ALICE_PHRASE, ALICE); + let bob = identity(BOB_PHRASE, BOB); + let bob_crypto = SeedCryptoProvider::from_seed(seed(BOB_PHRASE), Network::Testnet); + let bob_signer = signer(BOB_PHRASE, &bob); + let incoming = public_request(&alice, &bob, 3); + let dir = tempfile::tempdir().expect("temporary shielded store"); + let db_path = dir.path().join("tree.sqlite"); - let restarted_manager = PlatformWalletManager::new( - Arc::new(dashpay_sdk(&[&alice, &bob], vec![], PlatformVersion::latest(), None).await), - kept, - Arc::new(NoopEventHandler), - ); - restarted_manager - .load_from_persistor() - .await - .expect("the relaunch loads"); - restarted_manager - .configure_shielded(&db_path) - .await - .expect("the shielded store opens"); - let restarted = restarted_manager - .get_wallet(&before.wallet_id()) - .await - .expect("the relaunched wallet"); - assert!(restarted.private_payments_held().await, "still held"); - for recipient in [alice.id(), Identifier::from([0xCC; 32])] { - let public = restarted + let store = FileBackedShieldedStore::open_path(&db_path, 100).expect("open the store"); + let before_coordinator = Arc::new(NetworkShieldedCoordinator::new( + Arc::new(dash_sdk::Sdk::new_mock()), + Network::Testnet, + db_path.clone(), + store, + )); + let (_before_manager, before) = wallet(BOB_PHRASE, &bob, &[&alice, &bob], vec![]).await; + add_incoming(&before, BOB, &incoming).await; + let kept = Arc::new(snapshot(&before, &bob.id(), BOB_PHRASE).await); + let subwallet = SubwalletId::new(before.wallet_id(), 0); + let pay = { + let store = Arc::clone(before_coordinator.store()); + let (sender, recipient) = (bob.id(), alice.id()); + move |entry: BinaryData, started: PaymentStarted| async move { + let transition = private_contact_request_payment(entry); + let payment = PrivateContactPayment { + sender: sender.to_buffer(), + recipient: recipient.to_buffer(), + account_reference: started + .account_reference() + .expect("the send names its request"), + }; + arm_redrive_before_broadcast( + &store, + subwallet, + Some([0x77; 32]), + [7; 32], + vec![[1; 32]], + &transition, + "private document create from shielded pool", + Some(payment), + Some(&started), + ) + .await?; + Err::(PlatformWalletError::ShieldedSpendUnconfirmed { + operation: "private document create", + reason: "the result wait timed out".to_string(), + }) + } + }; + let paid = before .identity() .dashpay() - .send_contact_request_with_external_signer( - &bob.id(), - &recipient, - None, - AutoAcceptProofSource::None, - &bob_signer, - &bob_crypto, - ) + .accept_private_contact_request(&incoming, None, &bob_signer, &bob_crypto, pay) .await; assert!( matches!( - public, - Err(PlatformWalletError::PrivateContactPaymentsNotRestored) + paid, + Err(PlatformWalletError::ShieldedSpendUnconfirmed { .. }) ), - "{recipient}: {public:?}" + "{paid:?}" ); + drop(before_coordinator); + rusqlite::Connection::open(&db_path) + .expect("the store's file") + .execute(&format!("UPDATE shielded_pending_spends SET {damage}"), []) + .expect("the saved payment is damaged"); + + let restarted_manager = PlatformWalletManager::new( + Arc::new( + dashpay_sdk(&[&alice, &bob], vec![], PlatformVersion::latest(), None).await, + ), + kept, + Arc::new(NoopEventHandler), + ); + restarted_manager + .load_from_persistor() + .await + .expect("the relaunch loads"); + restarted_manager + .configure_shielded(&db_path) + .await + .expect("the shielded store opens"); + let restarted = restarted_manager + .get_wallet(&before.wallet_id()) + .await + .expect("the relaunched wallet"); + assert!(restarted.private_payments_held().await, "still held"); + for recipient in [alice.id(), Identifier::from([0xCC; 32])] { + let public = restarted + .identity() + .dashpay() + .send_contact_request_with_external_signer( + &bob.id(), + &recipient, + None, + AutoAcceptProofSource::None, + &bob_signer, + &bob_crypto, + ) + .await; + assert!( + matches!( + public, + Err(PlatformWalletError::PrivateContactPaymentsNotRestored) + ), + "{recipient}: {public:?}" + ); + } } } diff --git a/packages/rs-platform-wallet/src/wallet/platform_wallet.rs b/packages/rs-platform-wallet/src/wallet/platform_wallet.rs index 5f3f29e45f0..612789d512f 100644 --- a/packages/rs-platform-wallet/src/wallet/platform_wallet.rs +++ b/packages/rs-platform-wallet/src/wallet/platform_wallet.rs @@ -1808,7 +1808,10 @@ impl PlatformWallet { /// without its request (before it was kept with it) is restored by /// [`Self::restore_pending_private_contact_requests`] or the payment's /// own check. An identity added to the wallet after the bind is not - /// covered until the next bind or that call. + /// covered until the next bind or that call. A saved payment that does + /// not read back, its transition or the entries it appends, fails the + /// restoration with [`PlatformWalletError::ShieldedRecoveryCorrupted`], + /// and every hold stays. /// /// Run also by [`PlatformWalletManager::configure_shielded`] for every /// wallet the manager holds, and by its load for the wallets it loads once @@ -1832,22 +1835,19 @@ impl PlatformWallet { .private_contact_redrives_for_wallet(self.wallet_id) .map_err(PlatformWalletError::from)? }; - let payments: Vec<(Identifier, Identifier, u32, [u8; 32])> = saved - .iter() - .flat_map(|(redrive, payment)| { - private_contact_request_redrive_entries([redrive], dashpay_id) - .into_iter() - .filter_map(move |entry| { - let commitment: [u8; 32] = entry.as_slice().get(..32)?.try_into().ok()?; - Some(( - Identifier::from(payment.sender), - Identifier::from(payment.recipient), - payment.account_reference, - commitment, - )) - }) - }) - .collect(); + // All or nothing: a saved payment that does not read back fails the + // restoration, and every hold stays. + let mut payments: Vec<(Identifier, Identifier, u32, [u8; 32])> = Vec::new(); + for (redrive, payment) in &saved { + for commitment in associated_payment_commitments(redrive, dashpay_id)? { + payments.push(( + Identifier::from(payment.sender), + Identifier::from(payment.recipient), + payment.account_reference, + commitment, + )); + } + } let restored = self .identity .dashpay() @@ -2815,6 +2815,72 @@ impl DerefMut for WalletStateWriteGuard<'_> { } } +/// The commitments of the entries a saved private contact request payment +/// appends, read from its signed transition. Refused with +/// [`PlatformWalletError::ShieldedRecoveryCorrupted`] when the transition does +/// not decode, is not a private document create (type 29) appending to +/// DashPay's `privateContactRequest` store (`dashpay_id` being DashPay's +/// contract id), carries no entry, or carries one shorter than its 32-byte +/// commitment: the payment's pair is restored from it, so a payment that does +/// not read back fails the restoration rather than release the holds. +#[cfg(feature = "shielded")] +fn associated_payment_commitments( + redrive: &PendingRedrive, + dashpay_id: Identifier, +) -> Result, PlatformWalletError> { + let corrupted = |reason: String| PlatformWalletError::ShieldedRecoveryCorrupted { + account_index: None, + reason, + }; + let transition = match StateTransition::deserialize_from_bytes_trusted(&redrive.st_bytes) { + Ok(StateTransition::PrivateDocumentCreateFromShieldedPool(transition)) => transition, + Ok(_) => { + return Err(corrupted( + "a saved private contact request payment's transition is not a private \ + document create" + .to_string(), + )) + } + Err(error) => { + return Err(corrupted(format!( + "a saved private contact request payment's transition does not decode: {error}" + ))) + } + }; + if transition.data_contract_id() != dashpay_id + || transition.document_type_name() != PRIVATE_CONTACT_REQUEST_DOCUMENT_TYPE_NAME + { + return Err(corrupted(format!( + "a saved private contact request payment appends to {} {}, not DashPay's \ + {PRIVATE_CONTACT_REQUEST_DOCUMENT_TYPE_NAME}", + transition.data_contract_id(), + transition.document_type_name() + ))); + } + if transition.entries().is_empty() { + return Err(corrupted( + "a saved private contact request payment carries no entry".to_string(), + )); + } + transition + .entries() + .iter() + .map(|entry| { + entry + .as_slice() + .get(..32) + .and_then(|commitment| <[u8; 32]>::try_from(commitment).ok()) + .ok_or_else(|| { + corrupted( + "a saved private contact request payment's entry is shorter than its \ + commitment" + .to_string(), + ) + }) + }) + .collect() +} + /// The entries of every private document create (type 29) among `redrives` /// that appends to DashPay's `privateContactRequest` store, `dashpay_id` /// being DashPay's contract id. A record whose transition does not decode From f2b9c7da7ee23e5eb18a15d39711062866989ce3 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 9 Nov 2026 17:09:01 +0700 Subject: [PATCH 092/117] fix(platform-wallet): keep a private contact payment reserved through a broadcast that may have been delivered A private contact request payment whose broadcast ended in an ambiguous transport error (ShieldedBroadcastMaybeDelivered, such as Unavailable) released its notes and dropped its saved redrive and PrivateContactPayment association. The transition may still execute, yet nothing held its pair after a restart, and the redrive pass had nothing to resolve. The rollback now treats that case like ShieldedSpendUnconfirmed when the spend pays for a private contact request: the notes stay reserved, the redrive and its association stay saved, and the call returns ShieldedSpendUnconfirmed. The redrive pass then settles it as landed or rejected. Every other shielded spend, private document payments with no association included, still releases its notes on that error as before. Co-Authored-By: Claude Opus 5.5 --- .../network/private_contact_requests.rs | 22 +- .../src/wallet/shielded/operations.rs | 234 ++++++++++++++++-- 2 files changed, 237 insertions(+), 19 deletions(-) diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/private_contact_requests.rs b/packages/rs-platform-wallet/src/wallet/identity/network/private_contact_requests.rs index fc9eebd3163..278aa196c5a 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/private_contact_requests.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/private_contact_requests.rs @@ -6492,7 +6492,9 @@ mod tests { /// host calls nor a public send then goes to the contact. Before the /// bind, the relaunched wallet, which bound its shielded account before, /// refuses both too, with nothing sent: the reservation is not restored - /// yet. + /// yet. Once the reply's entry is appended and read back, the hold + /// resolves. A payment whose broadcast may have been delivered is saved + /// and kept exactly so (`settle_private_document_payment`). #[cfg(feature = "shielded")] #[tokio::test] async fn should_not_reply_publicly_after_a_restart_with_a_private_reply_unconfirmed() { @@ -6519,10 +6521,13 @@ mod tests { // What the backend keeps of the wallet, before the reply is paid. let kept = Arc::new(snapshot(&before, &bob.id(), BOB_PHRASE).await); let subwallet = SubwalletId::new(before.wallet_id(), 0); + let paid_entries = Arc::new(Mutex::new(Vec::new())); let pay = { let store = Arc::clone(before_coordinator.store()); + let paid_entries = Arc::clone(&paid_entries); let (sender, recipient) = (bob.id(), alice.id()); move |entry: BinaryData, started: PaymentStarted| async move { + paid_entries.lock().expect("paid").push(entry.clone()); // As the shielded payment does: the record saved with the // request it pays for, marked, broadcast; its result unknown. let transition = private_contact_request_payment(entry); @@ -6670,6 +6675,21 @@ mod tests { ), "{public:?}" ); + + // Once the reply's entry is appended and read back, its reservation + // settles: the hold resolves. + let entry = paid_entries.lock().expect("paid").remove(0); + publish(&restarted, 0, vec![entry.to_vec()]).await; + restarted + .identity() + .dashpay() + .sync_private_contact_requests(&bob_crypto, 4) + .await + .expect("the store is read"); + assert!( + !pending_to(&restarted, BOB, &alice).await, + "settled once read back" + ); } /// A payment saved with its request is restored only while neither that diff --git a/packages/rs-platform-wallet/src/wallet/shielded/operations.rs b/packages/rs-platform-wallet/src/wallet/shielded/operations.rs index 031e4acdb42..f4355390c56 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/operations.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/operations.rs @@ -1630,6 +1630,7 @@ pub(in crate::wallet) async fn private_document_create_from_pool_with_metadata< "PrivateDocumentCreateFromShieldedPool" ); + let pays_for_private_contact_request = private_contact.is_some(); let mut pending_entry = None; let result = async { let (spends, anchor) = extract_spends_and_anchor(sdk, store, &selected_notes).await?; @@ -1714,8 +1715,49 @@ pub(in crate::wallet) async fn private_document_create_from_pool_with_metadata< } .await; + settle_private_document_payment( + store, + persister, + wallet_id, + id, + &pending_entry, + reservation, + &selected_notes, + pays_for_private_contact_request, + result.map(|(_proof, metadata)| metadata), + ) + .await +} + +/// Settle a private document payment's notes and saved record once its +/// broadcast and result wait returned `result`, as +/// [`private_document_create_from_pool_with_metadata`] does. +/// +/// A payment that executed marks its notes spent; one that may have executed +/// keeps them reserved and its saved record, which the redrive pass resolves; +/// one that did not execute releases them, and its record with them. A +/// payment for a private contact request (`pays_for_private_contact_request`) +/// whose broadcast may have been delivered +/// ([`PlatformWalletError::ShieldedBroadcastMaybeDelivered`]) is one that may +/// have executed: its record, with the request it pays for, is the only +/// evidence a restart restores the request's pair from, so it is kept until +/// the redrive pass proves the payment landed or never will, and the spend +/// reads as unconfirmed. A spend paying for nothing else still releases its +/// notes then, as the on-chain nullifier set keeps funds safe. +#[allow(clippy::too_many_arguments)] +async fn settle_private_document_payment( + store: &Arc>, + persister: Option<&WalletPersister>, + wallet_id: WalletId, + id: SubwalletId, + pending_entry: &Option, + mut reservation: NoteReservation, + selected_notes: &[ShieldedNote], + pays_for_private_contact_request: bool, + result: Result, +) -> Result { match result { - Ok((_proof, metadata)) => { + Ok(metadata) => { // It executed: the outcome owns the notes, which it marks spent. reservation.hand_over(); record_activity_status( @@ -1723,23 +1765,22 @@ pub(in crate::wallet) async fn private_document_create_from_pool_with_metadata< persister, wallet_id, id, - &pending_entry, + pending_entry, ShieldedActivityStatus::Confirmed, None, ) .await; - if let Err(e) = finalize_pending(store, persister, wallet_id, id, &selected_notes).await + if let Err(e) = finalize_pending(store, persister, wallet_id, id, selected_notes).await { warn!( - account, + account = id.account_index, error = %e, "PrivateDocumentCreateFromShieldedPool broadcast succeeded but local \ spent-state update failed; will heal on next sync" ); } info!( - account, - document_type = document_type_name, + account = id.account_index, "PrivateDocumentCreateFromShieldedPool broadcast succeeded" ); Ok(metadata) @@ -1749,6 +1790,17 @@ pub(in crate::wallet) async fn private_document_create_from_pool_with_metadata< reservation.hand_over(); Err(e) } + Err(PlatformWalletError::ShieldedBroadcastMaybeDelivered(reason)) + if pays_for_private_contact_request => + { + // It may have executed, and its saved record is the request's + // pair's only durable evidence: kept, as an unconfirmed spend's. + reservation.hand_over(); + Err(PlatformWalletError::ShieldedSpendUnconfirmed { + operation: "private document create from shielded pool", + reason, + }) + } Err(e) => { // It did not execute: the guard owns the notes until they are // released, so a drop on the way still releases them. @@ -1758,7 +1810,7 @@ pub(in crate::wallet) async fn private_document_create_from_pool_with_metadata< persister, wallet_id, id, - &pending_entry, + pending_entry, ShieldedActivityStatus::Failed, None, ) @@ -3872,18 +3924,8 @@ async fn broadcast_shielded_spend( ) -> Result<(StateTransitionProofResult, ResponseMetadata), PlatformWalletError> { match state_transition.broadcast(sdk, None).await { Ok(()) => {} - // The notes are released either way; whether the transition may - // still have been delivered is kept for what it pays for. Err(e) if broadcast_definitely_failed(&e) => { - return Err( - crate::error::promote_address_nonce_error(&e).unwrap_or_else(|| { - if broadcast_may_have_been_delivered(&e) { - PlatformWalletError::ShieldedBroadcastMaybeDelivered(e.to_string()) - } else { - PlatformWalletError::ShieldedBroadcastFailed(e.to_string()) - } - }), - ); + return Err(definite_broadcast_failure(&e)); } Err(e) => { warn!( @@ -3914,6 +3956,22 @@ async fn broadcast_shielded_spend( waited.map_err(|wait_err| classify_spend_wait_failure(operation, &wait_err)) } +/// The error a spend's `broadcast()` failure that [`broadcast_definitely_failed`] +/// accepts is reported as: a promoted address-nonce error, or a failed +/// broadcast, kept apart when it may still have been delivered +/// ([`broadcast_may_have_been_delivered`]) for what the spend pays for. Its +/// notes are released either way, unless it pays for a private contact +/// request ([`settle_private_document_payment`]). +fn definite_broadcast_failure(e: &dash_sdk::Error) -> PlatformWalletError { + crate::error::promote_address_nonce_error(e).unwrap_or_else(|| { + if broadcast_may_have_been_delivered(e) { + PlatformWalletError::ShieldedBroadcastMaybeDelivered(e.to_string()) + } else { + PlatformWalletError::ShieldedBroadcastFailed(e.to_string()) + } + }) +} + /// Classify a `wait_for_response` failure for an already-broadcast /// shielded spend (see [`broadcast_shielded_spend`]). /// @@ -4101,6 +4159,10 @@ mod redrive_tests { use super::*; use crate::wallet::identity::network::PaymentStarted; use crate::wallet::shielded::store::InMemoryShieldedStore; + use crate::wallet::shielded::FileBackedShieldedStore; + use dash_sdk::dapi_client::transport::TransportError; + use dash_sdk::dapi_client::DapiClientError; + use dash_sdk::dapi_grpc::tonic::{Code, Status}; use dash_sdk::dpp::dashcore::Network; use dash_sdk::dpp::prelude::CoreBlockHeight; use dash_sdk::error::ContextProviderError; @@ -4112,6 +4174,9 @@ mod redrive_tests { use dpp::consensus::ConsensusError; use dpp::data_contract::{DataContract, TokenConfiguration}; use dpp::serialization::PlatformSerializable; + use dpp::shielded::SerializedAction; + use dpp::state_transition::private_document_create_from_shielded_pool_transition::methods::PrivateDocumentCreateFromShieldedPoolTransitionMethodsV0; + use dpp::state_transition::private_document_create_from_shielded_pool_transition::PrivateDocumentCreateFromShieldedPoolTransition; use dpp::state_transition::shield_from_identity_transition::v0::ShieldFromIdentityTransitionV0; use dpp::version::PlatformVersion; use futures::{poll, FutureExt}; @@ -4434,6 +4499,139 @@ mod redrive_tests { ); } + /// A private contact request's payment whose broadcast may have been + /// delivered, an `Unavailable` answer the spend's classifier reports so, + /// is settled as an unconfirmed one: its notes stay reserved, and its saved + /// record with the request it pays for survives a reopen of the store, so + /// the redrive pass resolves it and a restart restores the request's pair + /// from it. A private document payment paying for nothing else releases its + /// notes, and its record with them, as before. + #[tokio::test] + async fn should_keep_a_private_contact_payment_s_record_when_its_broadcast_may_have_been_delivered( + ) { + let sdk = Arc::new(dash_sdk::Sdk::new_mock()); + let unavailable = dash_sdk::Error::DapiClientError(DapiClientError::Transport( + TransportError::Grpc(Status::new( + Code::Unavailable, + "the stream was reset after the request was sent", + )), + )); + let action = SerializedAction { + nullifier: [1; 32], + rk: [2; 32], + cmx: [3; 32], + encrypted_note: vec![4; 216], + cv_net: [5; 32], + spend_auth_sig: [6; 64], + }; + let transition = PrivateDocumentCreateFromShieldedPoolTransition::try_from_bundle( + Identifier::from([0x44; 32]), + "privateContactRequest".to_string(), + vec![BinaryData::new(vec![9; 589])], + vec![action.clone(), action], + 0, + [7; 32], + vec![8; 64], + [9; 64], + PlatformVersion::latest(), + ) + .expect("a type 29 transition"); + let payment = PrivateContactPayment { + sender: [0xB1; 32], + recipient: [0xA1; 32], + account_reference: 5, + }; + let wallet_id = [0x67; 32]; + let id = SubwalletId::new(wallet_id, 0); + for pays_for_private_contact_request in [true, false] { + let dir = tempfile::tempdir().expect("a directory"); + let path = dir.path().join("tree.sqlite"); + let store = Arc::new(RwLock::new( + FileBackedShieldedStore::open_path(&path, 100).expect("the store"), + )); + let note = ShieldedNote { + position: 0, + cmx: [1; 32], + nullifier: [0x5F; 32], + block_height: 1, + is_spent: false, + value: 1_000_000_000_000, + note_data: vec![], + }; + store.write().await.save_note(id, ¬e).expect("a note"); + let (selected, _, _, reservation) = + reserve_notes_covering(&sdk, &store, id, 1, 2, ShieldedFeeKind::Base) + .await + .expect("reserved"); + let started = PaymentStarted::default(); + arm_redrive_before_broadcast( + &store, + id, + Some([0x77; 32]), + [7; 32], + selected.iter().map(|note| note.nullifier).collect(), + &transition, + "private document create from shielded pool", + pays_for_private_contact_request.then_some(payment), + Some(&started), + ) + .await + .expect("saved before the broadcast"); + + let settled = settle_private_document_payment( + &store, + None, + wallet_id, + id, + &None, + reservation, + &selected, + pays_for_private_contact_request, + Err(definite_broadcast_failure(&unavailable)), + ) + .await; + let unspent = store + .read() + .await + .get_unspent_notes(id) + .expect("notes") + .len(); + if pays_for_private_contact_request { + assert!( + matches!( + settled, + Err(PlatformWalletError::ShieldedSpendUnconfirmed { .. }) + ), + "{settled:?}" + ); + assert_eq!(unspent, 0, "the notes stay reserved"); + drop(store); + let reopened = FileBackedShieldedStore::open_path(&path, 100).expect("reopened"); + let saved = reopened + .private_contact_redrives_for_wallet(wallet_id) + .expect("listed"); + assert_eq!(saved.len(), 1, "the record and its association stay"); + assert_eq!(saved[0].1, payment); + assert!(reopened.get_unspent_notes(id).expect("notes").is_empty()); + } else { + assert!( + matches!( + settled, + Err(PlatformWalletError::ShieldedBroadcastMaybeDelivered(_)) + ), + "{settled:?}" + ); + assert_eq!(unspent, 1, "the notes are released"); + assert!(store + .read() + .await + .pending_redrives(id) + .expect("records") + .is_empty()); + } + } + } + /// A spend that failed before anything was broadcast, here a private document payment /// whose anchor fetch the mock refuses, and is dropped while its rollback waits for the /// store lock another task holds, still releases its notes: the guard owns them until the From d6279b03c968360760295be57847eb06b0e514b7 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 9 Nov 2026 17:09:08 +0700 Subject: [PATCH 093/117] fix(platform-wallet-ffi): report an unsupported private recovery by its own code from a shielded send map_spend_result had no arm for PrivateContactRecoveryUnsupported, so the explicit private contact request send and accept returned the generic shielded failure (6) where the error's own conversion reports 73. The arm joins the ones that keep 72 and the other dedicated codes, and the tests check 72, 73 and the maybe-delivered split through map_spend_result next to the From conversion. Co-Authored-By: Claude Opus 5.5 --- packages/rs-platform-wallet-ffi/src/error.rs | 32 +++++++++++++++++++ .../src/shielded_send.rs | 18 ++++++++--- 2 files changed, 46 insertions(+), 4 deletions(-) diff --git a/packages/rs-platform-wallet-ffi/src/error.rs b/packages/rs-platform-wallet-ffi/src/error.rs index da5e788ca6a..6ca6abf3455 100644 --- a/packages/rs-platform-wallet-ffi/src/error.rs +++ b/packages/rs-platform-wallet-ffi/src/error.rs @@ -2869,6 +2869,15 @@ mod private_contact_request_error_tests { PlatformWalletFFIResultCode::ErrorPrivateContactPaymentsNotRestored ); assert_eq!(result.code as u32, 72); + #[cfg(feature = "shielded")] + assert_eq!( + map_spend_result( + Err(PlatformWalletError::PrivateContactPaymentsNotRestored), + "private contact request" + ) + .code, + PlatformWalletFFIResultCode::ErrorPrivateContactPaymentsNotRestored + ); } /// A private payment over a persister that cannot keep its recovery marker @@ -2890,5 +2899,28 @@ mod private_contact_request_error_tests { delivered.code, PlatformWalletFFIResultCode::ErrorShieldedBroadcastFailed ); + // The explicit private send and accept report them through the spend + // contract alike. + #[cfg(feature = "shielded")] + { + assert_eq!( + map_spend_result( + Err(PlatformWalletError::PrivateContactRecoveryUnsupported), + "private contact request" + ) + .code, + PlatformWalletFFIResultCode::ErrorPrivateContactRecoveryUnsupported + ); + assert_eq!( + map_spend_result( + Err(PlatformWalletError::ShieldedBroadcastMaybeDelivered( + "unavailable".to_string() + )), + "private contact request" + ) + .code, + PlatformWalletFFIResultCode::ErrorShieldedBroadcastFailed + ); + } } } diff --git a/packages/rs-platform-wallet-ffi/src/shielded_send.rs b/packages/rs-platform-wallet-ffi/src/shielded_send.rs index 5becdafb0fa..f037a84cd65 100644 --- a/packages/rs-platform-wallet-ffi/src/shielded_send.rs +++ b/packages/rs-platform-wallet-ffi/src/shielded_send.rs @@ -622,11 +622,21 @@ pub(crate) fn map_spend_result( | Err(e @ PlatformWalletError::ShieldedRecoveryKeysRequired { .. }) => e.into(), // A private contact request in flight, or paid and not yet read back // from the store: nothing new was paid, and a later private sync - // settles it, so it keeps its own pending code. A wallet removed - // before the payment started keeps the lifecycle refusal code - // (`NotFound`) every other export gives it. + // settles it, so it keeps its own pending code; unrestored private + // payments keep theirs, and so does a persister that cannot keep a + // private payment's recovery marker. A wallet removed before the + // payment started keeps the lifecycle refusal code (`NotFound`) every + // other export gives it. A protocol-version refusal from the store + // read before any payment keeps the blanket conversion's split: a + // lasting refusal is `ErrorProtocolVersionNotActive`, a transient one + // the catch-all. Err(e @ PlatformWalletError::PrivateContactRequestPending(..)) - | Err(e @ PlatformWalletError::WalletRemovedBeforeSend(..)) => e.into(), + | Err(e @ PlatformWalletError::PrivateContactPaymentsNotRestored) + | Err(e @ PlatformWalletError::PrivateContactRecoveryUnsupported) + | Err(e @ PlatformWalletError::WalletRemovedBeforeSend(..)) + | Err(e @ PlatformWalletError::Sdk(dash_sdk::Error::ProtocolVersionNotActive { .. })) => { + e.into() + } // Retryable: the wallet couldn't build the spend against any // Platform-recorded anchor yet (its commitment tree is mid-block after // an index-chunk sync). Nothing was broadcast and the notes were From acb98eb3526d36a6afd11d9ade2e2be092222999 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 9 Nov 2026 17:21:35 +0700 Subject: [PATCH 094/117] fix(platform-wallet): free a pair rejected for good on the next private refresh The shielded redrive and prune passes note the commitments of a private payment they settle as never executing, and the wallet released the reservations held for them only before another private or public request or payment. A private contact request whose payment was unconfirmed and then definitively rejected therefore stayed pending, and its identity marked unresolved, for the rest of the session: the private refresh the apps offer did not release it, and the readiness read reported it. sync_private_contact_requests and private_contact_readiness now consume those notices first, so a refresh or a readiness read frees the pair and clears its marker with no restart and no other request. A reservation is still released only on such a notice: an unconfirmed payment, or one whose broadcast may have been delivered, stays reserved until its redrive resolves it. Co-Authored-By: Claude Opus 5.5 --- .../network/private_contact_requests.rs | 134 +++++++++++++++++- 1 file changed, 130 insertions(+), 4 deletions(-) diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/private_contact_requests.rs b/packages/rs-platform-wallet/src/wallet/identity/network/private_contact_requests.rs index 278aa196c5a..2468dcf9c23 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/private_contact_requests.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/private_contact_requests.rs @@ -910,11 +910,15 @@ impl DashPayView<'_, B> { } /// What a contact request from `identity_id` waits for, as the wallet - /// decides it ([`PrivateContactReadiness`]). A pure read. + /// decides it ([`PrivateContactReadiness`]). A read, once the requests + /// whose payment was settled as never executing are released + /// ([`Self::release_rejected_private_requests`]): a pair rejected for good + /// reads as free with no restart and no other request. pub async fn private_contact_readiness( &self, identity_id: &Identifier, ) -> Result { + self.release_rejected_private_requests().await?; let wallet_held = self.persister.private_payments_unrestored(); let wm = self.wallet_manager.read().await; let dashpay = wm @@ -982,9 +986,11 @@ impl DashPayView<'_, B> { /// rejection or a pruned anchor left unappendable /// (`WalletPersister::note_rejected_private_entries`): run before a /// private request is reserved, before a public one claims its pair, - /// and before a private payment, so a retry needs no restart. A - /// reservation is released only on that word, never for a missing - /// unconfirmed spend: a request paid and not yet read back has none. + /// before a private payment, by the private sync and by the readiness + /// read, so neither a retry nor the pair's pending state needs a restart. + /// A reservation is released only on that word, never for a missing + /// unconfirmed spend: a request paid and not yet read back, or whose + /// broadcast may have been delivered, has none. pub(crate) async fn release_rejected_private_requests( &self, ) -> Result<(), PlatformWalletError> { @@ -1417,6 +1423,11 @@ impl DashPayView<'_, B> { /// seed derives valid but unrelated keys, which open nothing, so its pass /// would record as read pages holding requests it never saw, and nothing /// rewinds them once the provider is fixed. + /// + /// Releases first the pending requests whose payment the shielded + /// coordinator settled as never executing + /// ([`Self::release_rejected_private_requests`]): no entry of theirs is + /// ever read back, so a sync is what frees their pairs. pub async fn sync_private_contact_requests( &self, crypto: &C, @@ -1425,6 +1436,7 @@ impl DashPayView<'_, B> { where C: ContactCryptoProvider + Sync, { + self.release_rejected_private_requests().await?; self.reconcile_private_scan_keys().await?; let (mut identities, paths) = self.private_scan_identities().await?; let mut report = PrivateContactSyncReport::default(); @@ -5874,6 +5886,120 @@ mod tests { assert_eq!(retry.lock().expect("retry").len(), 1, "the retry pays"); } + /// A private request whose payment is ambiguous, unconfirmed or with a + /// broadcast that may have been delivered, stays pending through a private + /// refresh. Once the shielded coordinator settles that payment as never + /// executing (a definitive rejection of its saved transition), the next + /// refresh, or a readiness read alone, frees the pair and clears its + /// marker, with no restart and no other request. + #[cfg(feature = "shielded")] + #[tokio::test] + async fn should_free_a_pair_rejected_for_good_on_the_next_private_refresh() { + let alice = identity(ALICE_PHRASE, ALICE); + let bob = identity(BOB_PHRASE, BOB); + let alice_crypto = SeedCryptoProvider::from_seed(seed(ALICE_PHRASE), Network::Testnet); + let alice_signer = signer(ALICE_PHRASE, &alice); + let marker = |persistence: &RecordingPersistence| { + last_marker(&persistence.stored.lock().expect("stored"), &alice.id()) + }; + for refreshed in [true, false] { + let persistence = RecordingPersistence::attesting( + PersistenceCapabilities::PRIVATE_CONTACT_SCAN_STATE_RESTART, + ); + let (_manager, alice_wallet) = wallet_on( + Arc::clone(&persistence), + ALICE_PHRASE, + &alice, + &[&alice, &bob], + vec![], + ) + .await; + let view = alice_wallet.identity().dashpay(); + let paid = Arc::new(Mutex::new(Vec::new())); + // What the wallet's shielded payment returns for a private contact + // request whose broadcast may have been delivered. + let ambiguous = { + let paid = Arc::clone(&paid); + move |entry: BinaryData, started: PaymentStarted| { + started.mark(); + paid.lock().expect("paid").push(entry); + std::future::ready(Err::( + PlatformWalletError::ShieldedSpendUnconfirmed { + operation: "private document create from shielded pool", + reason: "the broadcast may have been delivered: unavailable" + .to_string(), + }, + )) + } + }; + let sent = view + .send_private_contact_request( + &alice.id(), + &bob.id(), + None, + AutoAcceptProofSource::None, + None, + &alice_signer, + &alice_crypto, + ambiguous, + ) + .await; + assert!( + matches!( + sent, + Err(PlatformWalletError::ShieldedSpendUnconfirmed { .. }) + ), + "{sent:?}" + ); + assert_eq!(marker(&persistence), Some(true)); + + // Ambiguous: a refresh keeps the pair reserved. + view.sync_private_contact_requests(&alice_crypto, 4) + .await + .expect("the private refresh"); + assert!(pending_to(&alice_wallet, ALICE, &bob).await, "ambiguous"); + assert!( + view.private_contact_readiness(&alice.id()) + .await + .expect("the readiness") + .unresolved_private_payment, + "ambiguous" + ); + + // The redrive pass's definitive rejection of the saved transition. + let entry = paid.lock().expect("paid")[0].clone(); + note_rejected_private_documents( + &private_contact_request_payment(entry), + Some(&alice_wallet.identity().persister), + ); + + if refreshed { + view.sync_private_contact_requests(&alice_crypto, 4) + .await + .expect("the private refresh"); + let snapshot = managed(&alice_wallet, ALICE, |managed| managed.clone()).await; + assert!( + !snapshot.dashpay().private_reply_pending(&bob.id()), + "a fresh snapshot after the refresh shows the pair free" + ); + } + assert_eq!( + view.private_contact_readiness(&alice.id()) + .await + .expect("the readiness"), + PrivateContactReadiness { + private_payments_restored: true, + private_store_read: true, + unresolved_private_payment: false, + }, + "refreshed first: {refreshed}" + ); + assert!(!pending_to(&alice_wallet, ALICE, &bob).await); + assert_eq!(marker(&persistence), Some(false), "unmarked once released"); + assert_eq!(paid.lock().expect("paid").len(), 1, "no other request"); + } + } + /// A restoration that opened an unconfirmed request's entry before the /// shielded coordinator settled its payment as never executing, and that /// resumes only after a send's reconciliation took the rejection, reserves From f7a1204b77f364b90fbd533a08be77c6b7849c57 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 9 Nov 2026 17:48:40 +0700 Subject: [PATCH 095/117] test(drive-abci): expect DashPay v3's private store at chunk power 10 after the 14 to 15 upgrade The protocol 14 to 15 upgrade test reads the chunk power of the privateContactRequest store the upgrade creates. Under the 1 MiB chunk target (#5809, 84c2b96abc) 589-byte entries take power 10, 1024 to a chunk, not 9. #5809 merged with its workspace tests skipped (the runner image step failed), so the expectation was left at 9 on v5.1-dev. Co-Authored-By: Claude Opus 5.5 --- .../v0/mod.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/rs-drive-abci/src/execution/platform_events/protocol_upgrade/perform_events_on_first_block_of_protocol_change/v0/mod.rs b/packages/rs-drive-abci/src/execution/platform_events/protocol_upgrade/perform_events_on_first_block_of_protocol_change/v0/mod.rs index d3b8ac9458b..5df4c916fbe 100644 --- a/packages/rs-drive-abci/src/execution/platform_events/protocol_upgrade/perform_events_on_first_block_of_protocol_change/v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/platform_events/protocol_upgrade/perform_events_on_first_block_of_protocol_change/v0/mod.rs @@ -2711,7 +2711,7 @@ mod tests { } // v3's new private type gets its store with the upgrade, as a contract update adding a - // private type creates one: 589-byte entries, 512 to a chunk. + // private type creates one: 589-byte entries, 1024 to a chunk under the 1 MiB target. assert_eq!( platform .drive @@ -2723,7 +2723,7 @@ mod tests { platform_version_15, ) .expect("expected to read the store"), - Some(9), + Some(10), "the upgrade creates the privateContactRequest store" ); } From 4f3bd84732c79ba3d3a273c36d751b564dc70aff Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 9 Nov 2026 19:48:32 +0700 Subject: [PATCH 096/117] fix(platform-wallet): hold private contact request pairs from a durable private payment journal The hold that keeps a private contact request's pair from a public request or a second payment depended on the payment's redrive record, which other code deletes (note release, decode-failure cleanup, account purge) or may lose (the recovery connection committed at synchronous=NORMAL). Any path that lost the record released the pair while the signed payment could still execute. The shielded store now keeps a private payment journal beside the redrive: one fixed-format record per paid private request (sender, recipient, account reference, entry commitment, the spend's nullifiers), saved with the redrive in one transaction on a recovery connection at synchronous=FULL before the payment is marked started or broadcast. No note release, redrive removal, undecodable transition or purge removes it. It is discharged only once the entry is read back and its record stored durably, or when the transition never left the wallet. - Restoration reads the journal, under one wallet lock with the release of the holds, fenced against wallet replacement, refusing a coordinator of another network. It fails closed, holds staying and the wallet's raised, on a record that does not read, an undecodable spend no record accounts for, a legacy payment without its request, or a marker the journal cannot account for because it is new (a deleted or replaced store). The journal is opened only once a restoration succeeds. A bind never waits for a busy store: it defers the restoration to a task, the wallet held meanwhile. - A private payment consults the journal (71 for a pair it holds) and the restoration barrier (72), replacing the lenient legacy decode. Pairs of senders the wallet does not manage yet stay held for public sends. - Every shielded spend that consumes notes broadcasts in single dispatches and keeps each one's outcome: a dispatch that may have been delivered makes the broadcast maybe delivered whatever later refusals say, so the spend waits for its result and reports unconfirmed, its notes and redrive kept. Only a transition that never left the wallet reports failed. A refused re-broadcast no longer resolves a redrive, and an undecodable redrive is kept. - The rejection notices that freed a pair on a refused re-broadcast are removed: the journal keeps such a pair held until its entry is read back. - ShieldedStore's association and journal methods are required, and a damaged type 29 recovery row is retained and skipped like other note spends instead of failing the store's open. Co-Authored-By: Claude Opus 5.5 --- .../src/broadcast_outcome.rs | 288 +++- .../identity/network/contact_requests.rs | 7 +- .../src/wallet/identity/network/mod.rs | 4 + .../network/private_contact_requests.rs | 1154 ++++++++--------- .../managed_identity/contact_requests.rs | 13 - .../state/managed_identity/dashpay.rs | 7 + .../src/wallet/persister.rs | 109 +- .../src/wallet/platform_wallet.rs | 404 ++++-- .../src/wallet/shielded/coordinator.rs | 64 +- .../src/wallet/shielded/file_store.rs | 603 ++++++++- .../src/wallet/shielded/mod.rs | 3 +- .../src/wallet/shielded/operations.rs | 561 ++++---- .../src/wallet/shielded/store.rs | 269 +++- 13 files changed, 2392 insertions(+), 1094 deletions(-) diff --git a/packages/rs-platform-wallet/src/broadcast_outcome.rs b/packages/rs-platform-wallet/src/broadcast_outcome.rs index 0bcce5f4c5d..5202b3d4cc0 100644 --- a/packages/rs-platform-wallet/src/broadcast_outcome.rs +++ b/packages/rs-platform-wallet/src/broadcast_outcome.rs @@ -2,9 +2,13 @@ //! //! Shared by every Platform money-moving path that must keep "definitely //! rejected, safe to retry" apart from "ambiguous, may have executed, do -//! not retry" (shielded spends, masternode credit withdrawals). The shapes -//! are SDK-transport facts, not operation-specific, so they live here -//! rather than behind the `shielded` feature. +//! not retry". Shielded spends that consume notes broadcast through +//! [`broadcast_tracking_delivery`], which keeps every dispatch's outcome so an +//! earlier dispatch that may have been delivered is never hidden by a later +//! refusal; shields into the pool and masternode credit withdrawals classify +//! the SDK's final error with [`broadcast_definitely_failed`]. The shapes are +//! SDK-transport facts, not operation-specific, so they live here rather than +//! behind the `shielded` feature. /// Whether an SDK error carries Platform's own consensus verdict on the /// transition. Two shapes qualify: @@ -56,13 +60,12 @@ pub(crate) fn carries_consensus_rejection(err: &dash_sdk::Error) -> bool { /// stream resets after the request bytes left can surface the same code, /// and the dapi-client's cross-address retry only retains the LAST /// transport error, so an earlier-attempt delivery can hide behind a -/// later attempt's `Unavailable`. Releasing the notes in that residual -/// window is still fund-safe — the authoritative no-reuse guarantee is -/// the on-chain nullifier set, so a re-selected note at worst wastes a -/// ~30 s proof on a nullifier-already-used rejection (see the -/// `finalize_pending` downgrade rationale in `unshield`); never fund -/// loss. The trade is deliberate: UX for the dominant offline case over -/// strict conservatism in a rare race. +/// later attempt's final error, whatever its code. That is why shielded +/// spends do not use this classification but [`broadcast_tracking_delivery`], +/// which reports such a spend unconfirmed: a spend reported failed while it +/// may still execute could be paid again with other notes. The callers left +/// here are shields into the pool, which move funds to the wallet's own +/// notes, and masternode credit withdrawals. /// /// Everything else leaves the outcome unknown and the caller must fall /// through to the result wait instead of failing: `AlreadyExists` proves @@ -106,36 +109,257 @@ pub(crate) fn broadcast_definitely_failed(e: &dash_sdk::Error) -> bool { } } -/// Whether a `broadcast()` failure [`broadcast_definitely_failed`] counts as -/// definite may still have delivered the transition: an `Unavailable` -/// status, which can follow delivery or hide an earlier attempt's, as noted -/// there. The spend's notes are released all the same, as the on-chain -/// nullifier set keeps funds safe, but what the transition does may still -/// happen: a private contact request's entry may still be appended, so its -/// pair stays held. A consensus verdict, a rejection code and an empty -/// address list prove the transition was not admitted. +/// How many single dispatches [`broadcast_tracking_delivery`] makes at most: +/// the SDK's default budget, one dispatch and five retries. #[cfg(feature = "shielded")] -pub(crate) fn broadcast_may_have_been_delivered(e: &dash_sdk::Error) -> bool { +const SPEND_BROADCAST_DISPATCHES: usize = 6; + +/// What the dispatches of one shielded spend's broadcast proved about its +/// delivery ([`broadcast_tracking_delivery`]). +#[cfg(feature = "shielded")] +#[derive(Debug)] +pub(crate) enum BroadcastDelivery { + /// A node admitted the transition, or already held it (`AlreadyExists`). + Admitted, + /// No dispatch proved the transition was left out of every mempool: a + /// dispatch failed in transport, or in any other way that allows its + /// request to have been delivered. It may execute, so the caller waits + /// for its result, and an unconfirmed outcome keeps the notes reserved. + MaybeDelivered(dash_sdk::Error), + /// The transition never left the wallet: the first dispatch to reach a + /// node was refused with Platform's consensus verdict, after no dispatch + /// that could have delivered it, or there was no address to send it to, + /// or the SDK refused it before sending. Nothing holds its bytes but the + /// wallet, so it can never execute. + NeverDelivered(dash_sdk::Error), +} + +/// Broadcast a shielded spend `state_transition` in single dispatches (no +/// SDK retry inside a dispatch) at most [`SPEND_BROADCAST_DISPATCHES`] +/// times, keeping what every dispatch proved: the SDK's own retry loop +/// returns only its last error, which cannot show that an earlier attempt +/// was delivered with its response lost. +/// +/// Any dispatch that failed in a way that allows delivery (every transport +/// error, whatever its gRPC code, a timeout, a cause-less broadcast +/// envelope, an unknown error) makes the whole broadcast +/// [`BroadcastDelivery::MaybeDelivered`]: a later consensus rejection does +/// not override it, as a lagging node can refuse a transition another one +/// admitted. Only a rejection or a refusal with nothing possibly delivered +/// before it is [`BroadcastDelivery::NeverDelivered`]. +#[cfg(feature = "shielded")] +pub(crate) async fn broadcast_tracking_delivery( + sdk: &dash_sdk::Sdk, + state_transition: &dpp::state_transition::StateTransition, +) -> BroadcastDelivery { + use dash_sdk::dapi_client::RequestSettings; + use dash_sdk::platform::transition::broadcast::BroadcastStateTransition; + use dash_sdk::platform::transition::put_settings::PutSettings; + + let single_dispatch = PutSettings { + request_settings: RequestSettings { + retries: Some(0), + ..RequestSettings::default() + }, + ..PutSettings::default() + }; + track_delivery(|| state_transition.broadcast(sdk, Some(single_dispatch))).await +} + +/// The classification [`broadcast_tracking_delivery`] makes, over the +/// outcomes `dispatch` returns, one call per single dispatch. +#[cfg(feature = "shielded")] +async fn track_delivery(mut dispatch: F) -> BroadcastDelivery +where + F: FnMut() -> Fut, + Fut: std::future::Future>, +{ + use dash_sdk::dapi_client::DapiClientError; + + let mut maybe_delivered: Option = None; + for _ in 0..SPEND_BROADCAST_DISPATCHES { + let error = match dispatch().await { + Ok(()) | Err(dash_sdk::Error::AlreadyExists(_)) => return BroadcastDelivery::Admitted, + Err(error) => error, + }; + let nothing_sent = matches!( + error, + dash_sdk::Error::DapiClientError(DapiClientError::NoAvailableAddresses) + | dash_sdk::Error::ProtocolVersionNotActive { .. } + ) || matches!( + &error, + dash_sdk::Error::NoAvailableAddressesToRetry(inner) + if matches!( + **inner, + dash_sdk::Error::DapiClientError(DapiClientError::NoAvailableAddresses) + ) + ); + if carries_consensus_rejection(&error) || nothing_sent { + // Final: a verdict, or nowhere left to send it. + return match maybe_delivered { + Some(_) => BroadcastDelivery::MaybeDelivered(error), + None => BroadcastDelivery::NeverDelivered(error), + }; + } + maybe_delivered = Some(error); + } + match maybe_delivered { + Some(error) => BroadcastDelivery::MaybeDelivered(error), + // Unreachable with a non-zero budget: every pass sets it or returns. + None => BroadcastDelivery::MaybeDelivered(dash_sdk::Error::Generic( + "no broadcast dispatch was made".to_string(), + )), + } +} + +#[cfg(all(test, feature = "shielded"))] +mod tests { + use super::*; use dash_sdk::dapi_client::transport::TransportError; use dash_sdk::dapi_client::DapiClientError; - use dash_sdk::dapi_grpc::tonic::Code; + use dash_sdk::dapi_grpc::tonic::{Code, Status}; + use std::sync::Mutex; - fn unavailable(t: &TransportError) -> bool { - let TransportError::Grpc(status) = t; - status.code() == Code::Unavailable + /// Another error of the same shape as `error`, which is not `Clone`. + fn copy(error: &dash_sdk::Error) -> dash_sdk::Error { + match error { + dash_sdk::Error::DapiClientError(DapiClientError::Transport(TransportError::Grpc( + status, + ))) => grpc(status.code()), + dash_sdk::Error::DapiClientError(DapiClientError::NoAvailableAddresses) => { + dash_sdk::Error::DapiClientError(DapiClientError::NoAvailableAddresses) + } + dash_sdk::Error::NoAvailableAddressesToRetry(inner) => { + dash_sdk::Error::NoAvailableAddressesToRetry(Box::new(copy(inner))) + } + dash_sdk::Error::Protocol(dpp::ProtocolError::ConsensusError(_)) => rejection(), + other => dash_sdk::Error::Generic(other.to_string()), + } } - if carries_consensus_rejection(e) { - return false; + fn grpc(code: Code) -> dash_sdk::Error { + dash_sdk::Error::DapiClientError(DapiClientError::Transport(TransportError::Grpc( + Status::new(code, "test"), + ))) } - match e { - dash_sdk::Error::DapiClientError(DapiClientError::Transport(t)) => unavailable(t), - dash_sdk::Error::DapiClientError(DapiClientError::NoAvailableAddressesToRetry(t)) => { - unavailable(t) + + /// A consensus rejection, as DAPI attaches one to a refused broadcast. + fn rejection() -> dash_sdk::Error { + use dpp::consensus::basic::BasicError; + use dpp::consensus::basic::UnsupportedFeatureError; + dash_sdk::Error::Protocol(dpp::ProtocolError::ConsensusError(Box::new( + dpp::consensus::ConsensusError::BasicError(BasicError::UnsupportedFeatureError( + UnsupportedFeatureError::new("test".to_string(), 1), + )), + ))) + } + + /// What [`track_delivery`] makes of dispatches whose outcome `outcome` + /// gives by dispatch number, from 0, and how many dispatches it made. + async fn tracked( + outcome: impl Fn(usize) -> Result<(), dash_sdk::Error>, + ) -> (BroadcastDelivery, usize) { + let dispatched = Mutex::new(0usize); + let delivery = track_delivery(|| { + let mut count = dispatched.lock().expect("count"); + let result = outcome(*count); + *count += 1; + std::future::ready(result) + }) + .await; + let dispatched = *dispatched.lock().expect("count"); + (delivery, dispatched) + } + + /// An attempt admitted with its response lost, then retries refused for + /// any reason, leaves the broadcast maybe delivered: the last error + /// alone cannot show the transition never left. + #[tokio::test] + async fn should_keep_an_earlier_ambiguous_dispatch_over_later_refusals() { + for lost in [Code::Unavailable, Code::DeadlineExceeded] { + for refusal in [ + grpc(Code::ResourceExhausted), + grpc(Code::Unimplemented), + grpc(Code::InvalidArgument), + rejection(), + ] { + let refusal = &refusal; + let (delivery, _) = tracked(|dispatch| match dispatch { + 0 => Err(grpc(lost)), + _ => Err(copy(refusal)), + }) + .await; + assert!( + matches!(delivery, BroadcastDelivery::MaybeDelivered(_)), + "{lost:?} then a refusal: {delivery:?}" + ); + } } - dash_sdk::Error::NoAvailableAddressesToRetry(inner) => { - broadcast_may_have_been_delivered(inner) + } + + /// Every transport-level failure may have delivered the transition, + /// whatever its code, so a broadcast that only ever fails so is maybe + /// delivered after the whole budget. + #[tokio::test] + async fn should_treat_every_transport_failure_as_maybe_delivered() { + for code in [ + Code::Unavailable, + Code::ResourceExhausted, + Code::Unimplemented, + Code::InvalidArgument, + Code::Internal, + ] { + let (delivery, dispatched) = tracked(|_| Err(grpc(code))).await; + assert!( + matches!(delivery, BroadcastDelivery::MaybeDelivered(_)), + "{code:?}: {delivery:?}" + ); + assert_eq!(dispatched, SPEND_BROADCAST_DISPATCHES, "{code:?}"); } - _ => false, + } + + /// Only a consensus verdict, or nowhere to send the transition, before + /// any dispatch that could have delivered it shows it never left. + #[tokio::test] + async fn should_count_a_broadcast_never_delivered_only_without_an_earlier_ambiguity() { + for first in [ + rejection(), + dash_sdk::Error::DapiClientError(DapiClientError::NoAvailableAddresses), + dash_sdk::Error::NoAvailableAddressesToRetry(Box::new( + dash_sdk::Error::DapiClientError(DapiClientError::NoAvailableAddresses), + )), + ] { + let first = &first; + let (delivery, dispatched) = tracked(|_| Err(copy(first))).await; + assert!( + matches!(delivery, BroadcastDelivery::NeverDelivered(_)), + "{delivery:?}" + ); + assert_eq!(dispatched, 1); + } + } + + /// A dispatch a node admitted, or already holds, is admitted, after any + /// number of failed ones. + #[tokio::test] + async fn should_count_an_admitted_or_known_dispatch_as_admitted() { + let (delivery, _) = tracked(|dispatch| match dispatch { + 0 => Err(grpc(Code::Unavailable)), + _ => Ok(()), + }) + .await; + assert!( + matches!(delivery, BroadcastDelivery::Admitted), + "{delivery:?}" + ); + let (delivery, _) = tracked(|dispatch| match dispatch { + 0 => Err(grpc(Code::DeadlineExceeded)), + _ => Err(dash_sdk::Error::AlreadyExists("in the mempool".to_string())), + }) + .await; + assert!( + matches!(delivery, BroadcastDelivery::Admitted), + "{delivery:?}" + ); } } diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs b/packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs index d73612013e2..ec10fa03d51 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs @@ -764,7 +764,6 @@ impl DashPayView<'_, B> { { self.read_private_store_before_deciding(sender_identity_id, recipient_identity_id, crypto) .await?; - self.release_rejected_private_requests().await?; let Some(claim) = self .claim_public_pair(sender_identity_id, recipient_identity_id, adopt_reply) .await? @@ -809,7 +808,11 @@ impl DashPayView<'_, B> { if adopt_reply && managed.dashpay().outgoing_request(recipient).is_some() { return Ok(None); } - if managed.dashpay().private_reply_pending(recipient) { + if managed.dashpay().private_reply_pending(recipient) + || self + .persister + .absent_sender_private_pair_held(sender, recipient) + { return Err(PlatformWalletError::PrivateContactRequestPending( *recipient, )); diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/mod.rs b/packages/rs-platform-wallet/src/wallet/identity/network/mod.rs index 2be4eddd609..babb0e9fee1 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/mod.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/mod.rs @@ -76,6 +76,10 @@ pub(crate) use payments::{record_incoming_dashpay_payments, sent_payment_status_ mod prepared_payment; pub use prepared_payment::PreparedDashPayPayment; mod private_contact_requests; +#[cfg(feature = "shielded")] +pub(crate) use private_contact_requests::{ + restore_journaled_private_payments, JournaledPrivatePayment, +}; pub use private_contact_requests::{ PaymentStarted, PrivateContactReadiness, PrivateContactSyncReport, PrivateDocumentKeyPaths, }; diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/private_contact_requests.rs b/packages/rs-platform-wallet/src/wallet/identity/network/private_contact_requests.rs index 2468dcf9c23..596f7b05f2a 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/private_contact_requests.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/private_contact_requests.rs @@ -238,9 +238,14 @@ impl ScannedIdentity { /// It also names the request it pays for ([`Self::account_reference`]), so /// the payment's record can be saved with it and a restart restores the /// reservation without opening the entry. +/// +/// A payment that marked it and then failed keeps the reservation too: its +/// transition may still execute. Only one whose transition provably never +/// left the wallet ([`Self::note_never_delivered`]) releases it. #[derive(Clone, Debug, Default)] pub struct PaymentStarted { marked: Arc, + never_delivered: Arc, account_reference: Option, } @@ -255,6 +260,20 @@ impl PaymentStarted { self.marked.load(Ordering::Acquire) } + /// The payment's transition provably never left the wallet, and the + /// shielded store's journal record of it is discharged: nothing can + /// execute it, so its reservation may be released. + #[cfg(feature = "shielded")] + pub(crate) fn note_never_delivered(&self) { + self.never_delivered.store(true, Ordering::Release); + } + + /// Whether the payment may have reached the network: it was marked + /// started, and its transition was not shown never to have left. + pub(crate) fn may_have_reached_network(&self) -> bool { + self.is_marked() && !self.never_delivered.load(Ordering::Acquire) + } + /// The account reference of the private request this payment is for, /// when the send handed it over. pub fn account_reference(&self) -> Option { @@ -265,6 +284,7 @@ impl PaymentStarted { fn for_request(&self, account_reference: u32) -> Self { Self { marked: Arc::clone(&self.marked), + never_delivered: Arc::clone(&self.never_delivered), account_reference: Some(account_reference), } } @@ -283,6 +303,10 @@ struct PrivateReplyReservation { recipient: Identifier, attempt: u64, payment_started: PaymentStarted, + /// Whether the payment may have reached the network when it returned: + /// marked by the payment itself and not shown never to have left + /// ([`PaymentStarted::may_have_reached_network`]). + payment_may_have_executed: bool, kept: bool, } @@ -373,6 +397,111 @@ fn release_reservation( } } +/// Note `read_back`, the commitments of private requests a private sync read +/// back and recorded, for their journal records to be discharged +/// (`WalletPersister::note_read_back_private_entries`), once what records +/// them is durable: at once for a backend that commits inline, after a flush +/// otherwise. A failed flush notes nothing, which keeps the records, and so +/// the pairs held, until a later read. +fn note_read_back_once_durable(persister: &WalletPersister, read_back: Vec<[u8; 32]>) { + if !persister.store_commits_inline() { + if let Err(error) = persister.flush() { + tracing::warn!( + %error, + "private requests read back are not durably recorded; their journal records \ + stay" + ); + return; + } + } + persister.note_read_back_private_entries(read_back); +} + +/// A private contact request payment the shielded store's journal keeps, as +/// a restoration reads it: (sender, recipient, account reference, the entry's +/// commitment). +#[cfg(feature = "shielded")] +pub(crate) type JournaledPrivatePayment = (Identifier, Identifier, u32, [u8; 32]); + +/// The body of [`DashPayView::restore_private_payments`], over the wallet's +/// state alone, so a restoration deferred to a task while the shielded store +/// is busy runs it too. +#[cfg(feature = "shielded")] +pub(crate) async fn restore_journaled_private_payments( + wallet_manager: &Arc>>, + persister: &WalletPersister, + wallet_id: WalletId, + payments: &[JournaledPrivatePayment], + journal_authoritative: bool, +) -> Result, PlatformWalletError> { + let mut wm = wallet_manager.write().await; + let info = wm + .get_wallet_info_mut(&wallet_id) + .ok_or_else(|| PlatformWalletError::WalletNotFound(hex::encode(wallet_id)))?; + let mut restored = Vec::new(); + let mut absent = BTreeSet::new(); + for &(sender, recipient, account_reference, commitment) in payments { + let Some(managed) = info.identity_manager.managed_identity_mut(&sender) else { + absent.insert((sender, recipient)); + continue; + }; + if managed + .dashpay() + .outgoing_request(&recipient) + .is_some_and(|tracked| records_or_supersedes(tracked, account_reference)) + { + continue; + } + managed.restore_private_reply(recipient, commitment); + if !restored.contains(&(sender, recipient)) { + restored.push((sender, recipient)); + } + } + persister.hold_absent_sender_private_pairs(absent); + // Under the same lock, after the reservations: a send that sees an + // identity's hold gone sees them. + let identity_ids: Vec = info + .identity_manager + .all_identities() + .iter() + .map(|identity| identity.id()) + .collect(); + let mut unaccounted = 0usize; + for identity_id in identity_ids { + let Some(managed) = info.identity_manager.managed_identity_mut(&identity_id) else { + continue; + }; + if !managed.dashpay().private_payments_unrestored() { + continue; + } + if !journal_authoritative && !managed.dashpay().private_replies_pending() { + unaccounted += 1; + continue; + } + if managed.note_private_payments_restored() + && persister.persists_private_contact_scan_state() + { + store_private_position( + persister, + identity_id, + managed.private_contact_requests_position_record(), + ); + } + } + if unaccounted > 0 { + return Err(PlatformWalletError::ShieldedRecoveryCorrupted { + account_index: None, + reason: format!( + "{unaccounted} of the wallet's identities were loaded marking a private contact \ + request payment that may be unresolved, and the shielded store's private \ + payment journal for the wallet is new, so it cannot show that payment \ + resolved: their contact requests stay held" + ), + }); + } + Ok(restored) +} + /// Store `owner`'s private read position record again, for a backend that /// keeps it, as whether a private payment of `owner` may be unresolved /// (`PrivateContactRequestsPosition::private_payments_unresolved`) may have @@ -651,9 +780,19 @@ impl DashPayView<'_, B> { if !self.persister.persists_private_contact_scan_state() { return Err(PlatformWalletError::PrivateContactRecoveryUnsupported); } + // A private payment from an earlier process may hold this pair, which + // only a restoration from the shielded store's journal shows: until + // one succeeds, no private request is paid for either. + if self.persister.private_payments_unrestored() + || self.private_payments_unrestored(sender_identity_id).await? + { + return Err(PlatformWalletError::PrivateContactPaymentsNotRestored); + } self.read_private_store_before_sending(sender_identity_id, recipient_identity_id, crypto) .await?; - self.release_rejected_private_requests().await?; + let absent_sender_held = self + .persister + .absent_sender_private_pair_held(sender_identity_id, recipient_identity_id); let reserved = self .with_managed(sender_identity_id, |managed| { if adopt_reply @@ -664,6 +803,9 @@ impl DashPayView<'_, B> { { return None; } + if absent_sender_held { + return Some(None); + } Some(managed.reserve_private_reply(*recipient_identity_id)) }) .await?; @@ -681,6 +823,7 @@ impl DashPayView<'_, B> { recipient: *recipient_identity_id, attempt, payment_started: PaymentStarted::default(), + payment_may_have_executed: false, kept: false, }; let sent = self @@ -697,14 +840,16 @@ impl DashPayView<'_, B> { ) .await; // A payment that may have executed keeps the request pending, its - // marker set: one whose broadcast may have been delivered too, though - // its notes were released, as its entry may still be appended. - let keep_pending = matches!( - &sent, - Err(PlatformWalletError::PrivateContactRequestPending(_) - | PlatformWalletError::ShieldedSpendUnconfirmed { .. } - | PlatformWalletError::ShieldedBroadcastMaybeDelivered(_)) - ); + // marker set: one that reached the network and then failed too, as + // its entry may still be appended, unless its transition provably + // never left the wallet. + let keep_pending = reservation.payment_may_have_executed + || matches!( + &sent, + Err(PlatformWalletError::PrivateContactRequestPending(_) + | PlatformWalletError::ShieldedSpendUnconfirmed { .. } + | PlatformWalletError::ShieldedBroadcastMaybeDelivered(_)) + ); if keep_pending { reservation.keep(); } else { @@ -910,15 +1055,11 @@ impl DashPayView<'_, B> { } /// What a contact request from `identity_id` waits for, as the wallet - /// decides it ([`PrivateContactReadiness`]). A read, once the requests - /// whose payment was settled as never executing are released - /// ([`Self::release_rejected_private_requests`]): a pair rejected for good - /// reads as free with no restart and no other request. + /// decides it ([`PrivateContactReadiness`]). A pure read. pub async fn private_contact_readiness( &self, identity_id: &Identifier, ) -> Result { - self.release_rejected_private_requests().await?; let wallet_held = self.persister.private_payments_unrestored(); let wm = self.wallet_manager.read().await; let dashpay = wm @@ -949,86 +1090,6 @@ impl DashPayView<'_, B> { }) } - /// The reservations of the wallet's private payments from an earlier - /// process are restored from the shielded store: release the hold a load - /// set on each identity, and store the record each one changed for a - /// backend that keeps it. - #[cfg(feature = "shielded")] - pub(crate) async fn note_private_payments_restored(&self) -> Result<(), PlatformWalletError> { - let mut wm = self.wallet_manager.write().await; - let info = wm - .get_wallet_info_mut(&self.wallet_id) - .ok_or_else(|| PlatformWalletError::WalletNotFound(hex::encode(self.wallet_id)))?; - let identity_ids: Vec = info - .identity_manager - .all_identities() - .iter() - .map(|identity| identity.id()) - .collect(); - for identity_id in identity_ids { - if let Some(managed) = info.identity_manager.managed_identity_mut(&identity_id) { - if managed.note_private_payments_restored() - && self.persister.persists_private_contact_scan_state() - { - store_private_position( - &self.persister, - identity_id, - managed.private_contact_requests_position_record(), - ); - } - } - } - Ok(()) - } - - /// Release the pending private requests whose payment the shielded - /// coordinator settled as never executing, for an entry a definitive - /// rejection or a pruned anchor left unappendable - /// (`WalletPersister::note_rejected_private_entries`): run before a - /// private request is reserved, before a public one claims its pair, - /// before a private payment, by the private sync and by the readiness - /// read, so neither a retry nor the pair's pending state needs a restart. - /// A reservation is released only on that word, never for a missing - /// unconfirmed spend: a request paid and not yet read back, or whose - /// broadcast may have been delivered, has none. - pub(crate) async fn release_rejected_private_requests( - &self, - ) -> Result<(), PlatformWalletError> { - if !self.persister.has_rejected_private_entries() { - return Ok(()); - } - // Taken only once the wallet is locked and found, and applied with - // nothing awaited in between: a call dropped while it waits for the - // lock leaves the notices for the next one. - let mut wm = self.wallet_manager.write().await; - let info = wm - .get_wallet_info_mut(&self.wallet_id) - .ok_or_else(|| PlatformWalletError::WalletNotFound(hex::encode(self.wallet_id)))?; - let rejected = self.persister.take_rejected_private_entries(); - let identity_ids: Vec = info - .identity_manager - .all_identities() - .iter() - .map(|identity| identity.id()) - .collect(); - for identity_id in identity_ids { - if let Some(managed) = info.identity_manager.managed_identity_mut(&identity_id) { - let unresolved = managed.dashpay().private_payments_unresolved(); - managed.release_rejected_private_replies(&rejected); - if self.persister.persists_private_contact_scan_state() - && managed.dashpay().private_payments_unresolved() != unresolved - { - store_private_position( - &self.persister, - identity_id, - managed.private_contact_requests_position_record(), - ); - } - } - } - Ok(()) - } - /// Whether a private sync read DashPay's private contact request store /// to its end for `identity_id` since load. async fn private_store_read( @@ -1220,6 +1281,8 @@ impl DashPayView<'_, B> { reservation.payment_started.for_request(account_reference), ) .await; + reservation.payment_may_have_executed = + reservation.payment_started.may_have_reached_network(); reservation.payment_started.mark(); paid?; @@ -1423,11 +1486,6 @@ impl DashPayView<'_, B> { /// seed derives valid but unrelated keys, which open nothing, so its pass /// would record as read pages holding requests it never saw, and nothing /// rewinds them once the provider is fixed. - /// - /// Releases first the pending requests whose payment the shielded - /// coordinator settled as never executing - /// ([`Self::release_rejected_private_requests`]): no entry of theirs is - /// ever read back, so a sync is what frees their pairs. pub async fn sync_private_contact_requests( &self, crypto: &C, @@ -1436,7 +1494,6 @@ impl DashPayView<'_, B> { where C: ContactCryptoProvider + Sync, { - self.release_rejected_private_requests().await?; self.reconcile_private_scan_keys().await?; let (mut identities, paths) = self.private_scan_identities().await?; let mut report = PrivateContactSyncReport::default(); @@ -1772,6 +1829,7 @@ impl DashPayView<'_, B> { // account reference (another copy of it), or is // newer. An entry whose record was not built, its // recipient unreadable, settles nothing. + let mut read_back = Vec::new(); for (commitment, record) in sent_commitments .iter() .filter(|(_, record)| record.sender_id == identity_id) @@ -1785,6 +1843,7 @@ impl DashPayView<'_, B> { }); if reconciled { managed.settle_private_reply(&record.recipient_id, commitment); + read_back.push(*commitment); } } let before = managed.dashpay().private_contact_requests_position(); @@ -1803,6 +1862,13 @@ impl DashPayView<'_, B> { managed.private_contact_requests_position_record(), ); } + // The shielded store's journal records of the entries + // read back are discharged once what records them is + // durable: a record lost with them would leave the + // pair unheld while no request of it is recorded. + if !read_back.is_empty() { + note_read_back_once_durable(&self.persister, read_back); + } } else if !identity.failed { identity.failed = true; report.unpersisted_identities.push(identity_id); @@ -1889,52 +1955,77 @@ impl DashPayView<'_, B> { .await } - /// Reserve again, after a restart, the recipients of the private - /// requests whose payment is unconfirmed, from what the payment's record - /// was saved with: (sender, recipient, account reference, the entry's - /// commitment). Nothing is opened, so no keys are needed. A payment whose - /// request the sender's tracked request to that recipient records or - /// supersedes is discharged and left alone, as - /// [`Self::restore_pending_private_contact_requests`] leaves it; a - /// sender that is not one of the wallet's identities is skipped. So is a - /// payment the shielded coordinator settled as never executing in this - /// session, though its record was read before that - /// (`WalletPersister::private_entry_rejected`, checked under the wallet - /// lock). Returns the (sender, recipient) pairs restored. - #[cfg(feature = "shielded")] + /// Restore, after a restart or a bind, the reservations of the private + /// contact request payments the shielded store's private payment journal + /// keeps for this wallet, `payments` being each record's (sender, + /// recipient, account reference, the entry's commitment), and release the + /// holds a load set on the wallet's identities, all under one wallet + /// lock: an identity added in between is either restored and released or + /// neither. Nothing is opened, so no keys are needed. + /// + /// A payment whose request the sender's tracked request to that recipient + /// records or supersedes is resolved and left alone, as + /// [`Self::restore_pending_private_contact_requests`] leaves it. One + /// whose sender the wallet does not manage holds its pair for the public + /// send of an identity added later, until the next restoration + /// (`WalletPersister::hold_absent_sender_private_pairs`). + /// + /// An identity loaded marking a private payment that may be unresolved + /// is released when the journal is authoritative (`journal_authoritative`: + /// open before this bind, so it keeps every payment the wallet made since) + /// or holds a restored reservation now. Otherwise the journal cannot + /// account for its payment, the store being new or replaced: it stays + /// held, and the restoration fails with + /// [`PlatformWalletError::ShieldedRecoveryCorrupted`]. Returns the + /// (sender, recipient) pairs restored. + #[cfg(all(test, feature = "shielded"))] pub(crate) async fn restore_private_payments( &self, - payments: &[(Identifier, Identifier, u32, [u8; 32])], + payments: &[JournaledPrivatePayment], + journal_authoritative: bool, ) -> Result, PlatformWalletError> { - if payments.is_empty() { - return Ok(Vec::new()); + restore_journaled_private_payments( + &self.wallet_manager, + &self.persister, + self.wallet_id, + payments, + journal_authoritative, + ) + .await + } + + /// Whether an earlier private payment from `sender` to `recipient` that + /// the shielded store's journal keeps, `journaled` being each one's + /// (account reference, entry commitment), may still execute, neither its + /// request nor a later one being recorded: then the recipient stays + /// reserved for that entry instead of the one about to be paid, which + /// must not be. + #[cfg(feature = "shielded")] + pub(crate) async fn hold_for_journaled_private_payment( + &self, + sender: &Identifier, + recipient: &Identifier, + journaled: &[(u32, [u8; 32])], + ) -> Result { + if journaled.is_empty() { + return Ok(false); } - let mut restored = Vec::new(); - let mut wm = self.wallet_manager.write().await; - let info = wm - .get_wallet_info_mut(&self.wallet_id) - .ok_or_else(|| PlatformWalletError::WalletNotFound(hex::encode(self.wallet_id)))?; - for &(sender, recipient, account_reference, commitment) in payments { - // Settled as never executing, though its record was read before. - if self.persister.private_entry_rejected(&commitment) { - continue; - } - let Some(managed) = info.identity_manager.managed_identity_mut(&sender) else { - continue; - }; - if managed - .dashpay() - .outgoing_request(&recipient) - .is_some_and(|tracked| records_or_supersedes(tracked, account_reference)) - { - continue; - } - managed.restore_private_reply(recipient, commitment); - if !restored.contains(&(sender, recipient)) { - restored.push((sender, recipient)); + self.with_managed(sender, |managed| { + let unresolved = journaled.iter().find(|(account_reference, _)| { + !managed + .dashpay() + .outgoing_request(recipient) + .is_some_and(|tracked| records_or_supersedes(tracked, *account_reference)) + }); + match unresolved { + Some((_, commitment)) => { + managed.retarget_private_reply(*recipient, *commitment); + true + } + None => false, } - } - Ok(restored) + }) + .await } /// Whether a request from `sender` to `recipient` among `entries`, paid @@ -1944,7 +2035,7 @@ impl DashPayView<'_, B> { /// the one about to be paid, which must not be. The other entries are /// restored as [`Self::restore_pending_private_contact_requests`] restores /// them. - #[cfg(any(feature = "shielded", test))] + #[cfg(test)] pub(crate) async fn hold_for_restored_private_contact_request( &self, entries: &[BinaryData], @@ -2065,10 +2156,6 @@ impl DashPayView<'_, B> { continue; } let commitment = *sent.entry.commitment(); - // Settled as never executing while its entry was opened. - if self.persister.private_entry_rejected(&commitment) { - continue; - } if paying == Some((sender, recipient)) { managed.retarget_private_reply(recipient, commitment); } else { @@ -2095,13 +2182,11 @@ mod tests { use crate::wallet::identity::network::SeedCryptoProvider; use crate::wallet::platform_wallet::{PlatformWallet, WalletId}; #[cfg(feature = "shielded")] - use crate::wallet::shielded::operations::{ - arm_redrive_before_broadcast, note_rejected_private_documents, - }; + use crate::wallet::shielded::operations::arm_redrive_before_broadcast; #[cfg(feature = "shielded")] use crate::wallet::shielded::{ FileBackedShieldedStore, NetworkShieldedCoordinator, OrchardKeySet, PrivateContactPayment, - SubwalletId, + ShieldedStore, SubwalletId, }; use crate::PlatformWalletManager; use dash_sdk::error::ContextProviderError; @@ -2128,6 +2213,8 @@ mod tests { use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; use std::sync::Mutex; use std::time::Duration; + #[cfg(feature = "shielded")] + use tokio_util::sync::CancellationToken; const ALICE_PHRASE: &str = "abandon abandon abandon abandon abandon abandon abandon abandon \ abandon abandon abandon about"; @@ -2648,113 +2735,178 @@ mod tests { /// or sent, as no reservation holds the pair the payment is for. A build /// with the `shielded` feature releases it once the shielded store is /// configured, which restores the reservations and stores the record - /// unmarked; no private sync does, and a build without it keeps it. + /// unmarked, when the store's journal was open before, so it accounts for + /// the payment: one opened only now (a deleted or replaced store) keeps + /// the identity held. No private sync releases it, and a build without + /// the `shielded` feature keeps it. #[tokio::test] async fn should_hold_an_identity_reloaded_marking_an_unresolved_private_payment() { - let alice = identity(ALICE_PHRASE, ALICE); - let bob = identity(BOB_PHRASE, BOB); - let alice_crypto = SeedCryptoProvider::from_seed(seed(ALICE_PHRASE), Network::Testnet); - let alice_signer = signer(ALICE_PHRASE, &alice); - let incoming = public_request(&bob, &alice, 3); - let (_before_manager, before) = wallet(ALICE_PHRASE, &alice, &[&alice, &bob], vec![]).await; - add_incoming(&before, ALICE, &incoming).await; - let kept = Arc::new(scan_state_snapshot(&before, &alice.id(), true).await); - - let manager = PlatformWalletManager::new( - Arc::new(dashpay_sdk(&[&alice, &bob], vec![], PlatformVersion::latest(), None).await), - Arc::clone(&kept), - Arc::new(NoopEventHandler), - ); - manager - .load_from_persistor() - .await - .expect("the relaunch loads"); - let restarted = manager - .get_wallet(&before.wallet_id()) - .await - .expect("the relaunched wallet"); - assert!(restarted.private_payments_held().await); - - let public = restarted - .identity() - .dashpay() - .send_contact_request_with_external_signer( - &alice.id(), - &bob.id(), - None, - AutoAcceptProofSource::None, - &alice_signer, - &alice_crypto, - ) - .await; - assert!( - matches!( - public, - Err(PlatformWalletError::PrivateContactPaymentsNotRestored) - ), - "{public:?}" - ); - let accepted = restarted - .identity() - .dashpay() - .accept_contact_request_replying_in_kind( - &incoming, - None, - &alice_signer, - &alice_crypto, - no_payment, - ) - .await; - assert!( - matches!( - accepted, - Err(PlatformWalletError::PrivateContactPaymentsNotRestored) - ), - "{accepted:?}" - ); - - // A private sync reads the store, and restores nothing. - let view = restarted.identity().dashpay(); - view.sync_private_contact_requests(&alice_crypto, 4) - .await - .expect("the private sync"); - assert_eq!( - view.private_contact_readiness(&alice.id()) + for journal_open_before in [false, true] { + let alice = identity(ALICE_PHRASE, ALICE); + let bob = identity(BOB_PHRASE, BOB); + let alice_crypto = SeedCryptoProvider::from_seed(seed(ALICE_PHRASE), Network::Testnet); + let alice_signer = signer(ALICE_PHRASE, &alice); + let incoming = public_request(&bob, &alice, 3); + let (_before_manager, before) = + wallet(ALICE_PHRASE, &alice, &[&alice, &bob], vec![]).await; + add_incoming(&before, ALICE, &incoming).await; + let kept = Arc::new(scan_state_snapshot(&before, &alice.id(), true).await); + + let manager = PlatformWalletManager::new( + Arc::new( + dashpay_sdk(&[&alice, &bob], vec![], PlatformVersion::latest(), None).await, + ), + Arc::clone(&kept), + Arc::new(NoopEventHandler), + ); + manager + .load_from_persistor() .await - .expect("the readiness"), - PrivateContactReadiness { - private_payments_restored: false, - private_store_read: true, - unresolved_private_payment: true, - } - ); - assert!(restarted.private_payments_held().await, "still held"); + .expect("the relaunch loads"); + let restarted = manager + .get_wallet(&before.wallet_id()) + .await + .expect("the relaunched wallet"); + assert!(restarted.private_payments_held().await); - #[cfg(feature = "shielded")] - { - let dir = tempfile::tempdir().expect("temporary shielded store"); - manager - .configure_shielded(dir.path().join("tree.sqlite")) + let public = restarted + .identity() + .dashpay() + .send_contact_request_with_external_signer( + &alice.id(), + &bob.id(), + None, + AutoAcceptProofSource::None, + &alice_signer, + &alice_crypto, + ) + .await; + assert!( + matches!( + public, + Err(PlatformWalletError::PrivateContactPaymentsNotRestored) + ), + "{public:?}" + ); + let accepted = restarted + .identity() + .dashpay() + .accept_contact_request_replying_in_kind( + &incoming, + None, + &alice_signer, + &alice_crypto, + no_payment, + ) + .await; + assert!( + matches!( + accepted, + Err(PlatformWalletError::PrivateContactPaymentsNotRestored) + ), + "{accepted:?}" + ); + + // No private request is paid for either. + let private = restarted + .identity() + .dashpay() + .send_private_contact_request( + &alice.id(), + &bob.id(), + None, + AutoAcceptProofSource::None, + None, + &alice_signer, + &alice_crypto, + no_payment, + ) + .await; + assert!( + matches!( + private, + Err(PlatformWalletError::PrivateContactPaymentsNotRestored) + ), + "{private:?}" + ); + // A private sync reads the store, and restores nothing. + let view = restarted.identity().dashpay(); + view.sync_private_contact_requests(&alice_crypto, 4) .await - .expect("the shielded store is configured"); - assert!(!restarted.private_payments_held().await, "restored"); - assert_eq!(kept.last_marker(&alice.id()), Some(false)); + .expect("the private sync"); assert_eq!( view.private_contact_readiness(&alice.id()) .await .expect("the readiness"), PrivateContactReadiness { - private_payments_restored: true, + private_payments_restored: false, private_store_read: true, - unresolved_private_payment: false, + unresolved_private_payment: true, + } + ); + assert!(restarted.private_payments_held().await, "still held"); + + #[cfg(feature = "shielded")] + { + let dir = tempfile::tempdir().expect("temporary shielded store"); + let path = dir.path().join("tree.sqlite"); + if journal_open_before { + // An earlier bind's restoration opened the wallet's journal. + let mut store = + FileBackedShieldedStore::open_path(&path, 100).expect("the store"); + store + .open_private_payment_journal(before.wallet_id()) + .expect("opened"); + } + manager + .configure_shielded(path) + .await + .expect("the shielded store is configured"); + if journal_open_before { + assert!(!restarted.private_payments_held().await, "restored"); + assert_eq!(kept.last_marker(&alice.id()), Some(false)); + assert_eq!( + view.private_contact_readiness(&alice.id()) + .await + .expect("the readiness"), + PrivateContactReadiness { + private_payments_restored: true, + private_store_read: true, + unresolved_private_payment: false, + } + ); + } else { + // A journal opened only now, as in a deleted or replaced + // store, cannot show the marked payment resolved. + assert!(restarted.private_payments_held().await, "still held"); + assert_ne!(kept.last_marker(&alice.id()), Some(false), "never cleared"); + let public = restarted + .identity() + .dashpay() + .send_contact_request_with_external_signer( + &alice.id(), + &bob.id(), + None, + AutoAcceptProofSource::None, + &alice_signer, + &alice_crypto, + ) + .await; + assert!( + matches!( + public, + Err(PlatformWalletError::PrivateContactPaymentsNotRestored) + ), + "{public:?}" + ); } + } + #[cfg(not(feature = "shielded"))] + assert!( + restarted.private_payments_held().await, + "no shielded store restores it here, journal open before: {journal_open_before}" ); } - #[cfg(not(feature = "shielded"))] - assert!( - restarted.private_payments_held().await, - "no shielded store restores it here" - ); } /// A private payment is marked unresolved in its identity's position @@ -3290,14 +3442,21 @@ mod tests { ); } - /// A saved private payment that does not read back, its association with - /// the request it pays for or its signed transition, leaves the relaunched - /// wallet held: the restoration from the shielded store fails rather than - /// lose the pair it holds, and no public contact request goes to anyone. + /// A saved private payment whose redrive record does not read back, its + /// association with the request it pays for or its signed transition, or + /// was lost, still holds its pair after a relaunch: the shielded store's + /// private payment journal restores it. Through a coordinator sync pass, a second + /// restoration and another relaunch, a private and a public request to + /// that recipient are refused, while one to anyone else is not held. #[cfg(feature = "shielded")] #[tokio::test] - async fn should_stay_held_when_a_saved_payment_does_not_read() { - for damage in ["private_contact_payment = x'01'", "st_bytes = x'0102'"] { + async fn should_hold_a_pair_from_its_journal_when_its_saved_payment_does_not_read() { + for damage in [ + "UPDATE shielded_pending_spends SET private_contact_payment = x'01'", + "UPDATE shielded_pending_spends SET st_bytes = x'0102'", + // The redrive record lost after the broadcast. + "DELETE FROM shielded_pending_spends", + ] { let alice = identity(ALICE_PHRASE, ALICE); let bob = identity(BOB_PHRASE, BOB); let bob_crypto = SeedCryptoProvider::from_seed(seed(BOB_PHRASE), Network::Testnet); @@ -3315,7 +3474,13 @@ mod tests { )); let (_before_manager, before) = wallet(BOB_PHRASE, &bob, &[&alice, &bob], vec![]).await; add_incoming(&before, BOB, &incoming).await; - let kept = Arc::new(snapshot(&before, &bob.id(), BOB_PHRASE).await); + // A backend keeping the viewing key and the private scan state, so a + // private send reaches the pair's hold. + let mut kept = snapshot(&before, &bob.id(), BOB_PHRASE).await; + kept.capabilities = kept + .capabilities + .union(PersistenceCapabilities::PRIVATE_CONTACT_SCAN_STATE_RESTART); + let kept = Arc::new(kept); let subwallet = SubwalletId::new(before.wallet_id(), 0); let pay = { let store = Arc::clone(before_coordinator.store()); @@ -3362,49 +3527,101 @@ mod tests { drop(before_coordinator); rusqlite::Connection::open(&db_path) .expect("the store's file") - .execute(&format!("UPDATE shielded_pending_spends SET {damage}"), []) + .execute(damage, []) .expect("the saved payment is damaged"); - let restarted_manager = PlatformWalletManager::new( - Arc::new( - dashpay_sdk(&[&alice, &bob], vec![], PlatformVersion::latest(), None).await, - ), - kept, - Arc::new(NoopEventHandler), - ); - restarted_manager - .load_from_persistor() - .await - .expect("the relaunch loads"); - restarted_manager - .configure_shielded(&db_path) - .await - .expect("the shielded store opens"); - let restarted = restarted_manager - .get_wallet(&before.wallet_id()) - .await - .expect("the relaunched wallet"); - assert!(restarted.private_payments_held().await, "still held"); - for recipient in [alice.id(), Identifier::from([0xCC; 32])] { - let public = restarted - .identity() - .dashpay() - .send_contact_request_with_external_signer( - &bob.id(), - &recipient, - None, - AutoAcceptProofSource::None, - &bob_signer, - &bob_crypto, - ) - .await; - assert!( - matches!( - public, - Err(PlatformWalletError::PrivateContactPaymentsNotRestored) + for relaunch in 0..2 { + let restarted_manager = PlatformWalletManager::new( + Arc::new( + dashpay_sdk(&[&alice, &bob], vec![], PlatformVersion::latest(), None).await, ), - "{recipient}: {public:?}" + Arc::clone(&kept), + Arc::new(NoopEventHandler), ); + restarted_manager + .load_from_persistor() + .await + .expect("the relaunch loads"); + restarted_manager + .configure_shielded(&db_path) + .await + .expect("the shielded store opens"); + let restarted = restarted_manager + .get_wallet(&before.wallet_id()) + .await + .expect("the relaunched wallet"); + let coordinator = restarted_manager + .shielded_coordinator() + .await + .expect("the coordinator"); + for pass in 0..2 { + if pass == 1 { + // A coordinator pass, whose redrive keeps an undecodable + // record, then a second restoration. + coordinator.sync(true, &CancellationToken::new()).await; + restarted + .restore_private_contact_payments(&coordinator) + .await + .expect("restored again from the journal"); + } + let context = format!("{damage}, relaunch {relaunch}, pass {pass}"); + assert!(!restarted.private_payments_held().await, "{context}"); + let view = restarted.identity().dashpay(); + let public = view + .send_contact_request_with_external_signer( + &bob.id(), + &alice.id(), + None, + AutoAcceptProofSource::None, + &bob_signer, + &bob_crypto, + ) + .await; + assert!( + matches!( + public, + Err(PlatformWalletError::PrivateContactRequestPending(_)) + ), + "{context}: {public:?}" + ); + let private = view + .send_private_contact_request( + &bob.id(), + &alice.id(), + None, + AutoAcceptProofSource::None, + None, + &bob_signer, + &bob_crypto, + no_payment, + ) + .await; + assert!( + matches!( + private, + Err(PlatformWalletError::PrivateContactRequestPending(_)) + ), + "{context}: {private:?}" + ); + let elsewhere = view + .send_contact_request_with_external_signer( + &bob.id(), + &Identifier::from([0xCC; 32]), + None, + AutoAcceptProofSource::None, + &bob_signer, + &bob_crypto, + ) + .await; + assert!( + !matches!( + elsewhere, + Err(PlatformWalletError::PrivateContactRequestPending(_) + | PlatformWalletError::PrivateContactPaymentsNotRestored) + ), + "{context}: {elsewhere:?}" + ); + } } } } @@ -5792,147 +6009,33 @@ mod tests { assert_eq!(reply.lock().expect("reply").len(), 1, "the retry pays"); } - /// A private request whose payment was unconfirmed, and which the - /// shielded coordinator then settled as never executing (a definitive - /// consensus rejection of its saved transition), frees its pair without - /// a restart: the retry pays, even after a release was dropped while it - /// waited for the wallet lock. + /// A payment that may have reached the network keeps its pair pending + /// whatever error it returns, as its transition may still execute; one + /// whose transition provably never left the wallet releases it. #[cfg(feature = "shielded")] #[tokio::test] - async fn should_free_a_pair_once_its_unconfirmed_payment_is_rejected_for_good() { + async fn should_keep_a_pair_pending_once_its_payment_may_have_reached_the_network() { let alice = identity(ALICE_PHRASE, ALICE); let bob = identity(BOB_PHRASE, BOB); let alice_crypto = SeedCryptoProvider::from_seed(seed(ALICE_PHRASE), Network::Testnet); let alice_signer = signer(ALICE_PHRASE, &alice); - let (_manager, alice_wallet) = wallet(ALICE_PHRASE, &alice, &[&alice, &bob], vec![]).await; - let view = alice_wallet.identity().dashpay(); - let kept = Arc::new(Mutex::new(Vec::new())); - let unconfirmed = { - let kept = Arc::clone(&kept); - move |entry: BinaryData, started: PaymentStarted| { + for never_left in [false, true] { + let (_manager, alice_wallet) = + wallet(ALICE_PHRASE, &alice, &[&alice, &bob], vec![]).await; + let failing = move |_: BinaryData, started: PaymentStarted| { started.mark(); - kept.lock().expect("kept").push(entry); + if never_left { + started.note_never_delivered(); + } std::future::ready(Err::( - PlatformWalletError::ShieldedSpendUnconfirmed { - operation: "private document create", - reason: "the proof fetch timed out".to_string(), - }, + PlatformWalletError::ShieldedBroadcastFailed( + "rejected on its merits".to_string(), + ), )) - } - }; - let sent = view - .send_private_contact_request( - &alice.id(), - &bob.id(), - None, - AutoAcceptProofSource::None, - None, - &alice_signer, - &alice_crypto, - unconfirmed, - ) - .await; - assert!( - matches!( - sent, - Err(PlatformWalletError::ShieldedSpendUnconfirmed { .. }) - ), - "{sent:?}" - ); - assert!(pending_to(&alice_wallet, ALICE, &bob).await); - let entry = kept.lock().expect("kept").remove(0); - - // The redrive pass's definitive rejection of the saved transition. - note_rejected_private_documents( - &private_contact_request_payment(entry), - Some(&alice_wallet.identity().persister), - ); - - // A release dropped while it waits for the wallet lock keeps the - // notice for the next call. - { - let wm = alice_wallet.identity().wallet_manager.write().await; - let release = view.release_rejected_private_requests(); - assert!( - tokio::time::timeout(Duration::from_millis(50), release) - .await - .is_err(), - "the release waits for the wallet lock" - ); - drop(wm); - } - assert!(pending_to(&alice_wallet, ALICE, &bob).await); - - let retry = Arc::new(Mutex::new(Vec::new())); - let retried = view - .send_private_contact_request( - &alice.id(), - &bob.id(), - None, - AutoAcceptProofSource::None, - None, - &alice_signer, - &alice_crypto, - keep_entry(&retry), - ) - .await; - assert!( - matches!( - retried, - Err(PlatformWalletError::PrivateContactRequestPending(_)) - ), - "paid, not read back: {retried:?}" - ); - assert_eq!(retry.lock().expect("retry").len(), 1, "the retry pays"); - } - - /// A private request whose payment is ambiguous, unconfirmed or with a - /// broadcast that may have been delivered, stays pending through a private - /// refresh. Once the shielded coordinator settles that payment as never - /// executing (a definitive rejection of its saved transition), the next - /// refresh, or a readiness read alone, frees the pair and clears its - /// marker, with no restart and no other request. - #[cfg(feature = "shielded")] - #[tokio::test] - async fn should_free_a_pair_rejected_for_good_on_the_next_private_refresh() { - let alice = identity(ALICE_PHRASE, ALICE); - let bob = identity(BOB_PHRASE, BOB); - let alice_crypto = SeedCryptoProvider::from_seed(seed(ALICE_PHRASE), Network::Testnet); - let alice_signer = signer(ALICE_PHRASE, &alice); - let marker = |persistence: &RecordingPersistence| { - last_marker(&persistence.stored.lock().expect("stored"), &alice.id()) - }; - for refreshed in [true, false] { - let persistence = RecordingPersistence::attesting( - PersistenceCapabilities::PRIVATE_CONTACT_SCAN_STATE_RESTART, - ); - let (_manager, alice_wallet) = wallet_on( - Arc::clone(&persistence), - ALICE_PHRASE, - &alice, - &[&alice, &bob], - vec![], - ) - .await; - let view = alice_wallet.identity().dashpay(); - let paid = Arc::new(Mutex::new(Vec::new())); - // What the wallet's shielded payment returns for a private contact - // request whose broadcast may have been delivered. - let ambiguous = { - let paid = Arc::clone(&paid); - move |entry: BinaryData, started: PaymentStarted| { - started.mark(); - paid.lock().expect("paid").push(entry); - std::future::ready(Err::( - PlatformWalletError::ShieldedSpendUnconfirmed { - operation: "private document create from shielded pool", - reason: "the broadcast may have been delivered: unavailable" - .to_string(), - }, - )) - } }; - let sent = view + let sent = alice_wallet + .identity() + .dashpay() .send_private_contact_request( &alice.id(), &bob.id(), @@ -5941,192 +6044,21 @@ mod tests { None, &alice_signer, &alice_crypto, - ambiguous, + failing, ) .await; assert!( - matches!( - sent, - Err(PlatformWalletError::ShieldedSpendUnconfirmed { .. }) - ), + matches!(sent, Err(PlatformWalletError::ShieldedBroadcastFailed(_))), "{sent:?}" ); - assert_eq!(marker(&persistence), Some(true)); - - // Ambiguous: a refresh keeps the pair reserved. - view.sync_private_contact_requests(&alice_crypto, 4) - .await - .expect("the private refresh"); - assert!(pending_to(&alice_wallet, ALICE, &bob).await, "ambiguous"); - assert!( - view.private_contact_readiness(&alice.id()) - .await - .expect("the readiness") - .unresolved_private_payment, - "ambiguous" - ); - - // The redrive pass's definitive rejection of the saved transition. - let entry = paid.lock().expect("paid")[0].clone(); - note_rejected_private_documents( - &private_contact_request_payment(entry), - Some(&alice_wallet.identity().persister), - ); - - if refreshed { - view.sync_private_contact_requests(&alice_crypto, 4) - .await - .expect("the private refresh"); - let snapshot = managed(&alice_wallet, ALICE, |managed| managed.clone()).await; - assert!( - !snapshot.dashpay().private_reply_pending(&bob.id()), - "a fresh snapshot after the refresh shows the pair free" - ); - } assert_eq!( - view.private_contact_readiness(&alice.id()) - .await - .expect("the readiness"), - PrivateContactReadiness { - private_payments_restored: true, - private_store_read: true, - unresolved_private_payment: false, - }, - "refreshed first: {refreshed}" + pending_to(&alice_wallet, ALICE, &bob).await, + !never_left, + "never left the wallet: {never_left}" ); - assert!(!pending_to(&alice_wallet, ALICE, &bob).await); - assert_eq!(marker(&persistence), Some(false), "unmarked once released"); - assert_eq!(paid.lock().expect("paid").len(), 1, "no other request"); } } - /// A restoration that opened an unconfirmed request's entry before the - /// shielded coordinator settled its payment as never executing, and that - /// resumes only after a send's reconciliation took the rejection, reserves - /// nothing: the rejection outlives that take for the session. Restoring - /// from the payment's record afterwards reserves nothing either, and a - /// retry pays. - #[cfg(feature = "shielded")] - #[tokio::test] - async fn should_not_restore_a_request_rejected_for_good_while_its_restore_scanned() { - let alice = identity(ALICE_PHRASE, ALICE); - let bob = identity(BOB_PHRASE, BOB); - let alice_crypto = SeedCryptoProvider::from_seed(seed(ALICE_PHRASE), Network::Testnet); - let alice_signer = signer(ALICE_PHRASE, &alice); - let (_manager, alice_wallet) = wallet(ALICE_PHRASE, &alice, &[&alice, &bob], vec![]).await; - let kept = Arc::new(Mutex::new(Vec::new())); - let unconfirmed = { - let kept = Arc::clone(&kept); - move |entry: BinaryData, started: PaymentStarted| { - started.mark(); - kept.lock().expect("kept").push(entry); - std::future::ready(Err::( - PlatformWalletError::ShieldedSpendUnconfirmed { - operation: "private document create", - reason: "the proof fetch timed out".to_string(), - }, - )) - } - }; - let sent = alice_wallet - .identity() - .dashpay() - .send_private_contact_request( - &alice.id(), - &bob.id(), - None, - AutoAcceptProofSource::None, - None, - &alice_signer, - &alice_crypto, - unconfirmed, - ) - .await; - assert!( - matches!( - sent, - Err(PlatformWalletError::ShieldedSpendUnconfirmed { .. }) - ), - "{sent:?}" - ); - let entry = kept.lock().expect("kept").remove(0); - let commitment: [u8; 32] = entry.as_slice()[..32] - .try_into() - .expect("an entry starts with its commitment"); - - // Restarted: nothing reserved until a restoration runs. - let (_restarted_manager, restarted) = - wallet(ALICE_PHRASE, &alice, &[&alice, &bob], vec![]).await; - assert!(!pending_to(&restarted, ALICE, &bob).await); - let view = restarted.identity().dashpay(); - let persister = &restarted.identity().persister; - - let scanning = Arc::new(tokio::sync::Notify::new()); - let resume = Arc::new(tokio::sync::Notify::new()); - let mut pausing = HookedCrypto::new(ALICE_PHRASE); - pausing.before_page_scan = Some({ - let (scanning, resume) = (Arc::clone(&scanning), Arc::clone(&resume)); - Arc::new(move || { - let (scanning, resume) = (Arc::clone(&scanning), Arc::clone(&resume)); - Box::pin(async move { - scanning.notify_one(); - resume.notified().await; - }) - }) - }); - let restore = - view.restore_pending_private_contact_requests(std::slice::from_ref(&entry), &pausing); - let reject = async { - scanning.notified().await; - // The redrive pass's definitive rejection of the saved transition, - // then a send's reconciliation, with nothing restored to release. - note_rejected_private_documents( - &private_contact_request_payment(entry.clone()), - Some(persister), - ); - view.release_rejected_private_requests() - .await - .expect("the release"); - assert!( - !persister.has_rejected_private_entries(), - "the rejection was taken" - ); - resume.notify_one(); - }; - let (restored, ()) = tokio::join!(restore, reject); - assert_eq!(restored.expect("the restoration"), vec![]); - assert!(!pending_to(&restarted, ALICE, &bob).await); - - let from_record = view - .restore_private_payments(&[(alice.id(), bob.id(), 0, commitment)]) - .await - .expect("the restoration from the record"); - assert_eq!(from_record, vec![]); - assert!(!pending_to(&restarted, ALICE, &bob).await); - - let retry = Arc::new(Mutex::new(Vec::new())); - let retried = view - .send_private_contact_request( - &alice.id(), - &bob.id(), - None, - AutoAcceptProofSource::None, - None, - &alice_signer, - &alice_crypto, - keep_entry(&retry), - ) - .await; - assert!( - matches!( - retried, - Err(PlatformWalletError::PrivateContactRequestPending(_)) - ), - "paid, not read back: {retried:?}" - ); - assert_eq!(retry.lock().expect("retry").len(), 1, "the retry pays"); - } - /// The type 29 transition paying for `entry`, with placeholder actions: /// as the shielded store keeps a payment's signed transition. #[cfg(feature = "shielded")] @@ -6816,6 +6748,23 @@ mod tests { !pending_to(&restarted, BOB, &alice).await, "settled once read back" ); + // Read back and recorded durably, its journal record is discharged: + // the next restoration finds none, and the pair stays free. + restarted + .restore_private_contact_payments(&after) + .await + .expect("restored"); + assert!( + after + .store() + .read() + .await + .private_payment_journal(restarted.wallet_id()) + .expect("the journal") + .is_empty(), + "discharged once read back" + ); + assert!(!pending_to(&restarted, BOB, &alice).await); } /// A payment saved with its request is restored only while neither that @@ -6846,17 +6795,20 @@ mod tests { let view = bob_wallet.identity().dashpay(); let recorded = view - .restore_private_payments(&[ - (bob.id(), alice.id(), reference, [9; 32]), - (alice.id(), bob.id(), reference, [8; 32]), - ]) + .restore_private_payments( + &[ + (bob.id(), alice.id(), reference, [9; 32]), + (alice.id(), bob.id(), reference, [8; 32]), + ], + true, + ) .await .expect("restore"); assert!(recorded.is_empty(), "{recorded:?}"); assert!(!pending_to(&bob_wallet, BOB, &alice).await); let rotation = view - .restore_private_payments(&[(bob.id(), alice.id(), rotated, [9; 32])]) + .restore_private_payments(&[(bob.id(), alice.id(), rotated, [9; 32])], true) .await .expect("restore"); assert_eq!(rotation, vec![(bob.id(), alice.id())]); diff --git a/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/contact_requests.rs b/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/contact_requests.rs index 9a58e04b675..0ffbab17c29 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/contact_requests.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/contact_requests.rs @@ -1088,19 +1088,6 @@ impl ManagedIdentity { } } - /// Release every pending request sealed as one of `commitments`, whose - /// payments were settled as never executing, whichever attempt holds it. - pub(crate) fn release_rejected_private_replies( - &mut self, - commitments: &std::collections::BTreeSet<[u8; 32]>, - ) { - self.dashpay.pending_private_replies.retain(|_, pending| { - pending - .commitment - .is_none_or(|commitment| !commitments.contains(&commitment)) - }); - } - /// Settle the pending request to `recipient` sealed as `commitment`: the /// private sync read that entry back and recorded it. pub(crate) fn settle_private_reply(&mut self, recipient: &Identifier, commitment: &[u8; 32]) { diff --git a/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/dashpay.rs b/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/dashpay.rs index 713ad8c1f8c..acd334985c4 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/dashpay.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/dashpay.rs @@ -342,6 +342,13 @@ impl DashPayState { self.pending_private_replies.contains_key(contact) } + /// Whether any private request this identity sent is reserved as + /// pending ([`Self::private_reply_pending`]). + #[cfg(feature = "shielded")] + pub(crate) fn private_replies_pending(&self) -> bool { + !self.pending_private_replies.is_empty() + } + /// Whether this identity was loaded with a private contact request payment /// that may be unresolved, and no restoration from the shielded store has /// restored its reservations since: its public contact requests are held. diff --git a/packages/rs-platform-wallet/src/wallet/persister.rs b/packages/rs-platform-wallet/src/wallet/persister.rs index fd8100b3a93..0a357792134 100644 --- a/packages/rs-platform-wallet/src/wallet/persister.rs +++ b/packages/rs-platform-wallet/src/wallet/persister.rs @@ -59,12 +59,20 @@ pub struct WalletPersister { wallet_id: WalletId, inner: Arc, contact_payment_gate: Arc>, - /// Commitments of private document entries whose payment can never - /// execute: the shielded coordinator notes them when it settles an - /// unconfirmed spend as failed for good, and the wallet releases the - /// private contact request reservations held for them. Shared by the - /// clones the wallet and the coordinator hold. In memory only. - rejected_private_entries: Arc>, + /// Commitments of private contact request entries a private sync read + /// back and recorded durably, whose records in the shielded store's + /// private payment journal are to be discharged: the wallet's view + /// notes them, and whoever holds the store discharges them (the shielded + /// coordinator's sync pass, a restoration, a private payment). Shared by + /// the clones the wallet and the coordinator hold. In memory only: a + /// record left undischarged by a restart holds nothing, as the request + /// it pays for is recorded. + read_back_private_entries: Arc>>, + /// The (sender, recipient) pairs of journaled private payments whose + /// sender the wallet did not manage when they were last restored: an + /// identity added since holds them until the next restoration reserves + /// them. Replaced by every restoration. In memory only. + absent_sender_private_pairs: Arc>>, /// Whether the reservations of this wallet's unconfirmed private contact /// request payments from an earlier process may still be unrestored: /// raised at load for a wallet that bound a shielded account before, and @@ -74,67 +82,68 @@ pub struct WalletPersister { private_payments_unrestored: Arc, } -/// The commitments [`WalletPersister::note_rejected_private_entries`] noted, -/// both sets updated under one lock. -#[derive(Default)] -struct RejectedPrivateEntries { - /// Noted since the last take, for the wallet to release what it holds for - /// them ([`WalletPersister::take_rejected_private_entries`]). - unapplied: BTreeSet<[u8; 32]>, - /// Every one noted in this session, kept after the take: a restoration that - /// read an entry before its rejection and reserves its pair after the - /// release ran checks it here first - /// ([`WalletPersister::private_entry_rejected`]). One commitment per - /// payment settled as failed for good, gone with the process, as are the - /// reservations it guards. - session: BTreeSet<[u8; 32]>, -} - impl WalletPersister { pub fn new(wallet_id: WalletId, inner: Arc) -> Self { Self { wallet_id, inner, contact_payment_gate: Arc::new(tokio::sync::Mutex::new(())), - rejected_private_entries: Arc::default(), + read_back_private_entries: Arc::default(), + absent_sender_private_pairs: Arc::default(), private_payments_unrestored: Arc::default(), } } - /// Note the commitments of private document entries whose payment was - /// settled as never executing, for the wallet to release what it holds - /// for them ([`Self::take_rejected_private_entries`]). - #[cfg(feature = "shielded")] - pub(crate) fn note_rejected_private_entries( + /// Note the commitments of private contact request entries a private sync + /// read back and recorded durably, for their journal records to be + /// discharged ([`Self::take_read_back_private_entries`]). + pub(crate) fn note_read_back_private_entries( &self, commitments: impl IntoIterator, ) { - let mut rejected = self.rejected_entries(); - for commitment in commitments { - rejected.unapplied.insert(commitment); - rejected.session.insert(commitment); - } + self.read_back_entries().extend(commitments); } - /// Whether [`Self::note_rejected_private_entries`] noted any commitment - /// since the last take. - pub(crate) fn has_rejected_private_entries(&self) -> bool { - !self.rejected_entries().unapplied.is_empty() + /// Take the commitments [`Self::note_read_back_private_entries`] noted: + /// a caller that fails to discharge one notes it again. + #[cfg(feature = "shielded")] + pub(crate) fn take_read_back_private_entries(&self) -> BTreeSet<[u8; 32]> { + std::mem::take(&mut *self.read_back_entries()) } - /// Take the commitments [`Self::note_rejected_private_entries`] noted - /// since the last take. Take them only where they are applied with - /// nothing awaited in between, as nothing keeps a set taken and dropped. - pub(crate) fn take_rejected_private_entries(&self) -> BTreeSet<[u8; 32]> { - std::mem::take(&mut self.rejected_entries().unapplied) + /// Hold `pairs`, the (sender, recipient) pairs of journaled private + /// payments whose sender the wallet does not manage now, replacing the + /// ones an earlier restoration held. + #[cfg(feature = "shielded")] + pub(crate) fn hold_absent_sender_private_pairs( + &self, + pairs: BTreeSet<(Identifier, Identifier)>, + ) { + *self + .absent_sender_private_pairs + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) = pairs; } - /// Whether [`Self::note_rejected_private_entries`] noted `commitment` in - /// this session, taken or not: a restoration checks it under the wallet - /// lock before it reserves a pair for that entry, so a rejection noted and - /// applied while the restoration read its entries still holds. - pub(crate) fn private_entry_rejected(&self, commitment: &[u8; 32]) -> bool { - self.rejected_entries().session.contains(commitment) + /// Whether a journaled private payment from `sender` to `recipient` holds + /// the pair though the wallet did not manage `sender` when it was last + /// restored ([`Self::hold_absent_sender_private_pairs`]). + pub(crate) fn absent_sender_private_pair_held( + &self, + sender: &Identifier, + recipient: &Identifier, + ) -> bool { + self.absent_sender_private_pairs + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .contains(&(*sender, *recipient)) + } + + /// Whether [`Self::note_read_back_private_entries`] noted any commitment + /// not taken yet. + #[cfg(feature = "shielded")] + pub(crate) fn has_read_back_private_entries(&self) -> bool { + !self.read_back_entries().is_empty() } /// Hold this wallet's public contact requests, sent or accepted, until @@ -163,8 +172,8 @@ impl WalletPersister { self.private_payments_unrestored.load(Ordering::Acquire) } - fn rejected_entries(&self) -> std::sync::MutexGuard<'_, RejectedPrivateEntries> { - self.rejected_private_entries + fn read_back_entries(&self) -> std::sync::MutexGuard<'_, BTreeSet<[u8; 32]>> { + self.read_back_private_entries .lock() .unwrap_or_else(std::sync::PoisonError::into_inner) } diff --git a/packages/rs-platform-wallet/src/wallet/platform_wallet.rs b/packages/rs-platform-wallet/src/wallet/platform_wallet.rs index 612789d512f..b6aa68b5167 100644 --- a/packages/rs-platform-wallet/src/wallet/platform_wallet.rs +++ b/packages/rs-platform-wallet/src/wallet/platform_wallet.rs @@ -44,9 +44,13 @@ use dpp::identity::{Identity, IdentityPublicKey}; use dpp::prelude::Identifier; #[cfg(feature = "shielded")] use { - super::identity::network::dashpay_contract, - super::shielded::operations::private_document_create_from_pool_with_metadata, - super::shielded::store::{PendingRedrive, PrivateContactPayment}, + super::identity::network::{ + dashpay_contract, restore_journaled_private_payments, JournaledPrivatePayment, + }, + super::shielded::operations::{ + discharge_read_back_private_payments, private_document_create_from_pool_with_metadata, + }, + super::shielded::store::{PendingRedrive, PrivateContactPayment, PrivatePaymentRecord}, super::shielded::{NetworkShieldedCoordinator, OrchardKeySet, ShieldedStore}, crate::{ AutoAcceptProofSource, ContactCryptoProvider, ContactRequest, EstablishedContact, @@ -1037,15 +1041,38 @@ impl PlatformWallet { ) .await?; // After the install transaction: the restore takes the store and - // wallet locks on its own. - if let Err(error) = self.restore_private_contact_payments(coordinator).await { - tracing::warn!( - wallet_id = %hex::encode(self.wallet_id), - %error, - "private contact request reservations not restored at bind; a private send \ - still checks its own pair before it pays, a public one holds once the host \ - restores them or a private sync reads them back" - ); + // wallet locks on its own. It never waits for the store, which a + // running scan may hold for minutes while a host binds on its UI + // thread: a busy store defers it to a task, the wallet's contact + // requests held until it succeeds. + let restore = self.private_payment_restore(coordinator); + match restore.restore_and_release(false).await { + Ok(Some(_)) => {} + Ok(None) => { + tracing::debug!( + wallet_id = %hex::encode(self.wallet_id), + "the shielded store is busy; private contact request payments are \ + restored once it frees" + ); + tokio::spawn(async move { + if let Err(error) = restore.restore_and_release(true).await { + tracing::warn!( + wallet_id = %hex::encode(restore.wallet_id), + %error, + "private contact request payments not restored; the wallet's \ + contact requests stay held" + ); + } + }); + } + Err(error) => { + tracing::warn!( + wallet_id = %hex::encode(self.wallet_id), + %error, + "private contact request payments not restored at bind; the wallet's \ + contact requests stay held until a restoration succeeds" + ); + } } Ok(()) } @@ -1645,7 +1672,6 @@ impl PlatformWallet { account, sender_identity_id, recipient_identity_id, - crypto, entry, started, prover, @@ -1694,7 +1720,6 @@ impl PlatformWallet { account, &request.recipient_id, &request.sender_id, - crypto, entry, started, prover, @@ -1755,7 +1780,6 @@ impl PlatformWallet { account, &request.recipient_id, &request.sender_id, - crypto, entry, started, prover, @@ -1798,29 +1822,34 @@ impl PlatformWallet { .await } - /// Restore the reservations of the private contact requests whose payment - /// from this wallet's notes `coordinator`'s store holds as unconfirmed, - /// from the request each record was saved with (sender, recipient, - /// account reference) and the commitments its transition carries: no - /// keys open anything. Run when the wallet binds to the coordinator, so - /// restored reservations hold every public and private send from then - /// on. Returns the (sender, recipient) pairs restored. A record saved - /// without its request (before it was kept with it) is restored by - /// [`Self::restore_pending_private_contact_requests`] or the payment's - /// own check. An identity added to the wallet after the bind is not - /// covered until the next bind or that call. A saved payment that does - /// not read back, its transition or the entries it appends, fails the - /// restoration with [`PlatformWalletError::ShieldedRecoveryCorrupted`], - /// and every hold stays. + /// Restore the reservations of this wallet's private contact request + /// payments that may still execute, from the shielded store's private + /// payment journal (sender, recipient, account reference and entry + /// commitment of each, saved durably before it could reach the network), + /// and release the holds a load set: no keys open anything. Run when the + /// wallet binds to the coordinator, so restored reservations hold every + /// public and private send from then on. Returns the (sender, recipient) + /// pairs restored. + /// + /// Fails closed, every hold staying and the wallet's raised if it was + /// not: for a coordinator of another network, a wallet removed or + /// re-created since this handle was taken, a journal record that does + /// not read back, a saved spend whose transition does not decode and + /// that no journal record accounts for, a private contact request + /// payment saved before the journal kept it with no request to restore + /// its pair from, and an identity loaded marking a private payment that + /// may be unresolved when the journal is new + /// ([`PlatformWalletError::ShieldedRecoveryCorrupted`]). A payment saved + /// before the journal kept it, with its request, is added to the + /// journal. /// /// Run also by [`PlatformWalletManager::configure_shielded`] for every /// wallet the manager holds, and by its load for the wallets it loads once /// the store is configured: no keys are needed. Once it succeeds, the - /// wallet's public contact requests, which a load holds for a wallet that - /// bound a shielded account before, and for an identity whose position - /// record marks a private payment that may be unresolved - /// ([`PlatformWalletError::PrivateContactPaymentsNotRestored`]), go - /// ahead. + /// wallet's contact requests, which a load holds for a wallet that bound + /// a shielded account before, and for an identity whose position record + /// marks a private payment that may be unresolved + /// ([`PlatformWalletError::PrivateContactPaymentsNotRestored`]), go ahead. /// /// [`PlatformWalletManager::configure_shielded`]: crate::PlatformWalletManager::configure_shielded #[cfg(feature = "shielded")] @@ -1828,40 +1857,27 @@ impl PlatformWallet { &self, coordinator: &Arc, ) -> Result, PlatformWalletError> { - let dashpay_id = dashpay_contract()?.id(); - let saved = { - let store = coordinator.store().read().await; - store - .private_contact_redrives_for_wallet(self.wallet_id) - .map_err(PlatformWalletError::from)? - }; - // All or nothing: a saved payment that does not read back fails the - // restoration, and every hold stays. - let mut payments: Vec<(Identifier, Identifier, u32, [u8; 32])> = Vec::new(); - for (redrive, payment) in &saved { - for commitment in associated_payment_commitments(redrive, dashpay_id)? { - payments.push(( - Identifier::from(payment.sender), - Identifier::from(payment.recipient), - payment.account_reference, - commitment, - )); - } + self.private_payment_restore(coordinator) + .restore_and_release(true) + .await + .map(Option::unwrap_or_default) + } + + /// The handles a restoration of this wallet's private contact request + /// payments runs on ([`Self::restore_private_contact_payments`]). + #[cfg(feature = "shielded")] + fn private_payment_restore( + &self, + coordinator: &Arc, + ) -> PrivatePaymentRestore { + PrivatePaymentRestore { + coordinator: Arc::clone(coordinator), + wallet_manager: Arc::clone(&self.wallet_manager), + persister: self.persister.clone(), + generation: Arc::clone(&self.generation), + wallet_id: self.wallet_id, + network: self.sdk.network, } - let restored = self - .identity - .dashpay() - .restore_private_payments(&payments) - .await?; - // After the restored reservations are in place: a public send that - // sees the holds gone sees them. The identities' holds first, which - // their position records stored, then the wallet's. - self.identity - .dashpay() - .note_private_payments_restored() - .await?; - self.persister.private_payments_restored(); - Ok(restored) } /// Whether this wallet's public contact requests wait for its private @@ -1917,20 +1933,18 @@ impl PlatformWallet { /// dropped releases its reservation. #[cfg(feature = "shielded")] #[allow(clippy::too_many_arguments)] - async fn pay_private_contact_request( + async fn pay_private_contact_request

( &self, coordinator: &Arc, keyset: OrchardKeySet, account: u32, sender_identity_id: &Identifier, recipient_identity_id: &Identifier, - crypto: &C, entry: BinaryData, started: PaymentStarted, prover: P, ) -> Result where - C: ContactCryptoProvider + Sync, P: OrchardProver, { // The generation's lifecycle gate, held across the liveness check, @@ -1944,21 +1958,30 @@ impl PlatformWallet { self.wallet_id, ))); } - self.identity - .dashpay() - .release_rejected_private_requests() - .await?; - let unconfirmed = self - .unconfirmed_private_contact_requests(coordinator) - .await?; + // An earlier payment of the pair the journal keeps may still execute, + // whether or not its redrive record reads: then nothing is paid. The + // journal is read strictly: a record that does not read refuses. + let journaled: Vec<(u32, [u8; 32])> = { + let mut store = coordinator.store().write().await; + discharge_read_back_private_payments(&mut *store, &self.persister, self.wallet_id); + store + .private_payment_journal(self.wallet_id) + .map_err(PlatformWalletError::from)? + .into_iter() + .filter(|record| { + Identifier::from(record.payment.sender) == *sender_identity_id + && Identifier::from(record.payment.recipient) == *recipient_identity_id + }) + .map(|record| (record.payment.account_reference, record.commitment)) + .collect() + }; if self .identity .dashpay() - .hold_for_restored_private_contact_request( - &unconfirmed, - crypto, + .hold_for_journaled_private_payment( sender_identity_id, recipient_identity_id, + &journaled, ) .await? { @@ -2881,6 +2904,224 @@ fn associated_payment_commitments( .collect() } +/// The handles a restoration of a wallet's private contact request payments +/// from the shielded store's journal runs on, owned, so one the store is +/// busy for runs on a task ([`PlatformWallet::restore_private_contact_payments`]). +#[cfg(feature = "shielded")] +struct PrivatePaymentRestore { + coordinator: Arc, + wallet_manager: Arc>>, + persister: WalletPersister, + generation: Arc, + wallet_id: WalletId, + network: dashcore::Network, +} + +#[cfg(feature = "shielded")] +impl PrivatePaymentRestore { + /// Restore, and raise or release the wallet's hold as it went: released + /// once a restoration succeeds, raised when it fails or cannot run yet. + /// `Ok(None)` when the store is busy and `wait` is false. + async fn restore_and_release( + &self, + wait: bool, + ) -> Result>, PlatformWalletError> { + let restored = self.restore(wait).await; + match &restored { + Ok(Some(_)) => self.persister.private_payments_restored(), + Ok(None) | Err(_) => self.persister.hold_until_private_payments_restored(), + } + restored + } + + /// The restoration [`PlatformWallet::restore_private_contact_payments`] + /// documents, fenced against the wallet's replacement: the generation's + /// lifecycle gate is held from the liveness check until the reservations + /// are restored, so a removal cannot complete in between. + async fn restore( + &self, + wait: bool, + ) -> Result>, PlatformWalletError> { + if self.coordinator.network() != self.network { + return Err(PlatformWalletError::InvalidParameter(format!( + "the shielded coordinator is for {:?}, this wallet for {:?}", + self.coordinator.network(), + self.network + ))); + } + let _lifecycle = self.generation.payment_guard().await; + let current = { + let wm = self.wallet_manager.read().await; + wm.get_wallet_info(&self.wallet_id) + .is_some_and(|info| Arc::ptr_eq(&info.generation, &self.generation)) + }; + if !current { + return Err(PlatformWalletError::WalletNotFound(hex::encode( + self.wallet_id, + ))); + } + let dashpay_id = dashpay_contract()?.id(); + let (payments, journal_authoritative) = { + let mut store = if wait { + self.coordinator.store().write().await + } else { + match self.coordinator.store().try_write() { + Ok(store) => store, + Err(_) => return Ok(None), + } + }; + read_private_payment_journal(&mut *store, &self.persister, self.wallet_id, dashpay_id)? + }; + let restored = restore_journaled_private_payments( + &self.wallet_manager, + &self.persister, + self.wallet_id, + &payments, + journal_authoritative, + ) + .await?; + // Opened only once a restoration succeeded: a restoration that failed + // closed over a deleted or replaced store fails the same way until a + // known-good store is back. One left closed here is opened by the + // next restoration, which no unresolved marker holds back now. + if !journal_authoritative { + let opened = if wait { + Some(self.coordinator.store().write().await) + } else { + self.coordinator.store().try_write().ok() + } + .map(|mut store| store.open_private_payment_journal(self.wallet_id)); + if let Some(Err(error)) = opened { + tracing::warn!( + wallet_id = %hex::encode(self.wallet_id), + %error, + "the private payment journal was not opened; the next restoration opens it" + ); + } + } + Ok(Some(restored)) + } +} + +/// Read `wallet_id`'s private payment journal for a restoration, as +/// (sender, recipient, account reference, entry commitment), with whether it +/// is authoritative (open: a restoration from it succeeded before). The +/// read-back notices are discharged first. While the journal is new, a spend +/// saved before it kept the wallet's payments is accounted for: one paying +/// for a private contact request with its request saved is added to the +/// journal, one with no request, or whose transition does not decode and +/// that no record accounts for, fails the read +/// ([`PlatformWalletError::ShieldedRecoveryCorrupted`]). +#[cfg(feature = "shielded")] +fn read_private_payment_journal( + store: &mut S, + persister: &WalletPersister, + wallet_id: WalletId, + dashpay_id: Identifier, +) -> Result<(Vec, bool), PlatformWalletError> +where + PlatformWalletError: From, +{ + discharge_read_back_private_payments(store, persister, wallet_id); + let journal_authoritative = store.private_payment_journal_open(wallet_id)?; + let mut records = store.private_payment_journal(wallet_id)?; + let journaled: std::collections::BTreeSet<[u8; 32]> = + records.iter().map(|record| record.commitment).collect(); + let accounted: std::collections::BTreeSet<[u8; 32]> = + records.iter().map(|record| record.activity_id).collect(); + let corrupted = + |account_index: u32, reason: &str| PlatformWalletError::ShieldedRecoveryCorrupted { + account_index: Some(account_index), + reason: reason.to_string(), + }; + // The private contact request payments saved before the journal kept + // them: type 29 spends to DashPay's store no record accounts for. + // While the journal is new only: once a restoration from it succeeded, + // every payment is journaled with its redrive record in one write, so a + // record no journal record accounts for paid for nothing, or was + // discharged. + let mut unjournaled = Vec::new(); + let saved_before_the_journal = if journal_authoritative { + Vec::new() + } else { + store.pending_redrives_for_wallet(wallet_id)? + }; + for (id, redrive) in saved_before_the_journal { + // Identity debits are never private document payments. + if redrive.nullifiers.is_empty() || accounted.contains(&redrive.activity_id) { + continue; + } + let transition = match StateTransition::deserialize_from_bytes_trusted(&redrive.st_bytes) { + Ok(StateTransition::PrivateDocumentCreateFromShieldedPool(transition)) => transition, + Ok(_) => continue, + Err(_) => { + return Err(corrupted( + id.account_index, + "a saved spend's transition does not decode, and no private payment journal \ + record shows it pays for no private contact request", + )) + } + }; + if transition.data_contract_id() == dashpay_id + && transition.document_type_name() == PRIVATE_CONTACT_REQUEST_DOCUMENT_TYPE_NAME + { + unjournaled.push((id, redrive)); + } + } + // Read only when needed, so a damaged association of a payment the + // journal keeps holds nothing up: for an unjournaled payment, and for a + // new journal, which accounts for nothing saved before it, damaged + // records retained without being loaded included. + let associations: BTreeMap<[u8; 32], PrivateContactPayment> = if journal_authoritative { + BTreeMap::new() + } else { + store + .private_contact_redrives_for_wallet(wallet_id)? + .into_iter() + .map(|(redrive, payment)| (redrive.activity_id, payment)) + .collect() + }; + let mut migrated = Vec::new(); + for (id, redrive) in unjournaled { + let Some(payment) = associations.get(&redrive.activity_id) else { + return Err(corrupted( + id.account_index, + "a private contact request payment saved before the journal kept them carries \ + no request, so its pair cannot be restored", + )); + }; + for commitment in associated_payment_commitments(&redrive, dashpay_id)? { + if !journaled.contains(&commitment) { + migrated.push(PrivatePaymentRecord { + account_index: id.account_index, + activity_id: redrive.activity_id, + commitment, + payment: *payment, + nullifiers: redrive.nullifiers.clone(), + }); + } + } + } + if !migrated.is_empty() { + store.journal_private_payments(wallet_id, &migrated)?; + records.extend(migrated); + } + Ok(( + records + .into_iter() + .map(|record| { + ( + Identifier::from(record.payment.sender), + Identifier::from(record.payment.recipient), + record.payment.account_reference, + record.commitment, + ) + }) + .collect(), + journal_authoritative, + )) +} + /// The entries of every private document create (type 29) among `redrives` /// that appends to DashPay's `privateContactRequest` store, `dashpay_id` /// being DashPay's contract id. A record whose transition does not decode @@ -3040,7 +3281,6 @@ mod private_contact_request_redrive_tests { mod private_contact_request_payment_tests { use super::*; use crate::events::{EventHandler, PlatformEventHandler}; - use crate::wallet::identity::network::SeedCryptoProvider; use crate::wallet::persister::NoPlatformPersistence; use crate::wallet::shielded::FileBackedShieldedStore; use crate::PlatformWalletManager; @@ -3107,7 +3347,6 @@ mod private_contact_request_payment_tests { ) -> Result { let keyset = OrchardKeySet::from_seed(&SEED, dashcore::Network::Testnet, 0).expect("keyset"); - let crypto = SeedCryptoProvider::from_seed(SEED, dashcore::Network::Testnet); wallet .pay_private_contact_request( coordinator, @@ -3115,7 +3354,6 @@ mod private_contact_request_payment_tests { 0, &Identifier::from([0xA1; 32]), &Identifier::from([0xB1; 32]), - &crypto, BinaryData::new(vec![1; 589]), PaymentStarted::default(), UnreachableProver, diff --git a/packages/rs-platform-wallet/src/wallet/shielded/coordinator.rs b/packages/rs-platform-wallet/src/wallet/shielded/coordinator.rs index a71b709d85e..df53e957352 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/coordinator.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/coordinator.rs @@ -1329,6 +1329,31 @@ impl NetworkShieldedCoordinator { /// [`sync_notes_across`]: super::sync::sync_notes_across /// [`PlatformEventManager::on_shielded_sync_completed`]: /// crate::events::PlatformEventManager::on_shielded_sync_completed + /// Discharge the private payment journal records of the private contact + /// requests each registered wallet's private sync read back and recorded + /// durably (`WalletPersister::note_read_back_private_entries`). + async fn discharge_read_back_private_payments(&self) { + let noted: Vec<(WalletId, WalletPersister)> = self + .persisters + .read() + .await + .iter() + .filter(|(_, persister)| persister.has_read_back_private_entries()) + .map(|(wallet_id, persister)| (*wallet_id, persister.clone())) + .collect(); + if noted.is_empty() { + return; + } + let mut store = self.store.write().await; + for (wallet_id, persister) in ¬ed { + super::operations::discharge_read_back_private_payments( + &mut *store, + persister, + *wallet_id, + ); + } + } + pub async fn sync(&self, force: bool, cancel: &CancellationToken) -> ShieldedSyncPassSummary { // A stop that landed before any work: nothing to finalize, so // return the empty summary without touching Platform or the @@ -1340,6 +1365,10 @@ impl NetworkShieldedCoordinator { return ShieldedSyncPassSummary::default(); } + // Every pass, cooldown or not: the private requests a private sync read + // back discharge their journal records. + self.discharge_read_back_private_payments().await; + // Network-wide cooldown gate. Snapshot the remaining // window into a local before any await — `std::sync::Mutex` // is `!Send` across await points (clippy's @@ -1473,19 +1502,7 @@ impl NetworkShieldedCoordinator { // this pass's balances. Skipped on a cancelled pass: the re-drive // broadcasts, and a skipped release simply waits for the next pass. if let Some((snapshot, recorded)) = stranded_release.filter(|_| !cancelled) { - // Snapshot the per-wallet persisters BEFORE the loop and drop - // the read guard: `redrive_pending_spends` performs network - // broadcasts, and holding the persisters lock across those - // awaits would block wallet register/unregister for the - // duration of the round trips. - let subwallet_persisters: Vec<(SubwalletId, Option)> = { - let persisters = self.persisters.read().await; - subwallets - .iter() - .map(|(id, _)| (*id, persisters.get(&id.wallet_id).cloned())) - .collect() - }; - for (id, persister) in &subwallet_persisters { + for (id, _) in &subwallets { // Network broadcasts: a stop that lands after the scan's // snapshot above stops the loop here, and the helper // checks the token before each request it starts. @@ -1495,8 +1512,6 @@ impl NetworkShieldedCoordinator { super::operations::redrive_pending_spends( &self.sdk, &self.store, - persister.as_ref(), - id.wallet_id, *id, &recorded, cancel, @@ -1797,25 +1812,6 @@ impl NetworkShieldedCoordinator { ); continue; } - // A private document the stranded spend paid for can never be - // appended: the wallet releases what it holds for it. - if let Some(entry_id) = activity_id { - let rejected = store - .pending_redrives(id) - .ok() - .into_iter() - .flatten() - .filter(|redrive| redrive.activity_id == entry_id) - .filter_map(|redrive| { - StateTransition::deserialize_from_bytes_trusted(&redrive.st_bytes).ok() - }); - for transition in rejected { - super::operations::note_rejected_private_documents( - &transition, - persisters.get(&id.wallet_id), - ); - } - } match store.clear_pending(id, &nullifier) { // `still_same` held under this same guard, so the clear // removes exactly the snapshot's reservation. diff --git a/packages/rs-platform-wallet/src/wallet/shielded/file_store.rs b/packages/rs-platform-wallet/src/wallet/shielded/file_store.rs index 21fcb44e447..9c3feb8b389 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/file_store.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/file_store.rs @@ -23,12 +23,20 @@ use grovedb_commitment_tree::{ClientPersistentCommitmentTree, Position, Retentio use super::balance::{ShieldedBalanceSource, ShieldedLocalAccountBalance}; use super::store::{ - PendingRedrive, PrivateContactPayment, ShieldedNote, ShieldedOutgoingNote, ShieldedStore, - StalePendingSpend, SubwalletId, SubwalletState, + PendingRedrive, PrivateContactPayment, PrivatePaymentRecord, ShieldedNote, + ShieldedOutgoingNote, ShieldedStore, StalePendingSpend, SubwalletId, SubwalletState, }; use crate::error::PlatformWalletError; use crate::wallet::platform_wallet::WalletId; +/// The note-spend kind of a private document create from the shielded pool +/// (type 29), the transition a private contact request payment is. +const PRIVATE_DOCUMENT_CREATE_KIND: &str = "private document create from shielded pool"; + +/// A private payment journal row: the entry's commitment, which keys it, and +/// the record's bytes ([`PrivatePaymentRecord::to_bytes`]). +type JournalRow = ([u8; 32], Vec); + /// Error type for [`FileBackedShieldedStore`]. #[derive(Debug)] pub enum FileShieldedStoreError { @@ -102,12 +110,14 @@ pub struct FileBackedShieldedStore { /// Per-subwallet notes + sync state, keyed by `(wallet_id, /// account_index)`. Lazily populated on first use of an id. subwallets: BTreeMap, - /// Second connection on the same SQLite file, owning the - /// `shielded_pending_spends` table (armed [`PendingRedrive`] - /// records). Separate from `tree` because the commitment-tree - /// wrapper takes its `Connection` by value; WAL mode makes the - /// two-connection setup safe. `Mutex` for the same `Sync`-shim - /// reason as `tree`. + /// Second connection on the same SQLite file, owning the recovery + /// tables: `shielded_pending_spends` (armed [`PendingRedrive`] records) + /// and the private payment journal. Separate from `tree` because the + /// commitment-tree wrapper takes its `Connection` by value; WAL mode + /// makes the two-connection setup safe. It commits at + /// `synchronous=FULL` ([`Self::open_recovery_connection`]): a recovery + /// record saved before a broadcast must survive a power loss, unlike the + /// rebuildable tree. `Mutex` for the same `Sync`-shim reason as `tree`. pending_conn: Mutex, /// The private contact request each armed record pays for, by subwallet /// and activity id, as the `private_contact_payment` column keeps it. @@ -121,6 +131,16 @@ pub struct FileBackedShieldedStore { /// restoring a covered wallet's reservations fails /// ([`ShieldedStore::private_contact_redrives_for_wallet`]). unloaded_private_contact_payments: BTreeSet>, + /// The private payment journal's records, by wallet and commitment, as + /// the `shielded_private_payment_journal` table keeps them. + private_payment_journal: BTreeMap<(WalletId, [u8; 32]), PrivatePaymentRecord>, + /// The wallets whose private payment journal is open, as the + /// `shielded_private_payment_journal_wallets` table keeps them. + private_payment_journals: BTreeSet, + /// The wallets of journal records that do not read back, `None` for one + /// whose wallet does not read either. The row stays, and reading a + /// covered wallet's journal fails, so its holds stay. + unreadable_private_payment_journal: BTreeSet>, } /// The private contact request association a saved record carries. @@ -147,17 +167,26 @@ impl FileBackedShieldedStore { } /// Persist `redrive`, with the private contact request it pays for when - /// it does, in one row write. + /// it does, and the journal `records` of the entries it appends, in one + /// transaction committed at `synchronous=FULL`. fn arm_redrive_paying_for( &mut self, id: SubwalletId, redrive: PendingRedrive, payment: Option, + records: &[PrivatePaymentRecord], ) -> Result<(), FileShieldedStoreError> { + let journal_rows = Self::journal_rows(records)?; { - let conn = self.pending_conn.lock().expect("pending_conn mutex"); + let mut conn = self + .pending_conn + .lock() + .map_err(|e| FileShieldedStoreError::Storage(format!("pending_conn mutex: {e}")))?; + let tx = conn + .transaction() + .map_err(|e| FileShieldedStoreError::Storage(format!("begin redrive: {e}")))?; let nullifier_blob: Vec = redrive.nullifiers.iter().flatten().copied().collect(); - conn.execute( + tx.execute( "INSERT OR REPLACE INTO shielded_pending_spends \ (wallet_id, account_index, activity_id, anchor, nullifiers, st_bytes, attempts, identity_nonce_finalized, identity_user_abandoned, private_contact_payment) \ VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)", @@ -175,6 +204,16 @@ impl FileBackedShieldedStore { ], ) .map_err(|e| FileShieldedStoreError::Storage(format!("persist redrive: {e}")))?; + Self::insert_journal_rows(&tx, id.wallet_id, &journal_rows)?; + tx.commit() + .map_err(|e| FileShieldedStoreError::Storage(format!("commit redrive: {e}")))?; + } + if !records.is_empty() { + self.private_payment_journals.insert(id.wallet_id); + } + for record in records { + self.private_payment_journal + .insert((id.wallet_id, record.commitment), record.clone()); } match payment { Some(payment) => { @@ -192,6 +231,116 @@ impl FileBackedShieldedStore { Ok(()) } + /// The rows `records` are kept as: commitment and record bytes. Fails + /// for a record [`PrivatePaymentRecord::to_bytes`] refuses. + fn journal_rows( + records: &[PrivatePaymentRecord], + ) -> Result, FileShieldedStoreError> { + records + .iter() + .map(|record| { + record + .to_bytes() + .map(|bytes| (record.commitment, bytes)) + .ok_or_else(|| { + FileShieldedStoreError::Storage( + "a private payment's journal record spends no note, or too many" + .to_string(), + ) + }) + }) + .collect() + } + + /// Insert `rows` into `wallet_id`'s journal within `tx`, opening the + /// journal too. + fn insert_journal_rows( + tx: &rusqlite::Transaction<'_>, + wallet_id: WalletId, + rows: &[JournalRow], + ) -> Result<(), FileShieldedStoreError> { + if rows.is_empty() { + return Ok(()); + } + tx.execute( + "INSERT OR IGNORE INTO shielded_private_payment_journal_wallets (wallet_id) \ + VALUES (?1)", + rusqlite::params![wallet_id.as_slice()], + ) + .map_err(|e| FileShieldedStoreError::Storage(format!("open payment journal: {e}")))?; + for (commitment, bytes) in rows { + tx.execute( + "INSERT OR REPLACE INTO shielded_private_payment_journal \ + (wallet_id, commitment, record) VALUES (?1, ?2, ?3)", + rusqlite::params![wallet_id.as_slice(), commitment.as_slice(), bytes], + ) + .map_err(|e| FileShieldedStoreError::Storage(format!("journal payment: {e}")))?; + } + Ok(()) + } + + /// Load the private payment journal into memory. A row that does not + /// read back stays, and flags its wallet + /// (`unreadable_private_payment_journal`); the open does not fail. + fn load_private_payment_journal(&mut self) -> Result<(), FileShieldedStoreError> { + let conn = self + .pending_conn + .lock() + .map_err(|e| FileShieldedStoreError::Storage(format!("pending_conn mutex: {e}")))?; + let mut journals = conn + .prepare("SELECT wallet_id FROM shielded_private_payment_journal_wallets") + .map_err(|e| FileShieldedStoreError::Storage(format!("prepare journals: {e}")))?; + let wallets = journals + .query_map([], |row| row.get::<_, Vec>(0)) + .map_err(|e| FileShieldedStoreError::Storage(format!("query journals: {e}")))?; + for wallet in wallets { + // A journal whose wallet does not read is not open for any wallet. + if let Some(wallet_id) = wallet + .ok() + .and_then(|bytes| <[u8; 32]>::try_from(bytes.as_slice()).ok()) + { + self.private_payment_journals.insert(wallet_id); + } + } + drop(journals); + let mut statement = conn + .prepare("SELECT wallet_id, commitment, record FROM shielded_private_payment_journal") + .map_err(|e| FileShieldedStoreError::Storage(format!("prepare journal: {e}")))?; + let rows = statement + .query_map([], |row| { + Ok(( + row.get::<_, Vec>(0).ok(), + row.get::<_, Vec>(1).ok(), + row.get::<_, Vec>(2).ok(), + )) + }) + .map_err(|e| FileShieldedStoreError::Storage(format!("query journal: {e}")))?; + for row in rows { + let (wallet_id, commitment, record) = + row.map_err(|e| FileShieldedStoreError::Storage(format!("read journal: {e}")))?; + let wallet_id = wallet_id.and_then(|bytes| <[u8; 32]>::try_from(bytes.as_slice()).ok()); + let read = wallet_id.and_then(|wallet_id| { + let commitment = <[u8; 32]>::try_from(commitment?.as_slice()).ok()?; + let record = PrivatePaymentRecord::from_bytes(&record?)?; + (record.commitment == commitment).then_some((wallet_id, record)) + }); + match read { + Some((wallet_id, record)) => { + self.private_payment_journal + .insert((wallet_id, record.commitment), record); + } + None => { + tracing::warn!( + "retaining a private payment journal record that does not read back; \ + its wallet's private contact request holds stay" + ); + self.unreadable_private_payment_journal.insert(wallet_id); + } + } + } + Ok(()) + } + /// Called under the coordinator's store read guard so amount and provenance /// cannot straddle a sync or a pending-spend reservation. pub(super) fn local_account_balance( @@ -254,7 +403,7 @@ impl FileBackedShieldedStore { let conn = Self::open_tuned_connection(&path)?; let tree = ClientPersistentCommitmentTree::open(conn, max_checkpoints) .map_err(|e| FileShieldedStoreError::Storage(format!("open commitment tree: {e}")))?; - let pending_conn = Self::open_tuned_connection(&path)?; + let pending_conn = Self::open_recovery_connection(&path)?; pending_conn .execute( "CREATE TABLE IF NOT EXISTS shielded_pending_spends ( @@ -309,6 +458,24 @@ impl FileBackedShieldedStore { FileShieldedStoreError::Storage(format!("upgrade pending schema: {e}")) })?; } + // The private payment journal: one record per private contact request + // payment that may have reached the network, by wallet and entry + // commitment, and the wallets whose journal is open. Never purged. + pending_conn + .execute_batch( + "CREATE TABLE IF NOT EXISTS shielded_private_payment_journal ( + wallet_id BLOB NOT NULL, + commitment BLOB NOT NULL, + record BLOB NOT NULL, + PRIMARY KEY (wallet_id, commitment) + ); + CREATE TABLE IF NOT EXISTS shielded_private_payment_journal_wallets ( + wallet_id BLOB NOT NULL PRIMARY KEY + );", + ) + .map_err(|e| { + FileShieldedStoreError::Storage(format!("create private payment journal: {e}")) + })?; let mut store = Self { tree: Mutex::new(tree), path, @@ -317,8 +484,12 @@ impl FileBackedShieldedStore { pending_conn: Mutex::new(pending_conn), private_contact_payments: BTreeMap::new(), unloaded_private_contact_payments: BTreeSet::new(), + private_payment_journal: BTreeMap::new(), + private_payment_journals: BTreeSet::new(), + unreadable_private_payment_journal: BTreeSet::new(), }; store.rehydrate_pending_spends()?; + store.load_private_payment_journal()?; Ok(store) } @@ -414,7 +585,8 @@ impl FileBackedShieldedStore { error = %error, "retaining but skipping a damaged shielded note-spend recovery row" ); - if private_contact_payment.is_some() { + if private_contact_payment.is_some() || kind == PRIVATE_DOCUMENT_CREATE_KIND + { self.unloaded_private_contact_payments .insert(readable_wallet_id); } @@ -491,7 +663,8 @@ impl FileBackedShieldedStore { reason = %error, "retaining but skipping a damaged shielded note-spend recovery row" ); - if private_contact_payment.is_some() { + if private_contact_payment.is_some() || kind == PRIVATE_DOCUMENT_CREATE_KIND + { self.unloaded_private_contact_payments .insert(readable_wallet_id); } @@ -532,6 +705,9 @@ impl FileBackedShieldedStore { StateTransition::IdentityTopUpFromShieldedPool(_) => { Some("identity top up from shielded pool") } + StateTransition::PrivateDocumentCreateFromShieldedPool(_) => { + Some(PRIVATE_DOCUMENT_CREATE_KIND) + } _ => None, } } @@ -546,6 +722,21 @@ impl FileBackedShieldedStore { /// /// [`open_path`]: Self::open_path /// [`reset_commitment_tree`]: ShieldedStore::reset_commitment_tree + /// [`Self::open_tuned_connection`] committing at `synchronous=FULL`, for + /// the recovery tables: in WAL mode that syncs the WAL on every commit, + /// so a recovery record saved before a broadcast survives a crash or + /// power loss. The commitment tree's connection keeps `NORMAL`. + fn open_recovery_connection( + path: &Path, + ) -> Result { + let conn = Self::open_tuned_connection(path)?; + conn.pragma_update(None, "synchronous", "FULL") + .map_err(|e| { + FileShieldedStoreError::Storage(format!("PRAGMA synchronous=FULL: {e}")) + })?; + Ok(conn) + } + fn open_tuned_connection(path: &Path) -> Result { let conn = rusqlite::Connection::open(path) .map_err(|e| FileShieldedStoreError::Storage(format!("open sqlite: {e}")))?; @@ -756,16 +947,128 @@ impl ShieldedStore for FileBackedShieldedStore { } fn arm_redrive(&mut self, id: SubwalletId, redrive: PendingRedrive) -> Result<(), Self::Error> { - self.arm_redrive_paying_for(id, redrive, None) + self.arm_redrive_paying_for(id, redrive, None, &[]) } - fn arm_private_contact_redrive( + fn arm_private_contact_payment( &mut self, id: SubwalletId, redrive: PendingRedrive, payment: PrivateContactPayment, + commitments: &[[u8; 32]], + ) -> Result<(), Self::Error> { + let records: Vec = commitments + .iter() + .map(|commitment| PrivatePaymentRecord { + account_index: id.account_index, + activity_id: redrive.activity_id, + commitment: *commitment, + payment, + nullifiers: redrive.nullifiers.clone(), + }) + .collect(); + self.arm_redrive_paying_for(id, redrive, Some(payment), &records) + } + + fn private_payment_journal_open(&self, wallet_id: WalletId) -> Result { + Ok(self.private_payment_journals.contains(&wallet_id)) + } + + fn open_private_payment_journal(&mut self, wallet_id: WalletId) -> Result<(), Self::Error> { + if self.private_payment_journals.contains(&wallet_id) { + return Ok(()); + } + { + let conn = self + .pending_conn + .lock() + .map_err(|e| FileShieldedStoreError::Storage(format!("pending_conn mutex: {e}")))?; + conn.execute( + "INSERT OR IGNORE INTO shielded_private_payment_journal_wallets (wallet_id) \ + VALUES (?1)", + rusqlite::params![wallet_id.as_slice()], + ) + .map_err(|e| FileShieldedStoreError::Storage(format!("open payment journal: {e}")))?; + } + self.private_payment_journals.insert(wallet_id); + Ok(()) + } + + /// Fails with [`FileShieldedStoreError::RecoveryCorrupted`] when a + /// journal record of the wallet, or one whose wallet does not read, does + /// not read back: the pair it holds could not be restored. + fn private_payment_journal( + &self, + wallet_id: WalletId, + ) -> Result, Self::Error> { + if self + .unreadable_private_payment_journal + .contains(&Some(wallet_id)) + || self.unreadable_private_payment_journal.contains(&None) + { + return Err(FileShieldedStoreError::RecoveryCorrupted { + account_index: None, + reason: "a private payment journal record of the wallet does not read back" + .to_string(), + }); + } + Ok(self + .private_payment_journal + .range((wallet_id, [0u8; 32])..=(wallet_id, [u8::MAX; 32])) + .map(|(_, record)| record.clone()) + .collect()) + } + + fn journal_private_payments( + &mut self, + wallet_id: WalletId, + records: &[PrivatePaymentRecord], ) -> Result<(), Self::Error> { - self.arm_redrive_paying_for(id, redrive, Some(payment)) + let rows = Self::journal_rows(records)?; + { + let mut conn = self + .pending_conn + .lock() + .map_err(|e| FileShieldedStoreError::Storage(format!("pending_conn mutex: {e}")))?; + let tx = conn + .transaction() + .map_err(|e| FileShieldedStoreError::Storage(format!("begin journal: {e}")))?; + Self::insert_journal_rows(&tx, wallet_id, &rows)?; + tx.commit() + .map_err(|e| FileShieldedStoreError::Storage(format!("commit journal: {e}")))?; + } + if !records.is_empty() { + self.private_payment_journals.insert(wallet_id); + } + for record in records { + self.private_payment_journal + .insert((wallet_id, record.commitment), record.clone()); + } + Ok(()) + } + + fn discharge_private_payment( + &mut self, + wallet_id: WalletId, + commitment: &[u8; 32], + ) -> Result { + // Durable first: a failure leaves the record, which over-holds. + { + let conn = self + .pending_conn + .lock() + .map_err(|e| FileShieldedStoreError::Storage(format!("pending_conn mutex: {e}")))?; + conn.execute( + "DELETE FROM shielded_private_payment_journal \ + WHERE wallet_id = ?1 AND commitment = ?2", + rusqlite::params![wallet_id.as_slice(), commitment.as_slice()], + ) + .map_err(|e| FileShieldedStoreError::Storage(format!("discharge payment: {e}")))?; + } + Ok(self + .private_payment_journal + .remove(&(wallet_id, *commitment)) + .is_some()) } /// Fails with [`FileShieldedStoreError::RecoveryCorrupted`] when a live @@ -1284,7 +1587,7 @@ mod tests { { let mut store = FileBackedShieldedStore::open_path(&path, 100).expect("open"); store - .arm_private_contact_redrive(id, record(1, 3), payment) + .arm_private_contact_payment(id, record(1, 3), payment, &[[0xC1; 32]]) .expect("arm the payment"); store.arm_redrive(id, record(4, 6)).expect("arm a spend"); store.bump_redrive_attempts(id, &[1u8; 32]).expect("bump"); @@ -1349,7 +1652,12 @@ mod tests { let mut store = FileBackedShieldedStore::open_path(&path, 100).expect("open"); let arm = |store: &mut FileBackedShieldedStore, id, activity, nullifier| { store - .arm_private_contact_redrive(id, record(activity, nullifier), payment) + .arm_private_contact_payment( + id, + record(activity, nullifier), + payment, + &[[activity; 32]], + ) .expect("armed"); }; @@ -1574,7 +1882,7 @@ mod tests { let arm = || { let mut store = FileBackedShieldedStore::open_path(&path, 100).unwrap(); store - .arm_private_contact_redrive(id, record.clone(), payment) + .arm_private_contact_payment(id, record.clone(), payment, &[[0xC2; 32]]) .unwrap(); }; let set = |assignment: &str| { @@ -2389,4 +2697,259 @@ mod tests { recorded set is exactly these two and the mid-block anchor is outside it" ); } + + /// A type 29 transition paying for one entry whose commitment is + /// `[commitment; 32]`, with placeholder actions, serialized. + fn private_document_create_bytes(commitment: u8) -> Vec { + use dpp::platform_value::BinaryData; + use dpp::shielded::SerializedAction; + use dpp::state_transition::private_document_create_from_shielded_pool_transition::methods::PrivateDocumentCreateFromShieldedPoolTransitionMethodsV0; + use dpp::state_transition::private_document_create_from_shielded_pool_transition::PrivateDocumentCreateFromShieldedPoolTransition; + use dpp::version::PlatformVersion; + + let action = SerializedAction { + nullifier: [1; 32], + rk: [2; 32], + cmx: [3; 32], + encrypted_note: vec![4; 216], + cv_net: [5; 32], + spend_auth_sig: [6; 64], + }; + let mut entry = vec![commitment; 32]; + entry.extend_from_slice(&[0; 557]); + PrivateDocumentCreateFromShieldedPoolTransition::try_from_bundle( + dpp::prelude::Identifier::from([0x44; 32]), + "privateContactRequest".to_string(), + vec![BinaryData::new(entry)], + vec![action.clone(), action], + 0, + [7; 32], + vec![8; 64], + [9; 64], + PlatformVersion::latest(), + ) + .expect("a type 29 transition") + .serialize_to_bytes() + .expect("serializes") + } + + /// The recovery tables commit at `synchronous=FULL`, so a record saved + /// before a broadcast survives a power loss; the commitment tree's + /// connection keeps `NORMAL`. + #[test] + fn should_commit_recovery_records_at_full_sync() { + let path = temp_tree_path("recovery_full_sync"); + let store = FileBackedShieldedStore::open_path(&path, 100).expect("open"); + let synchronous = |conn: &rusqlite::Connection| { + conn.pragma_query_value(None, "synchronous", |row| row.get::<_, i64>(0)) + .expect("synchronous") + }; + let recovery = store.pending_conn.lock().expect("pending_conn mutex"); + assert_eq!(synchronous(&recovery), 2, "FULL"); + drop(recovery); + let tree = FileBackedShieldedStore::open_tuned_connection(&path).expect("tree conn"); + assert_eq!(synchronous(&tree), 1, "NORMAL"); + let _ = std::fs::remove_file(&path); + } + + /// A journal record is saved with its payment's redrive record, and + /// outlives it: no note release, purge or reopen removes it, only a + /// discharge does. The journal it was saved in reads as open. + #[test] + fn should_keep_a_journal_record_until_it_is_discharged() { + let path = temp_tree_path("journal_lifecycle"); + let wallet_id = [0x5A; 32]; + let id = SubwalletId::new(wallet_id, 1); + let payment = PrivateContactPayment { + sender: [0xB1; 32], + recipient: [0xA1; 32], + account_reference: 7, + }; + let redrive = PendingRedrive { + activity_id: [0x21; 32], + anchor: [0x22; 32], + nullifiers: vec![[0x23; 32], [0x24; 32]], + st_bytes: private_document_create_bytes(0xC3), + attempts: 0, + identity_nonce_finalized: false, + identity_user_abandoned: false, + }; + let expected = PrivatePaymentRecord { + account_index: 1, + activity_id: redrive.activity_id, + commitment: [0xC3; 32], + payment, + nullifiers: redrive.nullifiers.clone(), + }; + let mut store = FileBackedShieldedStore::open_path(&path, 100).expect("open"); + assert!(!store + .private_payment_journal_open(wallet_id) + .expect("open?")); + for nullifier in &redrive.nullifiers { + store.mark_pending(id, nullifier).expect("reserved"); + } + store + .arm_private_contact_payment(id, redrive.clone(), payment, &[[0xC3; 32]]) + .expect("armed"); + assert!(store + .private_payment_journal_open(wallet_id) + .expect("open?")); + assert_eq!( + store.private_payment_journal(wallet_id).expect("journal"), + vec![expected.clone()] + ); + // A note release drops the redrive record, not the journal record. + assert!(store.clear_pending(id, &[0x23; 32]).expect("released")); + assert!(store.pending_redrives(id).expect("records").is_empty()); + store.purge_subwallet(id).expect("account purge"); + store.purge_wallet(wallet_id).expect("wallet purge"); + store.purge_all_subwallets().expect("purge all"); + drop(store); + let mut store = FileBackedShieldedStore::open_path(&path, 100).expect("reopened"); + assert!(store + .private_payment_journal_open(wallet_id) + .expect("open?")); + assert_eq!( + store.private_payment_journal(wallet_id).expect("journal"), + vec![expected] + ); + assert!(store + .private_payment_journal([0x5B; 32]) + .expect("another wallet's") + .is_empty()); + assert!(store + .discharge_private_payment(wallet_id, &[0xC3; 32]) + .expect("discharged")); + drop(store); + let store = FileBackedShieldedStore::open_path(&path, 100).expect("reopened"); + assert!(store + .private_payment_journal(wallet_id) + .expect("journal") + .is_empty()); + let _ = std::fs::remove_file(&path); + } + + /// A journal record that does not read back stays, and fails reading + /// its wallet's journal, or every wallet's when its wallet does not read + /// either; the store still opens. + #[test] + fn should_fail_reading_a_journal_whose_record_does_not_read() { + for (damage, other_wallet_reads) in [ + ( + "UPDATE shielded_private_payment_journal SET record = x'01'", + true, + ), + ( + "UPDATE shielded_private_payment_journal SET commitment = x'02'", + true, + ), + ( + "UPDATE shielded_private_payment_journal SET wallet_id = x'03'", + false, + ), + ] { + let path = temp_tree_path("journal_damage"); + let wallet_id = [0x6A; 32]; + let id = SubwalletId::new(wallet_id, 0); + let payment = PrivateContactPayment { + sender: [0xB2; 32], + recipient: [0xA2; 32], + account_reference: 1, + }; + { + let mut store = FileBackedShieldedStore::open_path(&path, 100).expect("open"); + store + .arm_private_contact_payment( + id, + PendingRedrive { + activity_id: [0x31; 32], + anchor: [0x32; 32], + nullifiers: vec![[0x33; 32]], + st_bytes: private_document_create_bytes(0xD4), + attempts: 0, + identity_nonce_finalized: false, + identity_user_abandoned: false, + }, + payment, + &[[0xD4; 32]], + ) + .expect("armed"); + store + .pending_conn + .lock() + .expect("pending_conn mutex") + .execute(damage, []) + .expect("damaged"); + } + let store = FileBackedShieldedStore::open_path(&path, 100).expect("still opens"); + assert!( + matches!( + store.private_payment_journal(wallet_id), + Err(FileShieldedStoreError::RecoveryCorrupted { .. }) + ), + "{damage}" + ); + assert_eq!( + store.private_payment_journal([0x6B; 32]).is_ok(), + other_wallet_reads, + "{damage}" + ); + let rows: i64 = store + .pending_conn + .lock() + .expect("pending_conn mutex") + .query_row( + "SELECT count(*) FROM shielded_private_payment_journal", + [], + |row| row.get(0), + ) + .expect("count"); + assert_eq!(rows, 1, "{damage}: the record stays"); + let _ = std::fs::remove_file(&path); + } + } + + /// A private document create (type 29) is a note spend: a recovery row + /// for one with damaged metadata is retained and skipped, its wallet + /// flagged so its legacy payments do not restore, and the shared store + /// still opens for every wallet. + #[test] + fn should_retain_and_skip_a_damaged_private_document_payment_row() { + let path = temp_tree_path("damaged_private_document_row"); + let wallet_id = [0x7A; 32]; + let id = SubwalletId::new(wallet_id, 0); + { + let mut store = FileBackedShieldedStore::open_path(&path, 100).expect("open"); + store + .arm_redrive( + id, + PendingRedrive { + activity_id: [0x41; 32], + anchor: [0x42; 32], + nullifiers: vec![[0x43; 32]], + st_bytes: private_document_create_bytes(0xE5), + attempts: 0, + identity_nonce_finalized: false, + identity_user_abandoned: false, + }, + ) + .expect("armed"); + store + .pending_conn + .lock() + .expect("pending_conn mutex") + .execute("UPDATE shielded_pending_spends SET anchor = x'01'", []) + .expect("damaged"); + } + let store = FileBackedShieldedStore::open_path(&path, 100).expect("still opens"); + assert!(store.pending_redrives(id).expect("records").is_empty()); + assert!(matches!( + store.private_contact_redrives_for_wallet(wallet_id), + Err(FileShieldedStoreError::RecoveryCorrupted { .. }) + )); + assert!(store + .private_contact_redrives_for_wallet([0x7B; 32]) + .is_ok()); + let _ = std::fs::remove_file(&path); + } } diff --git a/packages/rs-platform-wallet/src/wallet/shielded/mod.rs b/packages/rs-platform-wallet/src/wallet/shielded/mod.rs index e73271b4aa2..db547420497 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/mod.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/mod.rs @@ -71,7 +71,8 @@ pub use prover::CachedOrchardProver; pub use seed_pool::{SeedPoolOutcome, SeedPoolProgress, DEFAULT_SEED_POOL_TARGET_NOTES}; pub use store::{ IdentityDebitRecoveryRecord, IdentityDebitRecoveryStatus, InMemoryShieldedStore, - PrivateContactPayment, ShieldedNote, ShieldedOutgoingNote, ShieldedStore, SubwalletId, + PrivateContactPayment, PrivatePaymentRecord, ShieldedNote, ShieldedOutgoingNote, ShieldedStore, + SubwalletId, }; pub use sync::{ShieldedSyncSummary, SyncNotesResult}; diff --git a/packages/rs-platform-wallet/src/wallet/shielded/operations.rs b/packages/rs-platform-wallet/src/wallet/shielded/operations.rs index f4355390c56..5570d5ef8da 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/operations.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/operations.rs @@ -32,7 +32,8 @@ use super::store::{ PendingRedrive, PrivateContactPayment, ShieldedNote, ShieldedStore, SubwalletId, }; use crate::broadcast_outcome::{ - broadcast_definitely_failed, broadcast_may_have_been_delivered, carries_consensus_rejection, + broadcast_definitely_failed, broadcast_tracking_delivery, carries_consensus_rejection, + BroadcastDelivery, }; use crate::changeset::{PlatformWalletChangeSet, ShieldedChangeSet}; use crate::error::{preserve_signer_key_unavailable_or, PlatformWalletError}; @@ -1630,7 +1631,20 @@ pub(in crate::wallet) async fn private_document_create_from_pool_with_metadata< "PrivateDocumentCreateFromShieldedPool" ); - let pays_for_private_contact_request = private_contact.is_some(); + // The journal records of a private contact request payment, keyed by + // these commitments, are discharged here only when the transition never + // left the wallet. + let journaled = private_contact.map(|_| { + entries + .iter() + .filter_map(|entry| { + entry + .as_slice() + .get(..32) + .and_then(|commitment| <[u8; 32]>::try_from(commitment).ok()) + }) + .collect::>() + }); let mut pending_entry = None; let result = async { let (spends, anchor) = extract_spends_and_anchor(sdk, store, &selected_notes).await?; @@ -1705,7 +1719,7 @@ pub(in crate::wallet) async fn private_document_create_from_pool_with_metadata< // Type 29 proves its spent nullifiers as a snapshot, which a competing // spend of the same notes shares, as type 22's does: the affected-state // wait accepts it (see `identity_top_up_from_pool_with_metadata`). - broadcast_shielded_spend( + broadcast_shielded_spend_classified( sdk, &state_transition, "private document create from shielded pool", @@ -1723,7 +1737,8 @@ pub(in crate::wallet) async fn private_document_create_from_pool_with_metadata< &pending_entry, reservation, &selected_notes, - pays_for_private_contact_request, + journaled.as_deref(), + payment_started, result.map(|(_proof, metadata)| metadata), ) .await @@ -1734,16 +1749,15 @@ pub(in crate::wallet) async fn private_document_create_from_pool_with_metadata< /// [`private_document_create_from_pool_with_metadata`] does. /// /// A payment that executed marks its notes spent; one that may have executed -/// keeps them reserved and its saved record, which the redrive pass resolves; -/// one that did not execute releases them, and its record with them. A -/// payment for a private contact request (`pays_for_private_contact_request`) -/// whose broadcast may have been delivered -/// ([`PlatformWalletError::ShieldedBroadcastMaybeDelivered`]) is one that may -/// have executed: its record, with the request it pays for, is the only -/// evidence a restart restores the request's pair from, so it is kept until -/// the redrive pass proves the payment landed or never will, and the spend -/// reads as unconfirmed. A spend paying for nothing else still releases its -/// notes then, as the on-chain nullifier set keeps funds safe. +/// (unconfirmed) keeps them reserved and its saved record, which the redrive +/// pass resolves; one that did not execute releases them, and its record with +/// them. The private payment journal's records of a private contact request +/// payment (`journaled`, its entries' commitments) outlive all of that: they +/// are discharged here only when the transition never left the wallet +/// ([`SpendFailure::never_delivered`]), as nothing else can then execute it, +/// and `payment_started` tells the caller so +/// ([`PaymentStarted::note_never_delivered`]). Otherwise they stay until the +/// entry is read back. #[allow(clippy::too_many_arguments)] async fn settle_private_document_payment( store: &Arc>, @@ -1753,8 +1767,9 @@ async fn settle_private_document_payment( pending_entry: &Option, mut reservation: NoteReservation, selected_notes: &[ShieldedNote], - pays_for_private_contact_request: bool, - result: Result, + journaled: Option<&[[u8; 32]]>, + payment_started: &PaymentStarted, + result: Result, ) -> Result { match result { Ok(metadata) => { @@ -1785,23 +1800,18 @@ async fn settle_private_document_payment( ); Ok(metadata) } - Err(e @ PlatformWalletError::ShieldedSpendUnconfirmed { .. }) => { + Err(SpendFailure { + error: e @ PlatformWalletError::ShieldedSpendUnconfirmed { .. }, + .. + }) => { // It may have executed: the reservation stays, released by sync. reservation.hand_over(); Err(e) } - Err(PlatformWalletError::ShieldedBroadcastMaybeDelivered(reason)) - if pays_for_private_contact_request => - { - // It may have executed, and its saved record is the request's - // pair's only durable evidence: kept, as an unconfirmed spend's. - reservation.hand_over(); - Err(PlatformWalletError::ShieldedSpendUnconfirmed { - operation: "private document create from shielded pool", - reason, - }) - } - Err(e) => { + Err(SpendFailure { + error, + never_delivered, + }) => { // It did not execute: the guard owns the notes until they are // released, so a drop on the way still releases them. reservation.reclaim(); @@ -1815,10 +1825,40 @@ async fn settle_private_document_payment( None, ) .await; + if never_delivered { + if let Some(commitments) = journaled { + if discharge_journal_records(store, wallet_id, commitments).await { + payment_started.note_never_delivered(); + } + } + } reservation.cancel().await; - Err(e) + Err(error) + } + } +} + +/// Discharge `wallet_id`'s journal records of `commitments`, whose payment's +/// transition never left the wallet. Returns whether every one is gone; a +/// failure leaves the records, which over-holds their pairs. +async fn discharge_journal_records( + store: &Arc>, + wallet_id: WalletId, + commitments: &[[u8; 32]], +) -> bool { + let mut store = store.write().await; + let mut discharged = true; + for commitment in commitments { + if let Err(e) = store.discharge_private_payment(wallet_id, commitment) { + discharged = false; + warn!( + error = %e, + "a never-delivered private payment's journal record was not discharged; its \ + pair stays held" + ); } } + discharged } // ------------------------------------------------------------------------- @@ -2543,26 +2583,24 @@ where ) .map_err(|e| PlatformWalletError::ShieldedBuildError(e.to_string()))?; - // Broadcast (relay-ACK only). Definitive failures — a consensus-verdict CheckTx - // rejection, or a transport failure that proves nothing was delivered (see - // `broadcast_definitely_failed`; connect-refused/offline is the common case) — map to - // `ShieldedBroadcastFailed` and let the outer match release the reservation, exactly as - // before. No-verdict failures (`AlreadyExists` proves the transition IS already in the - // mempool or on chain after a lost-ACK retry; timeouts merely allow it) fall through to - // the result wait below, whose ambiguous arm already owns the fetch-by-derived-id - // fallback — so the in-flight tx gets confirmed (or held as unconfirmed) instead of - // being reported as failed. - match st.broadcast(sdk, None).await { - Ok(()) => {} - Err(e) if broadcast_definitely_failed(&e) => { + // Broadcast (relay-ACK only). Only a transition that never left the wallet + // (`BroadcastDelivery::NeverDelivered`: a consensus verdict with no dispatch that could + // have delivered it before, or nowhere to send it) maps to `ShieldedBroadcastFailed`, + // which lets the outer match release the reservation. Every other failure may have + // delivered it and falls through to the result wait below, whose ambiguous arm owns the + // fetch-by-derived-id fallback — so the in-flight tx gets confirmed (or held as + // unconfirmed) instead of being reported as failed. + match broadcast_tracking_delivery(sdk, &st).await { + BroadcastDelivery::Admitted => {} + BroadcastDelivery::NeverDelivered(e) => { return Err(PlatformWalletError::ShieldedBroadcastFailed(e.to_string())); } - Err(e) => { + BroadcastDelivery::MaybeDelivered(e) => { warn!( derived_id = %identity_id, error = %e, - "IdentityCreateFromShieldedPool: broadcast returned no verdict; the \ - transition may have been admitted — falling through to the result wait" + "IdentityCreateFromShieldedPool: broadcast may have been delivered — \ + falling through to the result wait" ); } } @@ -3415,11 +3453,14 @@ async fn broadcast_shielded_spend_with_redrive_with_metadata( /// the transition, before its result returns, leaves the record. The record /// is keyed by the spend's `activity_id` and carries its `nullifiers`, and /// the private contact request the spend pays for when it does. A spend -/// whose record cannot be saved is refused before anything is broadcast, as -/// the unknown outcome could then not be recovered. `payment_started`, when -/// given, is marked right after the record is saved, under the same store -/// lock: dropped while it waits for the lock, the spend leaves neither a -/// record nor a mark. +/// paying for one is a private document create (type 29), and the private +/// payment journal keeps a record of each entry it appends, read from the +/// transition, in the same durable write ([`ShieldedStore::arm_private_contact_payment`]). +/// A spend whose record cannot be saved, or whose entries do not read, is +/// refused before anything is broadcast, as the unknown outcome could then +/// not be recovered. `payment_started`, when given, is marked right after +/// the record is saved, under the same store lock: dropped while it waits +/// for the lock, the spend leaves neither a record nor a mark. #[allow(clippy::too_many_arguments)] pub(crate) async fn arm_redrive_before_broadcast( store: &Arc>, @@ -3446,6 +3487,18 @@ pub(crate) async fn arm_redrive_before_broadcast( broadcast: {e}" )) })?; + let journal = match private_contact { + Some(payment) => Some(( + payment, + private_document_commitments(state_transition).ok_or_else(|| { + PlatformWalletError::ShieldedBuildError(format!( + "{operation}: the entries this private contact request payment appends do \ + not read, so it is not broadcast" + )) + })?, + )), + None => None, + }; let redrive = PendingRedrive { activity_id, anchor, @@ -3456,8 +3509,10 @@ pub(crate) async fn arm_redrive_before_broadcast( identity_user_abandoned: false, }; let mut store = store.write().await; - match private_contact { - Some(payment) => store.arm_private_contact_redrive(id, redrive, payment), + match journal { + Some((payment, commitments)) => { + store.arm_private_contact_payment(id, redrive, payment, &commitments) + } None => store.arm_redrive(id, redrive), } .map_err(|e| { @@ -3472,6 +3527,58 @@ pub(crate) async fn arm_redrive_before_broadcast( Ok(()) } +/// Discharge `wallet_id`'s journal records of the private contact request +/// entries a private sync read back and recorded durably, as `persister` +/// noted them (`WalletPersister::note_read_back_private_entries`). One whose +/// discharge fails is noted again for the next call. +pub(crate) fn discharge_read_back_private_payments( + store: &mut S, + persister: &WalletPersister, + wallet_id: WalletId, +) { + let mut kept = Vec::new(); + for commitment in persister.take_read_back_private_entries() { + if let Err(e) = store.discharge_private_payment(wallet_id, &commitment) { + warn!( + error = %e, + "a read-back private payment's journal record was not discharged; retried later" + ); + kept.push(commitment); + } + } + if !kept.is_empty() { + persister.note_read_back_private_entries(kept); + } +} + +/// The commitments of the entries the private document create (type 29) +/// `state_transition` appends, each entry's first 32 bytes; `None` for +/// another transition, one appending no entry, or an entry shorter than its +/// commitment. +pub(crate) fn private_document_commitments( + state_transition: &StateTransition, +) -> Option> { + use dpp::state_transition::private_document_create_from_shielded_pool_transition::accessors::PrivateDocumentCreateFromShieldedPoolTransitionAccessorsV0; + + let StateTransition::PrivateDocumentCreateFromShieldedPool(transition) = state_transition + else { + return None; + }; + if transition.entries().is_empty() { + return None; + } + transition + .entries() + .iter() + .map(|entry| { + entry + .as_slice() + .get(..32) + .and_then(|commitment| <[u8; 32]>::try_from(commitment).ok()) + }) + .collect() +} + /// Persist the re-drivable record for an ambiguous spend. Best-effort: /// a failure here only demotes the resolution path to the anchor-prune /// backstop (plus restart-loss of the reservation), never fails the @@ -3555,9 +3662,9 @@ enum RedriveBroadcastOutcome { /// `NullifierAlreadySpent`: the ORIGINAL broadcast executed — a /// success signal, never a failure. AlreadyExecuted, - /// Any other consensus verdict: the transition can never execute. - DefinitiveRejection, - /// Transport noise / `AlreadyExists` / anything non-definitive. + /// Any other outcome, a consensus verdict included: the original's + /// broadcast was ambiguous, so a refusal of the re-broadcast does not + /// prove it can never execute. Inconclusive, } @@ -3565,7 +3672,6 @@ fn classify_redrive_broadcast(result: &Result<(), dash_sdk::Error>) -> RedriveBr match result { Ok(()) => RedriveBroadcastOutcome::Accepted, Err(e) if is_nullifier_already_spent(e) => RedriveBroadcastOutcome::AlreadyExecuted, - Err(e) if carries_consensus_rejection(e) => RedriveBroadcastOutcome::DefinitiveRejection, Err(_) => RedriveBroadcastOutcome::Inconclusive, } } @@ -3716,11 +3822,16 @@ pub(super) async fn redrive_pending_identity_shields( /// - accepted / inconclusive → count the attempt; wait for the next scan; /// - `NullifierAlreadySpent` → the original executed; the next scan /// confirms — touch nothing; -/// - any other consensus verdict → provably dead NOW: release the -/// reservation and flip the activity row to Failed, hours before the -/// prune backstop would; +/// - any other consensus verdict → inconclusive too: a node refusing the +/// re-broadcast does not show that no other node holds the original, +/// whose broadcast was ambiguous, so the notes stay reserved; /// - pruned anchor / exhausted attempts → leave it to the -/// prune-backstop release pass. +/// prune-backstop release pass, which releases the notes once the +/// anchor is pruned and the spend can never execute. +/// +/// A record whose transition does not decode is kept, never dropped: it +/// may be the only evidence of a payment that can still execute. Its notes +/// stay reserved until the spend lands or the prune backstop releases them. /// /// Runs after the scan's spent-note reconcile (a landed spend's record /// was already dropped by the `mark_spent` hook, so anything still @@ -3728,8 +3839,6 @@ pub(super) async fn redrive_pending_identity_shields( pub(super) async fn redrive_pending_spends( sdk: &Arc, store: &Arc>, - persister: Option<&WalletPersister>, - wallet_id: WalletId, id: SubwalletId, recorded: &std::collections::HashSet<[u8; 32]>, cancel: &CancellationToken, @@ -3769,12 +3878,9 @@ pub(super) async fn redrive_pending_spends( Err(e) => { warn!( error = %e, - "redrive: stored transition failed to deserialize; dropping the record \ - (the prune backstop still frees the notes)" + "redrive: stored transition failed to deserialize; keeping the record \ + without re-broadcasting it (the prune backstop still frees the notes)" ); - if let Err(e) = store.write().await.clear_redrive(id, &redrive.activity_id) { - warn!(error = %e, "redrive: clear_redrive failed"); - } continue; } }; @@ -3807,33 +3913,6 @@ pub(super) async fn redrive_pending_spends( the notes spent" ); } - RedriveBroadcastOutcome::DefinitiveRejection => { - warn!( - error = %err_display, - "redrive: definitive consensus rejection; the spend can never execute — \ - releasing the reservation" - ); - note_rejected_private_documents(&st, persister); - { - let mut guard = store.write().await; - for n in &redrive.nullifiers { - // Also drops the redrive record via the - // clear_pending hook. - if let Err(e) = guard.clear_pending(id, n) { - warn!(error = %e, "redrive: clear_pending failed"); - } - } - } - record_activity_status_by_id( - store, - persister, - wallet_id, - id, - &redrive.activity_id, - ShieldedActivityStatus::Failed, - ) - .await; - } RedriveBroadcastOutcome::Inconclusive => { // `AlreadyExists` (still in a mempool after a lost-ACK // retry) or transport noise: inconclusive; counts toward @@ -3850,30 +3929,6 @@ pub(super) async fn redrive_pending_spends( } } -/// Note on `persister` the commitments of the private document entries the -/// transition `st` appends, when it is one (type 29): its payment was settled -/// as never executing, so the wallet releases the private contact request -/// reservations held for those entries. A commitment matching no -/// reservation is passed over. -pub(crate) fn note_rejected_private_documents( - st: &StateTransition, - persister: Option<&WalletPersister>, -) { - use dpp::state_transition::private_document_create_from_shielded_pool_transition::accessors::PrivateDocumentCreateFromShieldedPoolTransitionAccessorsV0; - - let (Some(persister), StateTransition::PrivateDocumentCreateFromShieldedPool(transition)) = - (persister, st) - else { - return; - }; - persister.note_rejected_private_entries(transition.entries().iter().filter_map(|entry| { - entry - .as_slice() - .get(..32) - .and_then(|commitment| commitment.try_into().ok()) - })); -} - /// Broadcast a built shielded spend transition (unshield / transfer / /// withdraw) and wait for proven execution, staging the two SDK calls /// separately so the caller's reservation rollback only runs when the @@ -3922,17 +3977,52 @@ async fn broadcast_shielded_spend( operation: &'static str, wait: SpendResultWait, ) -> Result<(StateTransitionProofResult, ResponseMetadata), PlatformWalletError> { - match state_transition.broadcast(sdk, None).await { - Ok(()) => {} - Err(e) if broadcast_definitely_failed(&e) => { - return Err(definite_broadcast_failure(&e)); + broadcast_shielded_spend_classified(sdk, state_transition, operation, wait) + .await + .map_err(|failure| failure.error) +} + +/// A failed [`broadcast_shielded_spend_classified`]: the error it reports, and +/// whether the transition never left the wallet. +struct SpendFailure { + error: PlatformWalletError, + /// The broadcast proved nothing was delivered + /// ([`BroadcastDelivery::NeverDelivered`]): nothing holds the transition's + /// bytes but the wallet, so it can never execute. + never_delivered: bool, +} + +impl From for SpendFailure { + fn from(error: PlatformWalletError) -> Self { + Self { + error, + never_delivered: false, } - Err(e) => { + } +} + +/// [`broadcast_shielded_spend`], telling a failure whose transition never +/// left the wallet from one that may have reached the network. +async fn broadcast_shielded_spend_classified( + sdk: &Arc, + state_transition: &StateTransition, + operation: &'static str, + wait: SpendResultWait, +) -> Result<(StateTransitionProofResult, ResponseMetadata), SpendFailure> { + match broadcast_tracking_delivery(sdk, state_transition).await { + BroadcastDelivery::Admitted => {} + BroadcastDelivery::NeverDelivered(e) => { + return Err(SpendFailure { + error: definite_broadcast_failure(&e), + never_delivered: true, + }); + } + BroadcastDelivery::MaybeDelivered(e) => { warn!( operation, error = %e, - "Shielded spend broadcast returned no verdict; the transition may have been \ - admitted — falling through to the result wait" + "Shielded spend broadcast may have been delivered; falling through to the \ + result wait" ); } } @@ -3953,23 +4043,19 @@ async fn broadcast_shielded_spend( .await } }; - waited.map_err(|wait_err| classify_spend_wait_failure(operation, &wait_err)) + waited + .map_err(|wait_err| classify_spend_wait_failure(operation, &wait_err)) + .map_err(SpendFailure::from) } -/// The error a spend's `broadcast()` failure that [`broadcast_definitely_failed`] -/// accepts is reported as: a promoted address-nonce error, or a failed -/// broadcast, kept apart when it may still have been delivered -/// ([`broadcast_may_have_been_delivered`]) for what the spend pays for. Its -/// notes are released either way, unless it pays for a private contact -/// request ([`settle_private_document_payment`]). +/// The error a spend whose transition never left the wallet +/// ([`BroadcastDelivery::NeverDelivered`]) is reported as: a promoted +/// address-nonce error, or a failed broadcast. Its notes are released. Every +/// broadcast that may have been delivered waits for its result instead, and +/// reads as unconfirmed when that wait fails. fn definite_broadcast_failure(e: &dash_sdk::Error) -> PlatformWalletError { - crate::error::promote_address_nonce_error(e).unwrap_or_else(|| { - if broadcast_may_have_been_delivered(e) { - PlatformWalletError::ShieldedBroadcastMaybeDelivered(e.to_string()) - } else { - PlatformWalletError::ShieldedBroadcastFailed(e.to_string()) - } - }) + crate::error::promote_address_nonce_error(e) + .unwrap_or_else(|| PlatformWalletError::ShieldedBroadcastFailed(e.to_string())) } /// Classify a `wait_for_response` failure for an already-broadcast @@ -4160,9 +4246,6 @@ mod redrive_tests { use crate::wallet::identity::network::PaymentStarted; use crate::wallet::shielded::store::InMemoryShieldedStore; use crate::wallet::shielded::FileBackedShieldedStore; - use dash_sdk::dapi_client::transport::TransportError; - use dash_sdk::dapi_client::DapiClientError; - use dash_sdk::dapi_grpc::tonic::{Code, Status}; use dash_sdk::dpp::dashcore::Network; use dash_sdk::dpp::prelude::CoreBlockHeight; use dash_sdk::error::ContextProviderError; @@ -4442,7 +4525,8 @@ mod redrive_tests { }); assert_eq!( classify_redrive_broadcast(&Err(rejection_err)), - RedriveBroadcastOutcome::DefinitiveRejection + RedriveBroadcastOutcome::Inconclusive, + "a refused re-broadcast does not prove the original never executes" ); let timeout = dash_sdk::Error::TimeoutReached( @@ -4499,23 +4583,18 @@ mod redrive_tests { ); } - /// A private contact request's payment whose broadcast may have been - /// delivered, an `Unavailable` answer the spend's classifier reports so, - /// is settled as an unconfirmed one: its notes stay reserved, and its saved - /// record with the request it pays for survives a reopen of the store, so - /// the redrive pass resolves it and a restart restores the request's pair - /// from it. A private document payment paying for nothing else releases its - /// notes, and its record with them, as before. + /// A private contact request payment's journal record outlives its notes' + /// and redrive record whatever the spend's outcome, except a transition + /// that provably never left the wallet. Settled unconfirmed (its + /// broadcast may have been delivered and the result wait failed), the + /// notes stay reserved and the record, association and journal record + /// survive a reopen. Rejected after delivery, the notes and record are + /// released, the journal record stays. Never delivered, the journal + /// record is discharged too and the payment reads as not reaching the + /// network. #[tokio::test] - async fn should_keep_a_private_contact_payment_s_record_when_its_broadcast_may_have_been_delivered( - ) { + async fn should_keep_a_private_payment_s_journal_record_unless_it_never_left() { let sdk = Arc::new(dash_sdk::Sdk::new_mock()); - let unavailable = dash_sdk::Error::DapiClientError(DapiClientError::Transport( - TransportError::Grpc(Status::new( - Code::Unavailable, - "the stream was reset after the request was sent", - )), - )); let action = SerializedAction { nullifier: [1; 32], rk: [2; 32], @@ -4524,10 +4603,12 @@ mod redrive_tests { cv_net: [5; 32], spend_auth_sig: [6; 64], }; + let entry = BinaryData::new(vec![9; 589]); + let commitment = [9u8; 32]; let transition = PrivateDocumentCreateFromShieldedPoolTransition::try_from_bundle( Identifier::from([0x44; 32]), "privateContactRequest".to_string(), - vec![BinaryData::new(vec![9; 589])], + vec![entry], vec![action.clone(), action], 0, [7; 32], @@ -4543,7 +4624,28 @@ mod redrive_tests { }; let wallet_id = [0x67; 32]; let id = SubwalletId::new(wallet_id, 0); - for pays_for_private_contact_request in [true, false] { + let failure = |outcome: &str| match outcome { + "unconfirmed" => SpendFailure { + error: PlatformWalletError::ShieldedSpendUnconfirmed { + operation: "private document create from shielded pool", + reason: "the result wait timed out".to_string(), + }, + never_delivered: false, + }, + "rejected after delivery" => SpendFailure { + error: PlatformWalletError::ShieldedBroadcastFailed( + "rejected on its merits".to_string(), + ), + never_delivered: false, + }, + _ => SpendFailure { + error: PlatformWalletError::ShieldedBroadcastFailed( + "refused at the door".to_string(), + ), + never_delivered: true, + }, + }; + for outcome in ["unconfirmed", "rejected after delivery", "never delivered"] { let dir = tempfile::tempdir().expect("a directory"); let path = dir.path().join("tree.sqlite"); let store = Arc::new(RwLock::new( @@ -4572,11 +4674,21 @@ mod redrive_tests { selected.iter().map(|note| note.nullifier).collect(), &transition, "private document create from shielded pool", - pays_for_private_contact_request.then_some(payment), + Some(payment), Some(&started), ) .await .expect("saved before the broadcast"); + assert_eq!( + store + .read() + .await + .private_payment_journal(wallet_id) + .expect("journal") + .len(), + 1, + "{outcome}: journaled before the broadcast" + ); let settled = settle_private_document_payment( &store, @@ -4586,48 +4698,47 @@ mod redrive_tests { &None, reservation, &selected, - pays_for_private_contact_request, - Err(definite_broadcast_failure(&unavailable)), + Some(&[commitment]), + &started, + Err(failure(outcome)), ) .await; + assert!(settled.is_err(), "{outcome}: {settled:?}"); let unspent = store .read() .await .get_unspent_notes(id) .expect("notes") .len(); - if pays_for_private_contact_request { - assert!( - matches!( - settled, - Err(PlatformWalletError::ShieldedSpendUnconfirmed { .. }) - ), - "{settled:?}" - ); - assert_eq!(unspent, 0, "the notes stay reserved"); - drop(store); - let reopened = FileBackedShieldedStore::open_path(&path, 100).expect("reopened"); - let saved = reopened - .private_contact_redrives_for_wallet(wallet_id) - .expect("listed"); - assert_eq!(saved.len(), 1, "the record and its association stay"); - assert_eq!(saved[0].1, payment); - assert!(reopened.get_unspent_notes(id).expect("notes").is_empty()); - } else { - assert!( - matches!( - settled, - Err(PlatformWalletError::ShieldedBroadcastMaybeDelivered(_)) - ), - "{settled:?}" - ); - assert_eq!(unspent, 1, "the notes are released"); - assert!(store - .read() - .await - .pending_redrives(id) - .expect("records") - .is_empty()); + drop(store); + let reopened = FileBackedShieldedStore::open_path(&path, 100).expect("reopened"); + let journal = reopened + .private_payment_journal(wallet_id) + .expect("journal"); + let saved = reopened + .private_contact_redrives_for_wallet(wallet_id) + .expect("listed"); + match outcome { + "unconfirmed" => { + assert_eq!(unspent, 0, "the notes stay reserved"); + assert_eq!(saved.len(), 1, "the record and its association stay"); + assert_eq!(journal.len(), 1, "the journal record stays"); + assert!(started.may_have_reached_network()); + } + "rejected after delivery" => { + assert_eq!(unspent, 1, "the notes are released"); + assert!(saved.is_empty(), "the record goes with them"); + assert_eq!(journal.len(), 1, "the journal record stays"); + assert_eq!(journal[0].commitment, commitment); + assert_eq!(journal[0].payment, payment); + assert!(started.may_have_reached_network()); + } + _ => { + assert_eq!(unspent, 1, "the notes are released"); + assert!(saved.is_empty(), "the record goes with them"); + assert!(journal.is_empty(), "the journal record is discharged"); + assert!(!started.may_have_reached_network()); + } } } } @@ -5030,16 +5141,7 @@ mod redrive_tests { .expect("arm identity guard"); let recorded = [anchor].into_iter().collect(); - redrive_pending_spends( - &sdk, - &store, - None, - wallet_id, - id, - &recorded, - &CancellationToken::new(), - ) - .await; + redrive_pending_spends(&sdk, &store, id, &recorded, &CancellationToken::new()).await; let retained = store.read().await.pending_redrives(id).expect("guard"); assert_eq!(retained.len(), 1); @@ -5053,9 +5155,10 @@ mod redrive_tests { /// no broadcast expectation, so any attempt would error into the /// inconclusive arm and bump the counter): a pruned anchor belongs /// to the release backstop, an exhausted budget stays parked, and a - /// corrupt stored transition is dropped so it can't wedge the pass. + /// corrupt stored transition is kept without a broadcast, as it may be + /// the only evidence of a payment that can still execute. #[tokio::test] - async fn redrive_skips_pruned_and_exhausted_and_drops_garbage() { + async fn should_skip_pruned_exhausted_and_undecodable_redrives_keeping_each() { let sdk = Arc::new(dash_sdk::SdkBuilder::new_mock().build().expect("mock sdk")); let store = Arc::new(RwLock::new(InMemoryShieldedStore::new())); let wallet_id = [5u8; 32]; @@ -5078,31 +5181,22 @@ mod redrive_tests { guard .arm_redrive(id, rec(2, 11, MAX_REDRIVE_ATTEMPTS)) .unwrap(); - // Recorded anchor, budget left, garbage bytes → dropped - // before any broadcast. + // Recorded anchor, budget left, garbage bytes → kept, with no + // broadcast. guard.arm_redrive(id, rec(3, 12, 0)).unwrap(); } let recorded: std::collections::HashSet<[u8; 32]> = [[11u8; 32], [12u8; 32]].into_iter().collect(); - redrive_pending_spends( - &sdk, - &store, - None, - wallet_id, - id, - &recorded, - &CancellationToken::new(), - ) - .await; + redrive_pending_spends(&sdk, &store, id, &recorded, &CancellationToken::new()).await; let left = store.read().await.pending_redrives(id).unwrap(); let ids: Vec<[u8; 32]> = left.iter().map(|r| r.activity_id).collect(); assert!(ids.contains(&[1u8; 32]), "pruned-anchor record left alone"); assert!(ids.contains(&[2u8; 32]), "exhausted record left alone"); assert!( - !ids.contains(&[3u8; 32]), - "corrupt record dropped without a broadcast attempt" + ids.contains(&[3u8; 32]), + "corrupt record kept without a broadcast attempt" ); assert_eq!( left.iter().map(|r| r.attempts).max(), @@ -5229,31 +5323,6 @@ mod classify_spend_wait_failure_tests { ))); } - /// Of the failures counted as definite, only `Unavailable` may still have - /// been delivered: a consensus verdict, a rejection code and an empty - /// address list prove the transition was not admitted. The notes are - /// released for all of them alike. - #[test] - fn should_tell_a_broadcast_that_may_have_been_delivered_from_a_refused_one() { - use dash_sdk::dapi_client::DapiClientError; - use dash_sdk::dapi_grpc::tonic::Code; - assert!(broadcast_may_have_been_delivered(&grpc_err( - Code::Unavailable - ))); - assert!(broadcast_may_have_been_delivered( - &dash_sdk::Error::NoAvailableAddressesToRetry(Box::new(grpc_err(Code::Unavailable))) - )); - for refused in [ - grpc_err(Code::InvalidArgument), - grpc_err(Code::ResourceExhausted), - consensus_metadata_rejection(), - dash_sdk::Error::DapiClientError(DapiClientError::NoAvailableAddresses), - ] { - assert!(broadcast_definitely_failed(&refused), "{refused:?}"); - assert!(!broadcast_may_have_been_delivered(&refused), "{refused:?}"); - } - } - /// No-response shapes of the `broadcast()` call itself are NOT /// definitive: the dapi-client retries across addresses, so the request /// may have been delivered to a node whose ACK was lost. The caller diff --git a/packages/rs-platform-wallet/src/wallet/shielded/store.rs b/packages/rs-platform-wallet/src/wallet/shielded/store.rs index e2a625cc4d5..cab51b2d615 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/store.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/store.rs @@ -212,6 +212,94 @@ impl PrivateContactPayment { } } +/// One private contact request payment the wallet may have broadcast, as the +/// shielded store's private payment journal keeps it: the request it pays +/// for, the commitment of the entry it appends and the nullifiers its spend +/// consumes. Saved durably before the payment can reach the network +/// ([`ShieldedStore::arm_private_contact_payment`]) and kept until the +/// payment provably resolved ([`ShieldedStore::discharge_private_payment`]), +/// whatever happens to the spend's notes and redrive record: the pair it +/// holds is restored from it after a restart. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PrivatePaymentRecord { + /// The account whose notes pay for it. + pub account_index: u32, + /// The spend's activity id, which keys its redrive record. + pub activity_id: [u8; 32], + /// The commitment of the entry it appends, which keys the record. + pub commitment: [u8; 32], + /// The request it pays for. + pub payment: PrivateContactPayment, + /// The nullifiers of the notes its spend consumes; never empty. + pub nullifiers: Vec<[u8; 32]>, +} + +impl PrivatePaymentRecord { + /// The format version [`Self::to_bytes`] writes. + const FORMAT_VERSION: u8 = 1; + /// The bytes before the nullifiers: version, account index, activity id, + /// commitment, sender, recipient, account reference and nullifier count. + const HEADER_LEN: usize = 1 + 4 + 32 + 32 + 32 + 32 + 4 + 2; + + /// The bytes the file store keeps it as: [`Self::FORMAT_VERSION`], the + /// account index little-endian, the activity id, the commitment, the + /// sender, the recipient, the account reference little-endian, the + /// nullifier count as a little-endian `u16`, then the nullifiers. `None` + /// when it has no nullifier or more than a `u16` counts. + pub(super) fn to_bytes(&self) -> Option> { + let count = u16::try_from(self.nullifiers.len()).ok()?; + if count == 0 { + return None; + } + let mut bytes = Vec::with_capacity(Self::HEADER_LEN + 32 * self.nullifiers.len()); + bytes.push(Self::FORMAT_VERSION); + bytes.extend_from_slice(&self.account_index.to_le_bytes()); + bytes.extend_from_slice(&self.activity_id); + bytes.extend_from_slice(&self.commitment); + bytes.extend_from_slice(&self.payment.sender); + bytes.extend_from_slice(&self.payment.recipient); + bytes.extend_from_slice(&self.payment.account_reference.to_le_bytes()); + bytes.extend_from_slice(&count.to_le_bytes()); + for nullifier in &self.nullifiers { + bytes.extend_from_slice(nullifier); + } + Some(bytes) + } + + /// The record [`Self::to_bytes`] wrote, or `None` for any other bytes: a + /// different version, a length that does not match the count, or no + /// nullifier. + pub(super) fn from_bytes(bytes: &[u8]) -> Option { + let (&version, rest) = bytes.split_first()?; + if version != Self::FORMAT_VERSION { + return None; + } + let (account_index, rest) = rest.split_first_chunk::<4>()?; + let (activity_id, rest) = rest.split_first_chunk::<32>()?; + let (commitment, rest) = rest.split_first_chunk::<32>()?; + let (sender, rest) = rest.split_first_chunk::<32>()?; + let (recipient, rest) = rest.split_first_chunk::<32>()?; + let (account_reference, rest) = rest.split_first_chunk::<4>()?; + let (count, rest) = rest.split_first_chunk::<2>()?; + let count = usize::from(u16::from_le_bytes(*count)); + let (nullifiers, remainder) = rest.as_chunks::<32>(); + if count == 0 || nullifiers.len() != count || !remainder.is_empty() { + return None; + } + Some(Self { + account_index: u32::from_le_bytes(*account_index), + activity_id: *activity_id, + commitment: *commitment, + payment: PrivateContactPayment { + sender: *sender, + recipient: *recipient, + account_reference: u32::from_le_bytes(*account_reference), + }, + nullifiers: nullifiers.to_vec(), + }) + } +} + /// What the wallet knows about an identity debit's local recovery state. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum IdentityDebitRecoveryStatus { @@ -347,25 +435,69 @@ pub trait ShieldedStore: Send + Sync + 'static { fn arm_redrive(&mut self, id: SubwalletId, redrive: PendingRedrive) -> Result<(), Self::Error>; /// [`Self::arm_redrive`] for a spend paying for the DashPay private - /// contact request `payment`, kept with the record and dropped with it. - /// A store that keeps nothing across restarts keeps no association. - fn arm_private_contact_redrive( + /// contact request `payment`, which is kept with the record and dropped + /// with it, and the private payment journal's record of each entry the + /// spend appends (`commitments`), which outlives it + /// ([`PrivatePaymentRecord`]). All of it is saved in one write that is + /// durable once this returns, across a crash or power loss too: the + /// spend is broadcast only after it. A store that cannot keep all of it + /// durably fails, and the payment is refused. + fn arm_private_contact_payment( &mut self, id: SubwalletId, redrive: PendingRedrive, - _payment: PrivateContactPayment, - ) -> Result<(), Self::Error> { - self.arm_redrive(id, redrive) - } + payment: PrivateContactPayment, + commitments: &[[u8; 32]], + ) -> Result<(), Self::Error>; /// The armed records of `wallet_id` that pay for a private contact - /// request, each with the request ([`Self::arm_private_contact_redrive`]). + /// request, each with the request ([`Self::arm_private_contact_payment`]). + /// Fails when one of them carries an association that does not read. fn private_contact_redrives_for_wallet( &self, - _wallet_id: WalletId, - ) -> Result, Self::Error> { - Ok(Vec::new()) - } + wallet_id: WalletId, + ) -> Result, Self::Error>; + + // ── Private payment journal (per-wallet) ─────────────────────────── + + /// Whether `wallet_id`'s private payment journal is open + /// ([`Self::open_private_payment_journal`]): then it is authoritative for + /// the wallet's private payments, so one marked unresolved with no record + /// in it has resolved or was never broadcast. A journal not open yet, as + /// in a store that was deleted or replaced, cannot tell. + fn private_payment_journal_open(&self, wallet_id: WalletId) -> Result; + + /// Open `wallet_id`'s private payment journal, durably: called once a + /// restoration of the wallet's private payments from it succeeded, before + /// the wallet can pay. Saving a journal record opens it too. + fn open_private_payment_journal(&mut self, wallet_id: WalletId) -> Result<(), Self::Error>; + + /// Every journal record of `wallet_id`'s private payments. Fails when one + /// of the wallet's records, or one whose wallet does not read, does not + /// read back. + fn private_payment_journal( + &self, + wallet_id: WalletId, + ) -> Result, Self::Error>; + + /// Add `records` to `wallet_id`'s journal durably, for payments whose + /// redrive record was saved before the journal kept them. + fn journal_private_payments( + &mut self, + wallet_id: WalletId, + records: &[PrivatePaymentRecord], + ) -> Result<(), Self::Error>; + + /// Remove `wallet_id`'s journal record of the entry `commitment`, once + /// its payment provably resolved: the entry was read back, or the + /// transition never left the wallet. Returns whether there was one. No + /// other call removes a journal record: not a note release, a redrive's + /// removal or a purge. + fn discharge_private_payment( + &mut self, + wallet_id: WalletId, + commitment: &[u8; 32], + ) -> Result; /// Every armed redrive record for `id`. fn pending_redrives(&self, id: SubwalletId) -> Result, Self::Error>; @@ -919,6 +1051,13 @@ pub struct InMemoryShieldedStore { checkpoints: Vec, /// Placeholder anchor; production stores compute the real Sinsemilla root. anchor: [u8; 32], + /// The private contact request each armed record pays for, by subwallet + /// and activity id, read joined with the live records. + private_contact_payments: BTreeMap<(SubwalletId, [u8; 32]), PrivateContactPayment>, + /// The private payment journal's records, by wallet and commitment. + private_payment_journal: BTreeMap<(WalletId, [u8; 32]), PrivatePaymentRecord>, + /// The wallets whose private payment journal is open. + private_payment_journals: BTreeSet, } impl InMemoryShieldedStore { @@ -1012,10 +1151,111 @@ impl ShieldedStore for InMemoryShieldedStore { } fn arm_redrive(&mut self, id: SubwalletId, redrive: PendingRedrive) -> Result<(), Self::Error> { + self.private_contact_payments + .remove(&(id, redrive.activity_id)); self.subwallets.entry(id).or_default().arm_redrive(redrive); Ok(()) } + fn arm_private_contact_payment( + &mut self, + id: SubwalletId, + redrive: PendingRedrive, + payment: PrivateContactPayment, + commitments: &[[u8; 32]], + ) -> Result<(), Self::Error> { + if redrive.nullifiers.is_empty() { + return Err(InMemoryStoreError( + "a private payment's record spends no note".to_string(), + )); + } + if !commitments.is_empty() { + self.private_payment_journals.insert(id.wallet_id); + } + for commitment in commitments { + self.private_payment_journal.insert( + (id.wallet_id, *commitment), + PrivatePaymentRecord { + account_index: id.account_index, + activity_id: redrive.activity_id, + commitment: *commitment, + payment, + nullifiers: redrive.nullifiers.clone(), + }, + ); + } + self.private_contact_payments + .insert((id, redrive.activity_id), payment); + self.subwallets.entry(id).or_default().arm_redrive(redrive); + Ok(()) + } + + fn private_contact_redrives_for_wallet( + &self, + wallet_id: WalletId, + ) -> Result, Self::Error> { + Ok(self + .pending_redrives_for_wallet(wallet_id)? + .into_iter() + .filter_map(|(id, redrive)| { + self.private_contact_payments + .get(&(id, redrive.activity_id)) + .map(|payment| (redrive, *payment)) + }) + .collect()) + } + + fn private_payment_journal_open(&self, wallet_id: WalletId) -> Result { + Ok(self.private_payment_journals.contains(&wallet_id)) + } + + fn open_private_payment_journal(&mut self, wallet_id: WalletId) -> Result<(), Self::Error> { + self.private_payment_journals.insert(wallet_id); + Ok(()) + } + + fn private_payment_journal( + &self, + wallet_id: WalletId, + ) -> Result, Self::Error> { + Ok(self + .private_payment_journal + .range((wallet_id, [0u8; 32])..=(wallet_id, [u8::MAX; 32])) + .map(|(_, record)| record.clone()) + .collect()) + } + + fn journal_private_payments( + &mut self, + wallet_id: WalletId, + records: &[PrivatePaymentRecord], + ) -> Result<(), Self::Error> { + if records.iter().any(|record| record.nullifiers.is_empty()) { + return Err(InMemoryStoreError( + "a private payment's record spends no note".to_string(), + )); + } + if !records.is_empty() { + self.private_payment_journals.insert(wallet_id); + } + for record in records { + self.private_payment_journal + .insert((wallet_id, record.commitment), record.clone()); + } + Ok(()) + } + + fn discharge_private_payment( + &mut self, + wallet_id: WalletId, + commitment: &[u8; 32], + ) -> Result { + Ok(self + .private_payment_journal + .remove(&(wallet_id, *commitment)) + .is_some()) + } + fn pending_redrives(&self, id: SubwalletId) -> Result, Self::Error> { Ok(self .subwallets @@ -1178,7 +1418,11 @@ impl ShieldedStore for InMemoryShieldedStore { } fn purge_wallet(&mut self, wallet_id: WalletId) -> Result<(), Self::Error> { + // The private payment journal outlives the purge: the payments it + // keeps may still execute. self.subwallets.retain(|id, _| id.wallet_id != wallet_id); + self.private_contact_payments + .retain(|(id, _), _| id.wallet_id != wallet_id); Ok(()) } @@ -1200,6 +1444,7 @@ impl ShieldedStore for InMemoryShieldedStore { fn purge_all_subwallets(&mut self) -> Result<(), Self::Error> { self.subwallets.clear(); + self.private_contact_payments.clear(); Ok(()) } From 1fb46dd932121c9d60334765fc32e3687ae1f8b0 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 9 Nov 2026 19:48:43 +0700 Subject: [PATCH 097/117] fix(platform-wallet-ffi): report a broadcast that may have been delivered as unconfirmed ShieldedBroadcastMaybeDelivered mapped to ErrorShieldedBroadcastFailed (16), through the blanket conversion and the spend contract alike, so a host could read a spend that may still execute as failed and pay again with other notes. It now maps to ErrorShieldedSpendUnconfirmed (18) both ways, and the docs of codes 16 and 18 say which outcomes each covers. Co-Authored-By: Claude Opus 5.5 --- packages/rs-platform-wallet-ffi/src/error.rs | 43 ++++++++++++------- .../src/shielded_send.rs | 25 +++++------ 2 files changed, 40 insertions(+), 28 deletions(-) diff --git a/packages/rs-platform-wallet-ffi/src/error.rs b/packages/rs-platform-wallet-ffi/src/error.rs index 6ca6abf3455..002f7d475ce 100644 --- a/packages/rs-platform-wallet-ffi/src/error.rs +++ b/packages/rs-platform-wallet-ffi/src/error.rs @@ -136,9 +136,12 @@ pub enum PlatformWalletFFIResultCode { ErrorWalletAlreadyExists = 15, /// Maps `PlatformWalletError::ShieldedBroadcastFailed`. The shielded /// transition (identity-create, unshield, transfer, or withdrawal) was - /// DEFINITIVELY not executed — either the relay/CheckTx rejected the - /// broadcast, or Platform reported the transition's own execution error. - /// Any note reservations were released and the caller is free to retry. + /// DEFINITIVELY not executed — either it never left the wallet (CheckTx + /// refused it with no earlier attempt that could have delivered it, or + /// there was nowhere to send it), or Platform reported the transition's + /// own execution error. A broadcast that may have been delivered is never + /// reported here: it is [`Self::ErrorShieldedSpendUnconfirmed`]. Any note + /// reservations were released and the caller is free to retry. /// For identity-create, the new identity does NOT exist and /// `out_identity_id` is left untouched (still zeroed). ErrorShieldedBroadcastFailed = 16, @@ -152,14 +155,18 @@ pub enum PlatformWalletFFIResultCode { /// the caller can hold the slot and surface the pending identity. ErrorShieldedBroadcastUnconfirmed = 17, /// Maps `PlatformWalletError::ShieldedSpendUnconfirmed` (unshield / - /// shielded transfer / shielded withdrawal). The spend transition was - /// ACCEPTED by the relay but its execution result could not be confirmed - /// (DAPI wait timeout, result-proof fetch/verify failure, …). The spend - /// may have executed on chain, so the wallet intentionally KEEPS the - /// notes reserved: the next nullifier sync promotes them to spent if the - /// spend landed, and an app restart frees them if it never did. The host - /// must NOT auto-retry — a retry would select different unreserved notes - /// and could double-send if the original spend landed. + /// shielded transfer / shielded withdrawal / identity top-up and create / + /// private document create from the pool), and + /// `PlatformWalletError::ShieldedBroadcastMaybeDelivered`. The spend + /// transition was accepted by the relay, or may have been delivered, but + /// its execution result could not be confirmed (a broadcast attempt that + /// failed in transport, a DAPI wait timeout, a result-proof fetch/verify + /// failure, …). The spend may have executed on chain, so the wallet + /// intentionally KEEPS the notes reserved and its recovery record: the + /// next nullifier sync promotes them to spent if the spend landed, and the + /// redrive and anchor-prune passes release them once it provably never + /// can. The host must NOT auto-retry — a retry would select different + /// unreserved notes and could double-send if the original spend landed. ErrorShieldedSpendUnconfirmed = 18, /// Maps `PlatformWalletError::ShieldedNoRecordedAnchor`. The wallet could /// not build the spend against any Platform-recorded anchor yet: its local @@ -974,14 +981,17 @@ impl From for PlatformWalletFFIResult { // slot-holding contract. A blanket conversion can't write // `out_identity_id` (it has no out-param), so the most it can do is // keep the typed code alive — which is what these arms guarantee. - PlatformWalletError::ShieldedBroadcastFailed(..) - | PlatformWalletError::ShieldedBroadcastMaybeDelivered(..) => { + PlatformWalletError::ShieldedBroadcastFailed(..) => { PlatformWalletFFIResultCode::ErrorShieldedBroadcastFailed } PlatformWalletError::ShieldedBroadcastUnconfirmed { .. } => { PlatformWalletFFIResultCode::ErrorShieldedBroadcastUnconfirmed } - PlatformWalletError::ShieldedSpendUnconfirmed { .. } => { + // A broadcast that may have been delivered may still execute: it + // reads as unconfirmed, never as failed, so a host does not pay + // again with other notes. + PlatformWalletError::ShieldedSpendUnconfirmed { .. } + | PlatformWalletError::ShieldedBroadcastMaybeDelivered(..) => { PlatformWalletFFIResultCode::ErrorShieldedSpendUnconfirmed } PlatformWalletError::ShieldedIdentityDebitPending { .. } => { @@ -2892,12 +2902,13 @@ mod private_contact_request_error_tests { PlatformWalletFFIResultCode::ErrorPrivateContactRecoveryUnsupported ); assert_eq!(result.code as u32, 73); + // A broadcast that may have been delivered reads as unconfirmed. let delivered = PlatformWalletFFIResult::from( PlatformWalletError::ShieldedBroadcastMaybeDelivered("unavailable".to_string()), ); assert_eq!( delivered.code, - PlatformWalletFFIResultCode::ErrorShieldedBroadcastFailed + PlatformWalletFFIResultCode::ErrorShieldedSpendUnconfirmed ); // The explicit private send and accept report them through the spend // contract alike. @@ -2919,7 +2930,7 @@ mod private_contact_request_error_tests { "private contact request" ) .code, - PlatformWalletFFIResultCode::ErrorShieldedBroadcastFailed + PlatformWalletFFIResultCode::ErrorShieldedSpendUnconfirmed ); } } diff --git a/packages/rs-platform-wallet-ffi/src/shielded_send.rs b/packages/rs-platform-wallet-ffi/src/shielded_send.rs index f037a84cd65..0fcd3b44f14 100644 --- a/packages/rs-platform-wallet-ffi/src/shielded_send.rs +++ b/packages/rs-platform-wallet-ffi/src/shielded_send.rs @@ -608,7 +608,11 @@ pub(crate) fn map_spend_result( // shield reserves nothing. Either way the next sync (or an app // restart) reconciles the outcome; the typed Display already // carries the operation name and guidance. - Err(e @ PlatformWalletError::ShieldedSpendUnconfirmed { .. }) => { + // A broadcast that may have been delivered reads the same: it may + // still execute, so its notes and recovery record stay, and a host + // must not pay again. + Err(e @ PlatformWalletError::ShieldedSpendUnconfirmed { .. }) + | Err(e @ PlatformWalletError::ShieldedBroadcastMaybeDelivered(_)) => { PlatformWalletFFIResult::err( PlatformWalletFFIResultCode::ErrorShieldedSpendUnconfirmed, e.to_string(), @@ -645,17 +649,14 @@ pub(crate) fn map_spend_result( PlatformWalletFFIResultCode::ErrorShieldedNoRecordedAnchor, format!("Wallet is still syncing to a confirmed state — try again shortly. ({e})"), ), - // Definitive failure: the notes were released; the host may retry. A - // broadcast that may have been delivered reads the same for the spend, - // while a private contact request it paid for stays pending (71 on a - // retry, `managed_identity_get_private_reply_pending`). - Err(e @ PlatformWalletError::ShieldedBroadcastFailed(_)) - | Err(e @ PlatformWalletError::ShieldedBroadcastMaybeDelivered(_)) => { - PlatformWalletFFIResult::err( - PlatformWalletFFIResultCode::ErrorShieldedBroadcastFailed, - format!("{operation} failed: {e}"), - ) - } + // Definitive failure: the transition never left the wallet, or Platform + // executed and rejected it; the notes were released and the host may + // retry. A private contact request it paid for may stay pending (71 + // on a retry, `managed_identity_get_private_reply_pending`). + Err(e @ PlatformWalletError::ShieldedBroadcastFailed(_)) => PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorShieldedBroadcastFailed, + format!("{operation} failed: {e}"), + ), // Definitively failed on an address-nonce race (a shield spends platform // address funds; a shield reserves no notes). Its own code carries the // safe-to-retry contract AND lets the host recognize the self-healing From 3ad51a3488d95dc67ccfa522b273fbeddaf2ba27 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 9 Nov 2026 19:48:43 +0700 Subject: [PATCH 098/117] docs(platform-wallet): describe the private payment journal and maybe-delivered spends The README's private payment section now describes the journal: what it keeps, its durable write before the broadcast, the only two ways a record leaves it, how a restoration reads it and when it fails closed, and what a user can do about a deleted or replaced store. A new section says which shielded spends report a broadcast that may have been delivered as unconfirmed, and the custom store migration notes list the required journal methods. Co-Authored-By: Claude Opus 5.5 --- packages/rs-platform-wallet/README.md | 22 +++++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/packages/rs-platform-wallet/README.md b/packages/rs-platform-wallet/README.md index 36e70893800..dbebdb951e8 100644 --- a/packages/rs-platform-wallet/README.md +++ b/packages/rs-platform-wallet/README.md @@ -123,17 +123,33 @@ Startup errors distinguish damaged recovery metadata or undecodable signed bytes ## Private contact request payments after a restart -A private contact request's payment that is unconfirmed when the process ends stays in the shielded store with the request it pays for. Until its reservation is restored, nothing shows that the pair is taken: the request's entry is not in DashPay's store until the payment executes. So a reloaded wallet sends and accepts no public contact request while such a payment may be unrestored, and returns `PrivateContactPaymentsNotRestored` (FFI code 72) with nothing read, signed or broadcast. The in-kind accept is held the same way. Two things hold it: +Before a private contact request's payment can reach the network, the shielded store saves a record of it in its private payment journal: the sender and recipient identities, the request's account reference, the commitment of the entry it appends and the nullifiers its spend consumes. The file store writes the journal, and the payment's redrive record, in one transaction on a connection at `synchronous=FULL`, so the record survives a crash or a power loss; a record that cannot be saved refuses the payment before anything is broadcast. The journal is kept apart from the spend's notes and redrive record: no note release, redrive removal, undecodable transition, account rebind or purge removes it. A record is removed only when its entry is read back from DashPay's private store and recorded durably, or when the transition provably never left the wallet (the first node to answer refused it with Platform's consensus verdict, with no earlier attempt that could have delivered it). A payment that never lands otherwise keeps its pair held; that fails closed. + +Until a payment's reservation is restored from the journal, nothing shows that the pair is taken: the request's entry is not in DashPay's store until the payment executes. So a reloaded wallet sends and accepts no contact request, public or private, while such a payment may be unrestored, and returns `PrivateContactPaymentsNotRestored` (FFI code 72) with nothing read, signed or broadcast. The in-kind accept is held the same way. Two things hold it: - In a build with the `shielded` feature, `load_from_persistor` holds a wallet that bound a shielded account before (its persisted viewing key shows it). A wallet with no persisted viewing key never bound one, since a bind persists the key before installing anything and refuses when it cannot. - In every build, an identity whose private scan state marks a private payment that may be unresolved (`PrivateContactRequestsPosition::private_payments_unresolved`) is held. The wallet stores the marker durably before such a payment may reach the network, flushing a backend that does not commit a store inline, and stores it cleared once the identity holds no private request it is paying for or has not read back. A loaded hold is kept even when the backend no longer attests the scan state. -A private contact request is therefore paid for only over a backend attesting `PRIVATE_CONTACT_REQUEST_SCAN_STATE` together with `ATOMIC_CHANGESETS` (`PersistenceCapabilities::PRIVATE_CONTACT_SCAN_STATE_RESTART`), as the scan state is written in changesets that must commit whole: any other refuses the private send and the private accept with `PrivateContactRecoveryUnsupported` (FFI code 73) before anything is sealed or paid, and a marker that cannot be stored or flushed refuses the payment too. A payment whose broadcast may have been delivered (`ShieldedBroadcastMaybeDelivered`, an `Unavailable` answer) keeps its pair held and its marker set, though its notes are released. A saved payment whose association with the request it pays for does not read back fails the restoration, so the holds stay. +A private contact request is therefore paid for only over a backend attesting `PRIVATE_CONTACT_REQUEST_SCAN_STATE` together with `ATOMIC_CHANGESETS` (`PersistenceCapabilities::PRIVATE_CONTACT_SCAN_STATE_RESTART`), as the scan state is written in changesets that must commit whole: any other refuses the private send and the private accept with `PrivateContactRecoveryUnsupported` (FFI code 73) before anything is sealed or paid, and a marker that cannot be stored or flushed refuses the payment too. A payment that may have reached the network keeps its pair held and its marker set whatever its spend's outcome, until its record is removed as above. A pair held so refuses another private payment (`PrivateContactRequestPending`, FFI code 71) and holds public sends and accepts. + +`PlatformWalletManager::configure_shielded` restores the payments of every held wallet from the journal, with no keys, and a load after it restores those it loads. Binding a wallet's shielded account restores its own; a bind never waits for a store that a scan holds, and defers the restoration to a task instead, the wallet held until it succeeds. A private sync does not restore. Hosts that use shielded support call `configure_shielded` right after the load. A host that opens no shielded store this session, and any build without the `shielded` feature, keeps those wallets' and identities' contact requests refused: the private payment may still execute, and a public request would publish the pair the private one kept off chain. `DashPayView::private_contact_readiness` (FFI `platform_wallet_get_private_contact_readiness`) reads whether the payments are restored, whether the private store was read to its end since load, and whether the marker is set, so a host shows the wallet's state rather than inferring it from refusals. + +A restoration fails closed, every hold staying, when the coordinator is for another network, when a journal record does not read back, when a saved spend's transition does not decode and no journal record accounts for it, when a private contact request payment saved before the journal kept it has no request saved with it, and when an identity is marked as holding an unresolved payment while the wallet's journal is new (the shielded store was deleted or replaced, so it cannot show that payment resolved). The first three clear once the damaged spend lands or its anchor is pruned, which removes its redrive record, or once a known-good store file is restored; the last one only once a known-good store is restored, or by removing the wallet and adding it again when the payment is known to have resolved. -`PlatformWalletManager::configure_shielded` restores the payments of every held wallet from the store, with no keys, and a load after it restores those it loads. Binding a wallet's shielded account restores its own. A private sync does not. Hosts that use shielded support call `configure_shielded` right after the load. A host that opens no shielded store this session, and any build without the `shielded` feature, keeps those wallets' and identities' public contact requests refused: the private payment may still execute, and a public request would publish the pair the private one kept off chain. `DashPayView::private_contact_readiness` (FFI `platform_wallet_get_private_contact_readiness`) reads whether the payments are restored, whether the private store was read to its end since load, and whether the marker is set, so a host shows the wallet's state rather than inferring it from refusals. +### Shielded spends whose broadcast may have been delivered + +Every shielded spend that consumes notes (shielded transfers, unshields, withdrawals, identity top-ups and creations from the pool, and private document creations) broadcasts in single dispatches and keeps what each one proved: the SDK's own retry loop returns only its last error, which cannot show that an earlier attempt was admitted with its response lost. Any dispatch that failed in a way that allows delivery, any transport error whatever its gRPC code, makes the whole broadcast possibly delivered, and a later refusal does not override it. Such a spend waits for its result, and reports `ShieldedSpendUnconfirmed` (FFI code 18) when that wait fails, its notes and redrive record kept so the redrive pass resolves it: a host must not pay again with other notes. Only a spend that never left the wallet reports `ShieldedBroadcastFailed` and releases its notes at once. A re-broadcast refused by a node does not prove that no other node holds the original, so the redrive pass keeps such a spend's notes reserved until it lands or its anchor is pruned. Shields into the pool (from platform addresses or an identity) keep their own handling. ## Shielded balance API migration +Custom `ShieldedStore` implementations must also implement the private +contact payment methods, which have no default: `arm_private_contact_payment`, +`private_contact_redrives_for_wallet` and the private payment journal +(`open_private_payment_journal`, `private_payment_journal`, +`journal_private_payments`, `discharge_private_payment`). A store that cannot +keep a payment's records durably returns an error, which refuses the payment; +it must never report success while dropping them. + The local shielded balance API introduces three Rust source compatibility changes: - Custom `ShieldedStore` implementations must implement From e312365b2bc92aa6ab17336adb7a07ae9936ffc4 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 9 Nov 2026 20:22:09 +0700 Subject: [PATCH 099/117] fix(platform-wallet): own note reservations by attempt, and save a private payment before handing its notes over - A spend attempt's note reservations are owned by its reservation id (ShieldedStore::mark_pending_by), and every release by its guard (cancellation, the detached Drop cleanup, a failed reservation's rollback) compares and clears under the store lock (clear_pending_reserved_by). A cleanup that runs after a purge and rebind, or the wallet's replacement, no longer releases a newer attempt's notes or, through the file store, deletes its redrive record and association. It never touches the private payment journal. - A reservation that cannot carry its anchor (set_pending_spend failing) stops the spend before it is broadcast, its guard releasing the notes, instead of broadcasting a spend no pruned anchor could free. - A private document payment hands its notes over only once its recovery record is saved: one dropped while the save waits for the store releases them, rather than leaving them reserved until an anchor prune or a restart. The save and broadcast step is split out (submit_private_document_payment) so a test intercepts the production broadcast: the record is on the store's file when it runs, and a failed save never reaches it. - The test node of the anchor-fetch drop test stops and is joined when dropped, also while an assertion unwinds. Co-Authored-By: Claude Opus 5.5 --- .../src/wallet/shielded/file_store.rs | 37 + .../src/wallet/shielded/operations.rs | 697 +++++++++++++++--- .../src/wallet/shielded/store.rs | 94 ++- 3 files changed, 741 insertions(+), 87 deletions(-) diff --git a/packages/rs-platform-wallet/src/wallet/shielded/file_store.rs b/packages/rs-platform-wallet/src/wallet/shielded/file_store.rs index 9c3feb8b389..4bc5f2acb89 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/file_store.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/file_store.rs @@ -925,6 +925,43 @@ impl ShieldedStore for FileBackedShieldedStore { Ok(removed) } + fn mark_pending_by( + &mut self, + id: SubwalletId, + nullifier: &[u8; 32], + reservation: u64, + ) -> Result { + Ok(self + .subwallets + .entry(id) + .or_default() + .mark_pending_by(nullifier, reservation)) + } + + fn clear_pending_reserved_by( + &mut self, + id: SubwalletId, + nullifier: &[u8; 32], + reservation: u64, + ) -> Result { + let Some(sw) = self.subwallets.get_mut(&id) else { + return Ok(false); + }; + let released = sw.clear_pending_reserved_by(nullifier, reservation); + if released { + // Same log-don't-abort rationale as `clear_pending`. + if let Err(e) = self.delete_redrive_rows_containing(id, nullifier) { + tracing::warn!( + error = %e, + "redrive row deletion failed after a released reservation; a stale row \ + may rehydrate on the next open (self-heals via reconcile/prune)" + ); + } + self.evict_private_contact_payments(id); + } + Ok(released) + } + fn set_pending_spend( &mut self, id: SubwalletId, diff --git a/packages/rs-platform-wallet/src/wallet/shielded/operations.rs b/packages/rs-platform-wallet/src/wallet/shielded/operations.rs index 5570d5ef8da..edf81fb1c66 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/operations.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/operations.rs @@ -42,6 +42,8 @@ use crate::wallet::persister::WalletPersister; use crate::wallet::platform_wallet::WalletId; use std::collections::{BTreeMap, HashSet}; +use std::future::Future; +use std::sync::atomic::{AtomicU64, Ordering as AtomicOrdering}; use std::sync::Arc; use dash_sdk::dapi_grpc::platform::v0::ResponseMetadata; @@ -1466,7 +1468,7 @@ pub(in crate::wallet) async fn identity_top_up_from_pool_with_metadata< }, ) .await; - if arm_pending_release(store, id, anchor_bytes, &pending_entry, &selected_notes).await { + if arm_pending_release(store, id, anchor_bytes, &pending_entry, &selected_notes).await? { // Each reservation carries its anchor: a pruned anchor releases it. reservation.hand_over(); } @@ -1617,7 +1619,7 @@ pub(in crate::wallet) async fn private_document_create_from_pool_with_metadata< entry_size: entries.first().map_or(0, |entry| entry.len() as u32), }; - let (selected_notes, total_input, exact_fee, mut reservation) = + let (selected_notes, total_input, exact_fee, reservation) = reserve_notes_covering(sdk, store, id, 0, 2, fee_kind).await?; info!( @@ -1645,11 +1647,8 @@ pub(in crate::wallet) async fn private_document_create_from_pool_with_metadata< }) .collect::>() }); - let mut pending_entry = None; - let result = async { + let built = async { let (spends, anchor) = extract_spends_and_anchor(sdk, store, &selected_notes).await?; - let anchor_bytes = anchor.to_bytes(); - let (state_transition, fee_used) = build_private_document_create_from_shielded_pool_transition( spends, @@ -1668,47 +1667,130 @@ pub(in crate::wallet) async fn private_document_create_from_pool_with_metadata< fee_used, exact_fee, "builder fee must match the reserved private document create fee" ); - - // The spend's only outputs are change, so it records as an own spend - // with its fee and no counterparty. - pending_entry = record_pending_activity( + Ok::<_, PlatformWalletError>((state_transition, fee_used, anchor.to_bytes())) + } + .await; + let (state_transition, fee_used, anchor_bytes) = match built { + Ok(built) => built, + Err(e) => { + // Nothing was saved or broadcast. + reservation.cancel().await; + return Err(e); + } + }; + // The spend's only outputs are change, so it records as an own spend + // with its fee and no counterparty. + let pending_entry = record_pending_activity( + store, + persister, + wallet_id, + id, + &views, + LiveEntryParams { + kind: ShieldedActivityKind::ShieldedSpend, + direction: ShieldedDirection::Out, + amount: 0, + fee: Some(fee_used), + counterparty: None, + memo: None, + actions: shielded_actions(&state_transition), + spent_notes: &selected_notes, + }, + ) + .await; + // Type 29 proves its spent nullifiers as a snapshot, which a competing + // spend of the same notes shares, as type 22's does: the affected-state + // wait accepts it (see `identity_top_up_from_pool_with_metadata`). + submit_private_document_payment( + PrivateDocumentSubmission { store, persister, wallet_id, id, - &views, - LiveEntryParams { - kind: ShieldedActivityKind::ShieldedSpend, - direction: ShieldedDirection::Out, - amount: 0, - fee: Some(fee_used), - counterparty: None, - memo: None, - actions: shielded_actions(&state_transition), - spent_notes: &selected_notes, - }, - ) - .await; - if arm_pending_release(store, id, anchor_bytes, &pending_entry, &selected_notes).await { - // Each reservation carries its anchor: a pruned anchor releases it. - reservation.hand_over(); - } + pending_entry, + selected_notes: &selected_notes, + reservation, + state_transition, + anchor: anchor_bytes, + private_contact, + journaled: journaled.as_deref(), + payment_started, + }, + |state_transition| async move { + broadcast_shielded_spend_classified( + sdk, + &state_transition, + "private document create from shielded pool", + SpendResultWait::AffectedState, + ) + .await + }, + ) + .await +} - trace!("PrivateDocumentCreateFromShieldedPool: state transition built, broadcasting..."); - // Saved before the broadcast, not after an unconfirmed wait: a - // process that exits once the node admitted the transition, before - // the wait returns, still leaves the record a restart restores the - // request's reservation from, and the redrive resolves. The record - // is dropped with the notes' reservation on either outcome. The - // payment is marked started once the record is saved, with nothing - // awaited in between: a send dropped while the save waits for the - // store has saved and broadcast nothing, and releases its - // reservation. +/// A built private document create, and everything submitting it takes +/// ([`submit_private_document_payment`]). +struct PrivateDocumentSubmission<'a, S: ShieldedStore> { + store: &'a Arc>, + persister: Option<&'a WalletPersister>, + wallet_id: WalletId, + id: SubwalletId, + /// The spend's activity entry, recorded pending; it keys its records. + pending_entry: Option, + selected_notes: &'a [ShieldedNote], + reservation: NoteReservation, + state_transition: StateTransition, + anchor: [u8; 32], + private_contact: Option, + journaled: Option<&'a [[u8; 32]]>, + payment_started: &'a PaymentStarted, +} + +/// Record, save and broadcast a built private document create, then settle +/// its notes and saved record ([`settle_private_document_payment`]). +/// `broadcast` sends the transition and waits for its result: the production +/// flow passes the classified broadcast, a test intercepts it. +/// +/// Saved before the broadcast, not after an unconfirmed wait: a process that +/// exits once the node admitted the transition, before the wait returns, +/// still leaves the record a restart restores the request's reservation from, +/// and the redrive resolves. The payment is marked started once the record is +/// saved, with nothing awaited in between, and the notes are handed over only +/// then: a send dropped while the save waits for the store has saved and +/// broadcast nothing, and its guard releases the notes. A save that fails +/// stops the payment before the broadcast. +async fn submit_private_document_payment( + submission: PrivateDocumentSubmission<'_, S>, + broadcast: B, +) -> Result +where + S: ShieldedStore, + B: FnOnce(StateTransition) -> Fut, + Fut: Future>, +{ + let PrivateDocumentSubmission { + store, + persister, + wallet_id, + id, + pending_entry, + selected_notes, + mut reservation, + state_transition, + anchor, + private_contact, + journaled, + payment_started, + } = submission; + let result = async { + let anchored = + arm_pending_release(store, id, anchor, &pending_entry, selected_notes).await?; arm_redrive_before_broadcast( store, id, pending_entry.as_ref().map(|entry| entry.id), - anchor_bytes, + anchor, selected_notes.iter().map(|note| note.nullifier).collect(), &state_transition, "private document create from shielded pool", @@ -1716,16 +1798,12 @@ pub(in crate::wallet) async fn private_document_create_from_pool_with_metadata< Some(payment_started), ) .await?; - // Type 29 proves its spent nullifiers as a snapshot, which a competing - // spend of the same notes shares, as type 22's does: the affected-state - // wait accepts it (see `identity_top_up_from_pool_with_metadata`). - broadcast_shielded_spend_classified( - sdk, - &state_transition, - "private document create from shielded pool", - SpendResultWait::AffectedState, - ) - .await + if anchored { + // Each reservation carries its anchor: a pruned anchor releases it. + reservation.hand_over(); + } + trace!("PrivateDocumentCreateFromShieldedPool: state transition saved, broadcasting..."); + broadcast(state_transition).await } .await; @@ -1736,8 +1814,8 @@ pub(in crate::wallet) async fn private_document_create_from_pool_with_metadata< id, &pending_entry, reservation, - &selected_notes, - journaled.as_deref(), + selected_notes, + journaled, payment_started, result.map(|(_proof, metadata)| metadata), ) @@ -1968,7 +2046,7 @@ pub async fn unshield( }, ) .await; - if arm_pending_release(store, id, anchor_bytes, &pending_entry, &selected_notes).await { + if arm_pending_release(store, id, anchor_bytes, &pending_entry, &selected_notes).await? { // Each reservation carries its anchor: a pruned anchor releases it. reservation.hand_over(); } @@ -2159,7 +2237,7 @@ pub async fn transfer( }, ) .await; - if arm_pending_release(store, id, anchor_bytes, &pending_entry, &selected_notes).await { + if arm_pending_release(store, id, anchor_bytes, &pending_entry, &selected_notes).await? { // Each reservation carries its anchor: a pruned anchor releases it. reservation.hand_over(); } @@ -2340,7 +2418,7 @@ pub async fn withdraw( }, ) .await; - if arm_pending_release(store, id, anchor_bytes, &pending_entry, &selected_notes).await { + if arm_pending_release(store, id, anchor_bytes, &pending_entry, &selected_notes).await? { // Each reservation carries its anchor: a pruned anchor releases it. reservation.hand_over(); } @@ -2558,7 +2636,7 @@ where }, ) .await; - if arm_pending_release(store, id, anchor_bytes, &pending_entry, &selected_notes).await { + if arm_pending_release(store, id, anchor_bytes, &pending_entry, &selected_notes).await? { // Each reservation carries its anchor: a pruned anchor releases it. reservation.hand_over(); } @@ -3191,10 +3269,11 @@ fn mark_notes_pending( id: SubwalletId, notes: &[ShieldedNote], ) -> Result, PlatformWalletError> { + let reservation = next_note_reservation(); let mut marked = Vec::with_capacity(notes.len()); for note in notes { - if let Err(e) = locked.mark_pending(id, ¬e.nullifier) { - release_reserved_notes(locked, id, &marked); + if let Err(e) = locked.mark_pending_by(id, ¬e.nullifier, reservation) { + release_reserved_notes(locked, id, &marked, reservation); return Err(PlatformWalletError::ShieldedStoreError(e.to_string())); } marked.push(note.nullifier); @@ -3203,10 +3282,18 @@ fn mark_notes_pending( store: Arc::clone(store), id, nullifiers: marked, + reservation, handed_over: false, }) } +/// A spend attempt's reservation id, unique in the process: what its note +/// reservations are owned by ([`ShieldedStore::mark_pending_by`]). +fn next_note_reservation() -> u64 { + static NEXT: AtomicU64 = AtomicU64::new(1); + NEXT.fetch_add(1, AtomicOrdering::Relaxed) +} + /// Notes one spend reserved ([`ShieldedStore::mark_pending`]), released when /// dropped before the spend hands them over ([`Self::hand_over`]): a spend /// future dropped before its notes have an owner that frees them (an anchor @@ -3220,6 +3307,11 @@ pub(super) struct NoteReservation { store: Arc>, id: SubwalletId, nullifiers: Vec<[u8; 32]>, + /// The attempt that owns the reservations: a release clears a note + /// only while it still holds it, so a cleanup that runs after a purge, + /// a rebind or a wallet's replacement never releases a newer attempt's + /// notes or deletes its records. + reservation: u64, handed_over: bool, } @@ -3241,7 +3333,7 @@ impl NoteReservation { pub(super) async fn cancel(mut self) { self.handed_over = false; let mut store = self.store.write().await; - release_reserved_notes(&mut *store, self.id, &self.nullifiers); + release_reserved_notes(&mut *store, self.id, &self.nullifiers, self.reservation); self.nullifiers.clear(); } } @@ -3251,10 +3343,10 @@ impl Drop for NoteReservation { if self.handed_over || self.nullifiers.is_empty() { return; } - let id = self.id; + let (id, reservation) = (self.id, self.reservation); let nullifiers = std::mem::take(&mut self.nullifiers); if let Ok(mut store) = self.store.try_write() { - release_reserved_notes(&mut *store, id, &nullifiers); + release_reserved_notes(&mut *store, id, &nullifiers, reservation); return; } let Ok(runtime) = tokio::runtime::Handle::try_current() else { @@ -3266,19 +3358,21 @@ impl Drop for NoteReservation { let store = Arc::clone(&self.store); runtime.spawn(async move { let mut store = store.write().await; - release_reserved_notes(&mut *store, id, &nullifiers); + release_reserved_notes(&mut *store, id, &nullifiers, reservation); }); } } -/// Clear the reservations of `nullifiers`. +/// Clear the reservations of `nullifiers` the spend attempt `reservation` +/// still holds ([`ShieldedStore::clear_pending_reserved_by`]). fn release_reserved_notes( store: &mut S, id: SubwalletId, nullifiers: &[[u8; 32]], + reservation: u64, ) { for nullifier in nullifiers { - if let Err(e) = store.clear_pending(id, nullifier) { + if let Err(e) = store.clear_pending_reserved_by(id, nullifier, reservation) { warn!( error = %e, "releasing a dropped spend's note failed; the next note scan will reconcile" @@ -3343,38 +3437,38 @@ async fn finalize_pending( /// /// No-op when no activity entry was recorded (an output-less bundle — /// unreachable for our own builders, which always carry a visible -/// output). Best-effort: a store write failure only means the -/// reservation won't self-release on a pruned anchor (it still frees -/// on the next restart), so it must never abort a spend about to -/// broadcast. A success (`finalize_pending`) or a definite failure +/// output). A success (`finalize_pending`) or a definite failure /// (`NoteReservation::cancel`) removes the entry, so only an ambiguous /// unconfirmed outcome leaves it carrying the anchor. /// /// Returns whether every reservation now carries the anchor, so a pruned -/// anchor releases each. +/// anchor releases each. A store write that fails stops the spend before it +/// is broadcast ([`PlatformWalletError::ShieldedStoreError`]): the caller's +/// reservation guard still owns the notes, so they are released, rather +/// than a spend that may execute leaving reservations no pruned anchor +/// frees. async fn arm_pending_release( store: &Arc>, id: SubwalletId, anchor: [u8; 32], pending_entry: &Option, notes: &[ShieldedNote], -) -> bool { +) -> Result { let Some(entry) = pending_entry else { - return false; + return Ok(false); }; let mut store = store.write().await; - let mut armed = true; for note in notes { - if let Err(e) = store.set_pending_spend(id, ¬e.nullifier, anchor, entry.id) { - armed = false; - warn!( - error = %e, - "set_pending_spend failed; this reservation won't self-release on a pruned \ - anchor (it still frees on the next restart)" - ); - } + store + .set_pending_spend(id, ¬e.nullifier, anchor, entry.id) + .map_err(|e| { + PlatformWalletError::ShieldedStoreError(format!( + "the spend's note reservation could not carry its anchor, so it is not \ + broadcast: {e}" + )) + })?; } - armed + Ok(true) } /// Maximum sync-time re-broadcast attempts for a @@ -4244,6 +4338,7 @@ mod shield_recipient_tests { mod redrive_tests { use super::*; use crate::wallet::identity::network::PaymentStarted; + use crate::wallet::shielded::activity::ShieldedActivityEntry; use crate::wallet::shielded::store::InMemoryShieldedStore; use crate::wallet::shielded::FileBackedShieldedStore; use dash_sdk::dpp::dashcore::Network; @@ -4583,6 +4678,396 @@ mod redrive_tests { ); } + /// The production payment flow saves a private contact request payment + /// durably before its broadcast runs: intercepting the broadcast, a second + /// handle on the store's file reads its journal record and redrive record, + /// and the payment is marked started. A payment whose save fails is never + /// broadcast, is not marked started, and releases its notes. + #[tokio::test] + async fn should_save_a_private_payment_durably_before_its_broadcast() { + use std::sync::atomic::{AtomicBool, Ordering}; + + let sdk = Arc::new(dash_sdk::Sdk::new_mock()); + let action = SerializedAction { + nullifier: [1; 32], + rk: [2; 32], + cmx: [3; 32], + encrypted_note: vec![4; 216], + cv_net: [5; 32], + spend_auth_sig: [6; 64], + }; + let payment = PrivateContactPayment { + sender: [0xB1; 32], + recipient: [0xA1; 32], + account_reference: 5, + }; + let wallet_id = [0x68; 32]; + let id = SubwalletId::new(wallet_id, 0); + for save_fails in [false, true] { + let dir = tempfile::tempdir().expect("a directory"); + let path = dir.path().join("tree.sqlite"); + let store = Arc::new(RwLock::new( + FileBackedShieldedStore::open_path(&path, 100).expect("the store"), + )); + let note = ShieldedNote { + position: 0, + cmx: [1; 32], + nullifier: [0x5F; 32], + block_height: 1, + is_spent: false, + value: 1_000_000_000_000, + note_data: vec![], + }; + store.write().await.save_note(id, ¬e).expect("a note"); + let (selected, _, _, reservation) = + reserve_notes_covering(&sdk, &store, id, 1, 2, ShieldedFeeKind::Base) + .await + .expect("reserved"); + if save_fails { + rusqlite::Connection::open(&path) + .expect("the store's file") + .execute_batch( + "CREATE TRIGGER reject_redrive_insert BEFORE INSERT ON \ + shielded_pending_spends BEGIN SELECT RAISE(FAIL, 'injected'); END;", + ) + .expect("the trigger"); + } + let transition = PrivateDocumentCreateFromShieldedPoolTransition::try_from_bundle( + Identifier::from([0x44; 32]), + "privateContactRequest".to_string(), + vec![BinaryData::new(vec![9; 589])], + vec![action.clone(), action.clone()], + 0, + [7; 32], + vec![8; 64], + [9; 64], + PlatformVersion::latest(), + ) + .expect("a type 29 transition"); + let started = PaymentStarted::default(); + let broadcast = AtomicBool::new(false); + let submitted = submit_private_document_payment( + PrivateDocumentSubmission { + store: &store, + persister: None, + wallet_id, + id, + pending_entry: Some(ShieldedActivityEntry { + id: [0x77; 32], + kind: ShieldedActivityKind::ShieldedSpend, + direction: ShieldedDirection::Out, + amount: 0, + fee: Some(0), + counterparty: None, + memo: None, + block_height: None, + status: ShieldedActivityStatus::Pending, + created_at_ms: 1, + min_note_position: None, + note_cmxs: vec![], + spent_nullifiers: vec![], + }), + selected_notes: &selected, + reservation, + state_transition: transition, + anchor: [7; 32], + private_contact: Some(payment), + journaled: Some(&[[9; 32]]), + payment_started: &started, + }, + |_| { + broadcast.store(true, Ordering::SeqCst); + let durable = + FileBackedShieldedStore::open_path(&path, 100).expect("a second handle"); + assert_eq!( + durable + .private_payment_journal(wallet_id) + .expect("the journal") + .len(), + 1, + "the journal record is on the file" + ); + assert_eq!( + durable + .private_contact_redrives_for_wallet(wallet_id) + .expect("the records") + .len(), + 1, + "the redrive record and its association are on the file" + ); + assert!(started.is_marked(), "marked once saved"); + std::future::ready(Err(SpendFailure { + error: PlatformWalletError::ShieldedSpendUnconfirmed { + operation: "private document create from shielded pool", + reason: "intercepted".to_string(), + }, + never_delivered: false, + })) + }, + ) + .await; + let unspent = store + .read() + .await + .get_unspent_notes(id) + .expect("notes") + .len(); + if save_fails { + assert!( + matches!(submitted, Err(PlatformWalletError::ShieldedStoreError(_))), + "{submitted:?}" + ); + assert!(!broadcast.load(Ordering::SeqCst), "never broadcast"); + assert!(!started.is_marked(), "never marked"); + assert_eq!(unspent, 1, "the notes are released"); + assert!(store + .read() + .await + .private_payment_journal(wallet_id) + .expect("the journal") + .is_empty()); + } else { + assert!( + matches!( + submitted, + Err(PlatformWalletError::ShieldedSpendUnconfirmed { .. }) + ), + "{submitted:?}" + ); + assert!(broadcast.load(Ordering::SeqCst)); + assert_eq!(unspent, 0, "the notes stay reserved"); + } + } + } + + /// A payment dropped while its recovery save waits for the store has saved + /// and broadcast nothing, and its note guard releases the notes once the + /// store frees: the notes are handed over only after the save. + #[tokio::test] + async fn should_release_the_notes_of_a_payment_dropped_while_its_save_waits() { + use std::future::Future; + use std::task::{Context, Poll, Waker}; + + let sdk = Arc::new(dash_sdk::Sdk::new_mock()); + let action = SerializedAction { + nullifier: [1; 32], + rk: [2; 32], + cmx: [3; 32], + encrypted_note: vec![4; 216], + cv_net: [5; 32], + spend_auth_sig: [6; 64], + }; + let wallet_id = [0x69; 32]; + let id = SubwalletId::new(wallet_id, 0); + let store = Arc::new(RwLock::new(InMemoryShieldedStore::new())); + let note = ShieldedNote { + position: 0, + cmx: [1; 32], + nullifier: [0x5E; 32], + block_height: 1, + is_spent: false, + value: 1_000_000_000_000, + note_data: vec![], + }; + store.write().await.save_note(id, ¬e).expect("a note"); + let (selected, _, _, reservation) = + reserve_notes_covering(&sdk, &store, id, 1, 2, ShieldedFeeKind::Base) + .await + .expect("reserved"); + let transition = PrivateDocumentCreateFromShieldedPoolTransition::try_from_bundle( + Identifier::from([0x44; 32]), + "privateContactRequest".to_string(), + vec![BinaryData::new(vec![9; 589])], + vec![action.clone(), action], + 0, + [7; 32], + vec![8; 64], + [9; 64], + PlatformVersion::latest(), + ) + .expect("a type 29 transition"); + let started = PaymentStarted::default(); + let mut cx = Context::from_waker(Waker::noop()); + + let held = store.write().await; + let mut submitted = Box::pin(submit_private_document_payment( + PrivateDocumentSubmission { + store: &store, + persister: None, + wallet_id, + id, + pending_entry: Some(ShieldedActivityEntry { + id: [0x78; 32], + kind: ShieldedActivityKind::ShieldedSpend, + direction: ShieldedDirection::Out, + amount: 0, + fee: Some(0), + counterparty: None, + memo: None, + block_height: None, + status: ShieldedActivityStatus::Pending, + created_at_ms: 1, + min_note_position: None, + note_cmxs: vec![], + spent_nullifiers: vec![], + }), + selected_notes: &selected, + reservation, + state_transition: transition, + anchor: [7; 32], + private_contact: Some(PrivateContactPayment { + sender: [0xB1; 32], + recipient: [0xA1; 32], + account_reference: 5, + }), + journaled: Some(&[[9; 32]]), + payment_started: &started, + }, + |_| async { panic!("never broadcast") }, + )); + // The submission waits for the store before its anchor step. + assert!(submitted.as_mut().poll(&mut cx).is_pending()); + // Freed, the lock goes to the submission first; this request queues + // behind it, so the submission's next one, the save, waits for it. + drop(held); + let mut ours = Box::pin(store.write()); + assert!(ours.as_mut().poll(&mut cx).is_pending()); + assert!(submitted.as_mut().poll(&mut cx).is_pending()); + let Poll::Ready(held) = ours.as_mut().poll(&mut cx) else { + panic!("the store is ours once the submission's anchor step is done"); + }; + // Dropped while the save waits. + drop(submitted); + assert!(!started.is_marked(), "nothing was saved"); + drop(held); + for _ in 0..50 { + if !store + .read() + .await + .get_unspent_notes(id) + .expect("notes") + .is_empty() + { + break; + } + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + } + assert_eq!( + store + .read() + .await + .get_unspent_notes(id) + .expect("notes") + .len(), + 1, + "the guard released the notes" + ); + assert!(store + .read() + .await + .private_payment_journal(wallet_id) + .expect("the journal") + .is_empty()); + } + + /// A stale spend attempt's detached cleanup, delayed until after an + /// account purge and rebind (or the wallet's replacement) let a newer + /// attempt reserve the same notes and save its payment, releases nothing: + /// the newer attempt's notes stay reserved, and its redrive record, + /// association and journal record stay. + #[tokio::test] + async fn should_not_release_a_newer_attempt_s_notes_from_a_stale_cleanup() { + let sdk = Arc::new(dash_sdk::Sdk::new_mock()); + let wallet_id = [0x6A; 32]; + let id = SubwalletId::new(wallet_id, 0); + let note = ShieldedNote { + position: 0, + cmx: [1; 32], + nullifier: [0x5D; 32], + block_height: 1, + is_spent: false, + value: 1_000_000_000_000, + note_data: vec![], + }; + let payment = PrivateContactPayment { + sender: [0xB1; 32], + recipient: [0xA1; 32], + account_reference: 5, + }; + for wallet_replaced in [false, true] { + let dir = tempfile::tempdir().expect("a directory"); + let store = Arc::new(RwLock::new( + FileBackedShieldedStore::open_path(dir.path().join("tree.sqlite"), 100) + .expect("the store"), + )); + store.write().await.save_note(id, ¬e).expect("a note"); + let (_, _, _, stale) = + reserve_notes_covering(&sdk, &store, id, 1, 2, ShieldedFeeKind::Base) + .await + .expect("reserved"); + // Dropped while the store is busy: its cleanup waits on a task. + let mut held = store.write().await; + drop(stale); + if wallet_replaced { + held.purge_wallet(wallet_id).expect("the wallet's purge"); + } else { + held.purge_subwallet(id).expect("the account's purge"); + } + held.save_note(id, ¬e).expect("the note again"); + assert!(held + .mark_pending_by(id, ¬e.nullifier, u64::MAX) + .expect("reserved by the newer attempt")); + held.arm_private_contact_payment( + id, + PendingRedrive { + activity_id: [0x79; 32], + anchor: [7; 32], + nullifiers: vec![note.nullifier], + st_bytes: vec![0xAB; 96], + attempts: 0, + identity_nonce_finalized: false, + identity_user_abandoned: false, + }, + payment, + &[[0xC4; 32]], + ) + .expect("the newer payment saved"); + drop(held); + // The stale cleanup gets the store now. + for _ in 0..10 { + tokio::task::yield_now().await; + tokio::time::sleep(std::time::Duration::from_millis(5)).await; + } + let store = store.read().await; + let context = format!("wallet replaced: {wallet_replaced}"); + assert!( + store.get_unspent_notes(id).expect("notes").is_empty(), + "{context}: the newer attempt's note stays reserved" + ); + assert_eq!( + store.pending_redrives(id).expect("records").len(), + 1, + "{context}" + ); + assert_eq!( + store + .private_contact_redrives_for_wallet(wallet_id) + .expect("associations") + .len(), + 1, + "{context}" + ); + assert_eq!( + store + .private_payment_journal(wallet_id) + .expect("the journal") + .len(), + 1, + "{context}" + ); + } + } + /// A private contact request payment's journal record outlives its notes' /// and redrive record whatever the spend's outcome, except a transition /// that provably never left the wallet. Settled unconfirmed (its @@ -4933,13 +5418,53 @@ mod redrive_tests { } } - // A node that accepts connections and never answers. - let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("a loopback port"); - let address = listener.local_addr().expect("its address"); - std::thread::spawn(move || { - let held: Vec<_> = listener.incoming().collect(); - drop(held); - }); + /// A loopback node that accepts connections and never answers. Dropped, + /// even while a failed assertion unwinds, it stops accepting, closes + /// what it accepted and joins its thread. + struct SilentNode { + address: std::net::SocketAddr, + shutdown: Arc, + thread: Option>, + } + impl SilentNode { + fn start() -> Self { + let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("a loopback port"); + listener.set_nonblocking(true).expect("non-blocking accept"); + let address = listener.local_addr().expect("its address"); + let shutdown = Arc::new(std::sync::atomic::AtomicBool::new(false)); + let stop = Arc::clone(&shutdown); + let thread = std::thread::spawn(move || { + let mut held = Vec::new(); + while !stop.load(std::sync::atomic::Ordering::Acquire) { + match listener.accept() { + Ok((stream, _)) => held.push(stream), + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { + std::thread::sleep(std::time::Duration::from_millis(5)); + } + Err(_) => break, + } + } + drop(held); + }); + Self { + address, + shutdown, + thread: Some(thread), + } + } + } + impl Drop for SilentNode { + fn drop(&mut self) { + self.shutdown + .store(true, std::sync::atomic::Ordering::Release); + if let Some(thread) = self.thread.take() { + let _ = thread.join(); + } + } + } + + let node = SilentNode::start(); + let address = node.address; let sdk = Arc::new( dash_sdk::SdkBuilder::new( AddressList::from_str(&format!("http://{address}")).expect("an address"), diff --git a/packages/rs-platform-wallet/src/wallet/shielded/store.rs b/packages/rs-platform-wallet/src/wallet/shielded/store.rs index cab51b2d615..d7b1ec888ee 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/store.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/store.rs @@ -395,10 +395,35 @@ pub trait ShieldedStore: Send + Sync + 'static { /// got removed; `false` is a no-op (paired clear from the /// rollback path on a transition that never marked pending, /// or a stale clear after the spend already promoted to - /// `mark_spent`). + /// `mark_spent`). Clears whoever holds it: for a resolution of the + /// spend (a pruned anchor). A spend attempt releases its own with + /// [`Self::clear_pending_reserved_by`]. fn clear_pending(&mut self, id: SubwalletId, nullifier: &[u8; 32]) -> Result; + /// [`Self::mark_pending`] for the spend attempt `reservation`, which owns + /// the reservation: only [`Self::clear_pending_reserved_by`] with the + /// same attempt, or a resolution of the spend, clears it. + fn mark_pending_by( + &mut self, + id: SubwalletId, + nullifier: &[u8; 32], + reservation: u64, + ) -> Result; + + /// Release `id`'s reservation of `nullifier`, and the redrive records + /// carrying it, only while the spend attempt `reservation` still holds + /// it ([`Self::mark_pending_by`]), compared and cleared in one call + /// under the store lock: a stale attempt's cleanup never releases a + /// newer attempt's notes or deletes its records. Returns whether it was + /// released. + fn clear_pending_reserved_by( + &mut self, + id: SubwalletId, + nullifier: &[u8; 32], + reservation: u64, + ) -> Result; + /// Attach the recorded `anchor` the spend was built against, and /// the linked `activity_id`, to `id`'s pending reservation for /// `nullifier` (taken earlier by [`Self::mark_pending`]). No-op @@ -745,6 +770,11 @@ pub(super) struct PendingSpend { /// The linked activity-log entry id, so a released reservation can /// flip its "Pending" row to "Failed" instead of stranding it. pub activity_id: Option<[u8; 32]>, + /// The spend attempt that reserved the note + /// ([`ShieldedStore::mark_pending_by`]), whose release clears it only + /// while it still holds it. `None` for a reservation restored from a + /// redrive record, which only a resolution of its spend clears. + pub reservation: Option, } /// Per-subwallet note + sync state used by both the in-memory and @@ -889,6 +919,42 @@ impl SubwalletState { } } + /// [`Self::mark_pending`] for the spend attempt `reservation`, which owns + /// the reservation from now on. + pub(super) fn mark_pending_by(&mut self, nullifier: &[u8; 32], reservation: u64) -> bool { + match self.pending_nullifiers.entry(*nullifier) { + std::collections::btree_map::Entry::Vacant(e) => { + e.insert(PendingSpend { + reservation: Some(reservation), + ..PendingSpend::default() + }); + true + } + std::collections::btree_map::Entry::Occupied(_) => false, + } + } + + /// Release the reservation of `nullifier` only while the spend attempt + /// `reservation` holds it ([`Self::mark_pending_by`]), with the redrive + /// records it carries, as [`Self::clear_pending`] does. Returns whether + /// it was released: another attempt's reservation, or one restored from + /// a redrive record, stays. + pub(super) fn clear_pending_reserved_by( + &mut self, + nullifier: &[u8; 32], + reservation: u64, + ) -> bool { + if self + .pending_nullifiers + .get(nullifier) + .is_some_and(|pending| pending.reservation == Some(reservation)) + { + self.clear_pending(nullifier) + } else { + false + } + } + /// Attach the built spend's recorded `anchor` and linked /// `activity_id` to an existing reservation for `nullifier`. /// No-op if the nullifier is no longer pending (e.g. the @@ -1129,6 +1195,32 @@ impl ShieldedStore for InMemoryShieldedStore { .unwrap_or(false)) } + fn mark_pending_by( + &mut self, + id: SubwalletId, + nullifier: &[u8; 32], + reservation: u64, + ) -> Result { + Ok(self + .subwallets + .entry(id) + .or_default() + .mark_pending_by(nullifier, reservation)) + } + + fn clear_pending_reserved_by( + &mut self, + id: SubwalletId, + nullifier: &[u8; 32], + reservation: u64, + ) -> Result { + let released = self + .subwallets + .get_mut(&id) + .is_some_and(|sw| sw.clear_pending_reserved_by(nullifier, reservation)); + Ok(released) + } + fn set_pending_spend( &mut self, id: SubwalletId, From a1748d9f36ad72afeeffe0e08660f52bd3e27f47 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 9 Nov 2026 20:22:09 +0700 Subject: [PATCH 100/117] fix(platform-wallet): replay the last private payment marker, and flush marker changes The loader's replay of private scan state OR-ed each record's unresolved-payment marker into the identity's hold, so a later cleared record never lifted an earlier marked one. The record replayed last now decides it; live reservations are left alone, and a restoration from the shielded store's private payment journal still holds every pair whose payment may execute. A marker change stored when a reservation is released or settled is flushed for a backend that does not commit inline. A failed store or flush is logged and leaves the marker set, which the next restoration clears once the journal shows the payment resolved. Co-Authored-By: Claude Opus 5.5 --- .../network/private_contact_requests.rs | 20 +++++++++++--- .../managed_identity/contact_requests.rs | 27 ++++++++++++++++++- 2 files changed, 43 insertions(+), 4 deletions(-) diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/private_contact_requests.rs b/packages/rs-platform-wallet/src/wallet/identity/network/private_contact_requests.rs index 596f7b05f2a..545ef31c3e7 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/private_contact_requests.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/private_contact_requests.rs @@ -505,9 +505,13 @@ pub(crate) async fn restore_journaled_private_payments( /// Store `owner`'s private read position record again, for a backend that /// keeps it, as whether a private payment of `owner` may be unresolved /// (`PrivateContactRequestsPosition::private_payments_unresolved`) may have -/// changed: a reservation was released or settled. Best effort: a failed -/// store leaves the marker set, which holds the identity after a restart -/// until a restoration clears it, never the other way round. +/// changed: a reservation was released or settled. Flushed when the backend +/// does not commit a store inline, so a cleared marker does not hold the +/// identity after a restart. A store or flush that fails is logged and +/// leaves the marker set, which holds the identity after a restart only +/// until the next restoration: that one clears it, the shielded store's +/// journal being open by then and showing the payment resolved. Never the +/// other way round. fn store_private_payment_marker( persister: &WalletPersister, wm: &WalletManager, @@ -526,6 +530,16 @@ fn store_private_payment_marker( *owner, managed.private_contact_requests_position_record(), ); + if !persister.store_commits_inline() { + if let Err(error) = persister.flush() { + tracing::warn!( + owner = %owner, + %error, + "a private payment marker change is not flushed; the next restoration \ + clears a stale one" + ); + } + } } } diff --git a/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/contact_requests.rs b/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/contact_requests.rs index 0ffbab17c29..8897d7274e2 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/contact_requests.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/contact_requests.rs @@ -841,13 +841,18 @@ impl ManagedIdentity { /// (`ContactChangeSet::private_contact_requests_positions`): the loader's /// replay, for a backend attesting /// [`PRIVATE_CONTACT_REQUEST_SCAN_STATE`](crate::changeset::PersistenceCapabilities::PRIVATE_CONTACT_REQUEST_SCAN_STATE). + /// The record replayed last decides the identity's load-time hold, so a + /// later record cleared lifts an earlier marked one; the live + /// per-recipient reservations are left alone, and a restoration from + /// the shielded store's journal holds every pair whose payment may still + /// execute whatever the marker says. pub fn apply_private_contact_requests_position( &mut self, position: PrivateContactRequestsPosition, ) { self.dashpay.private_contact_requests_position = position.position; self.dashpay.private_scan_keys = Some(position.key_fingerprint); - self.dashpay.private_payments_unrestored |= position.private_payments_unresolved; + self.dashpay.private_payments_unrestored = position.private_payments_unresolved; } /// The reservations of this identity's private payments from an earlier @@ -1284,6 +1289,26 @@ mod tests { WalletPersister::new([0u8; 32], Arc::new(Failing)) } + /// The private scan state replayed last decides an identity's load-time + /// hold: a later record cleared lifts an earlier marked one. + #[test] + fn should_take_the_last_replayed_private_payment_marker() { + let mut managed = create_test_identity([0x31; 32]); + let marked = |unresolved: bool| PrivateContactRequestsPosition { + private_payments_unresolved: unresolved, + ..managed_position() + }; + managed.apply_private_contact_requests_position(marked(true)); + assert!(managed.dashpay().private_payments_unrestored()); + managed.apply_private_contact_requests_position(marked(false)); + assert!(!managed.dashpay().private_payments_unrestored()); + } + + /// A private scan state record with nothing marked. + fn managed_position() -> PrivateContactRequestsPosition { + create_test_identity([0x32; 32]).private_contact_requests_position_record() + } + fn create_test_identity(id_bytes: [u8; 32]) -> ManagedIdentity { let identity_v0 = IdentityV0 { id: Identifier::from(id_bytes), From 94b4c8327609ce9d710250304f7eaf1c1f9d022a Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 9 Nov 2026 20:22:09 +0700 Subject: [PATCH 101/117] fix(platform-wallet-ffi): keep code 70 for genuine protocol-version refusals A ProtocolVersionNotActive refusal whose query for the network's version failed because of the SDK's own setup (a context provider that cannot give quorum keys at all) mapped to ErrorProtocolVersionNotActive (70), which tells a host the network runs an earlier version. Code 70 is now kept for a refusal the network's verified version explains, or a pinned SDK; any refusal whose version query failed takes the catch-all, as a transient one already did. A test checks the readiness conversion with each flag set alone. Co-Authored-By: Claude Opus 5.5 --- .../src/contact_request.rs | 45 +++++++++++++++++++ packages/rs-platform-wallet-ffi/src/error.rs | 33 ++++++++++---- 2 files changed, 70 insertions(+), 8 deletions(-) diff --git a/packages/rs-platform-wallet-ffi/src/contact_request.rs b/packages/rs-platform-wallet-ffi/src/contact_request.rs index 918e289c170..217e36c4651 100644 --- a/packages/rs-platform-wallet-ffi/src/contact_request.rs +++ b/packages/rs-platform-wallet-ffi/src/contact_request.rs @@ -301,6 +301,51 @@ pub unsafe extern "C" fn platform_wallet_get_private_contact_readiness( #[cfg(test)] mod tests { use super::*; + + /// Each readiness flag reaches its own FFI field, alone: a conversion + /// that swapped or cleared one would show here. + #[test] + fn should_convert_each_readiness_flag_to_its_own_field() { + let restored = PrivateContactReadinessFFI::from(PrivateContactReadiness { + private_payments_restored: true, + private_store_read: false, + unresolved_private_payment: false, + }); + assert_eq!( + restored, + PrivateContactReadinessFFI { + private_payments_restored: true, + private_store_read: false, + unresolved_private_payment: false, + } + ); + let read = PrivateContactReadinessFFI::from(PrivateContactReadiness { + private_payments_restored: false, + private_store_read: true, + unresolved_private_payment: false, + }); + assert_eq!( + read, + PrivateContactReadinessFFI { + private_payments_restored: false, + private_store_read: true, + unresolved_private_payment: false, + } + ); + let unresolved = PrivateContactReadinessFFI::from(PrivateContactReadiness { + private_payments_restored: false, + private_store_read: false, + unresolved_private_payment: true, + }); + assert_eq!( + unresolved, + PrivateContactReadinessFFI { + private_payments_restored: false, + private_store_read: false, + unresolved_private_payment: true, + } + ); + } use dpp::prelude::Identifier; /// The readiness of a fresh wallet's identity: nothing held, the store not diff --git a/packages/rs-platform-wallet-ffi/src/error.rs b/packages/rs-platform-wallet-ffi/src/error.rs index 002f7d475ce..65fe2cbac3a 100644 --- a/packages/rs-platform-wallet-ffi/src/error.rs +++ b/packages/rs-platform-wallet-ffi/src/error.rs @@ -653,8 +653,10 @@ pub enum PlatformWalletFFIResultCode { /// at a protocol version below the one that serves it (for example /// DashPay's private contact request store before protocol version 15), /// or the SDK is pinned below it. Expected on such a network; it lifts - /// when the network upgrades. A refusal that may lift sooner, because the - /// network's version could not be confirmed, keeps the catch-all code. + /// when the network upgrades. A refusal whose query for the network's + /// version failed keeps the catch-all code, whether that may pass or comes + /// from the SDK's own setup (a context provider that cannot give quorum + /// keys at all), as neither shows the network runs an earlier version. ErrorProtocolVersionNotActive = 70, /// Maps `PlatformWalletError::PrivateContactRequestPending`: a private @@ -1265,12 +1267,17 @@ impl From for PlatformWalletFFIResult { // `MessageSigningFailed` reason exists, any marker in it sits // mid-string and is deliberately not matched. // The network does not serve the request yet, as its proven - // protocol version shows. A refusal that may lift soon (the query - // for the version failed for a reason that can pass) stays on the - // catch-all: that is the network failing, which a host surfaces. - PlatformWalletError::Sdk( - sdk_error @ dash_sdk::Error::ProtocolVersionNotActive { .. }, - ) if !sdk_error.is_transient_protocol_version_refusal() => { + // protocol version shows, or the SDK is pinned below it. A + // refusal whose query for the version failed stays on the + // catch-all, whether that may pass (the network failing) or not + // (a context provider configured so it cannot give quorum keys: + // this SDK's own setup), as neither says the network runs an + // earlier version. + PlatformWalletError::Sdk(dash_sdk::Error::ProtocolVersionNotActive { + cause, + pinned, + .. + }) if *pinned || cause.is_none() => { PlatformWalletFFIResultCode::ErrorProtocolVersionNotActive } _ => PlatformWalletFFIResultCode::ErrorUnknown, @@ -2833,6 +2840,16 @@ mod private_contact_request_error_tests { "no node answered".to_string(), )))); assert_eq!(transient.code, PlatformWalletFFIResultCode::ErrorUnknown); + + // A provider that cannot give quorum keys at all is this SDK's setup, + // not the network's version: the catch-all too. + let misconfigured = refused(Some(Box::new(dash_sdk::Error::ContextProviderError( + dash_sdk::error::ContextProviderError::Config("no quorum source".to_string()), + )))); + assert_eq!( + misconfigured.code, + PlatformWalletFFIResultCode::ErrorUnknown + ); } #[test] From 360fb00edab59230ca9659aa11f8e4658f0e9478 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 9 Nov 2026 20:22:09 +0700 Subject: [PATCH 102/117] test(platform-wallet): restore a saved private payment in both startup orders Both startup-order tests ran on an empty shielded store and checked only the wallet hold. They now save a private contact request payment, its redrive and journal records, for an identity the bound wallet manages, and check that loading before and after configuring the store both release the wallet and hold the payment's pair. Co-Authored-By: Claude Opus 5.5 --- .../rs-platform-wallet/src/manager/load.rs | 110 +++++++++++++++--- 1 file changed, 94 insertions(+), 16 deletions(-) diff --git a/packages/rs-platform-wallet/src/manager/load.rs b/packages/rs-platform-wallet/src/manager/load.rs index 8e2148b8a7f..cad98556b14 100644 --- a/packages/rs-platform-wallet/src/manager/load.rs +++ b/packages/rs-platform-wallet/src/manager/load.rs @@ -1956,12 +1956,25 @@ mod private_payment_hold_tests { }; use crate::events::PlatformEventHandler; use crate::test_support::NoopTestEventHandler; + use crate::wallet::identity::ManagedIdentity; use crate::wallet::platform_wallet::WalletId; - use crate::wallet::shielded::SubwalletId; + use crate::wallet::shielded::store::PendingRedrive; + use crate::wallet::shielded::{ + FileBackedShieldedStore, PrivateContactPayment, ShieldedStore, SubwalletId, + }; use crate::PlatformWalletManager; + use dpp::identity::v0::IdentityV0; + use dpp::prelude::Identifier; + + /// The identity the bound wallet manages, which paid for a private + /// contact request to [`RECIPIENT`] in an earlier process. + const SENDER: [u8; 32] = [0xB1; 32]; + /// The identity that private request is to. + const RECIPIENT: [u8; 32] = [0xA1; 32]; /// Two wallets, of which only `bound` has a persisted shielded viewing - /// key: it bound a shielded account in an earlier process. + /// key: it bound a shielded account in an earlier process, and manages + /// [`SENDER`]. struct ShieldedTracePersister { wallets: Vec<(Wallet, ManagedWalletInfo)>, bound: WalletId, @@ -1985,12 +1998,28 @@ mod private_payment_hold_tests { .wallets .iter() .map(|(wallet, managed)| { + let wallet_id = wallet.compute_wallet_id(); + let mut identity_manager = IdentityManagerStartState::default(); + if wallet_id == self.bound { + let sender = ManagedIdentity::new( + dpp::identity::Identity::V0(IdentityV0 { + id: Identifier::from(SENDER), + public_keys: BTreeMap::new(), + balance: 0, + revision: 1, + }), + 0, + ); + identity_manager + .wallet_identities + .insert(wallet_id, BTreeMap::from([(0, sender)])); + } ( - wallet.compute_wallet_id(), + wallet_id, ClientWalletStartState { wallet: wallet.clone(), wallet_info: managed.clone(), - identity_manager: IdentityManagerStartState::default(), + identity_manager, unused_asset_locks: BTreeMap::new(), unconfirmed_outgoing_txs: Vec::new(), dashpay_backfill: Default::default(), @@ -2052,42 +2081,91 @@ mod private_payment_hold_tests { .private_payments_unrestored() } + /// Save, in the shielded store at `path`, the bound wallet's payment for a + /// private contact request from [`SENDER`] to [`RECIPIENT`] that may + /// still execute, as an earlier process left it: its redrive record and + /// its journal record. + fn save_payment(path: &std::path::Path, bound: WalletId) { + let mut store = FileBackedShieldedStore::open_path(path, 100).expect("the store"); + store + .arm_private_contact_payment( + SubwalletId::new(bound, 0), + PendingRedrive { + activity_id: [0x21; 32], + anchor: [0x22; 32], + nullifiers: vec![[0x23; 32]], + st_bytes: vec![0xAB; 96], + attempts: 0, + identity_nonce_finalized: false, + identity_user_abandoned: false, + }, + PrivateContactPayment { + sender: SENDER, + recipient: RECIPIENT, + account_reference: 0, + }, + &[[0xC1; 32]], + ) + .expect("the payment saved"); + } + + /// Whether `wallet_id`'s [`SENDER`] holds its pair with [`RECIPIENT`], + /// which refuses every request to it, public or private. + async fn pair_held( + manager: &PlatformWalletManager, + wallet_id: WalletId, + ) -> bool { + let wallet = manager.get_wallet(&wallet_id).await.expect("loaded"); + let wm = wallet.wallet_manager().read().await; + wm.get_wallet_info(&wallet_id) + .and_then(|info| { + info.identity_manager + .managed_identity(&Identifier::from(SENDER)) + }) + .is_some_and(|sender| { + sender + .dashpay() + .private_reply_pending(&Identifier::from(RECIPIENT)) + }) + } + /// A loaded wallet that bound a shielded account before holds its public /// contact requests until the shielded store is configured, which - /// restores its private payments with no keys; one that never bound a - /// shielded account is never held. + /// restores its private payment's pair with no keys and releases the + /// wallet; one that never bound a shielded account is never held. #[tokio::test] async fn should_hold_a_loaded_wallet_that_bound_a_shielded_account_until_the_store_opens() { let (manager, bound, unbound) = manager(); + let dir = tempfile::tempdir().expect("a directory"); + let path = dir.path().join("tree.sqlite"); + save_payment(&path, bound); manager.load_from_persistor().await.expect("loaded"); assert!( held(&manager, bound).await, "may have an unrestored payment" ); + assert!(!pair_held(&manager, bound).await, "not restored yet"); assert!( !held(&manager, unbound).await, "never bound a shielded account" ); - let dir = tempfile::tempdir().expect("a directory"); - manager - .configure_shielded(dir.path().join("tree.sqlite")) - .await - .expect("configured"); + manager.configure_shielded(&path).await.expect("configured"); assert!(!held(&manager, bound).await, "restored from the store"); + assert!(pair_held(&manager, bound).await, "the pair is held"); } /// A wallet loaded once the shielded store is configured is restored by - /// the load itself. + /// the load itself, its private payment's pair held. #[tokio::test] async fn should_restore_a_wallet_loaded_once_the_store_is_configured() { let (manager, bound, _) = manager(); let dir = tempfile::tempdir().expect("a directory"); - manager - .configure_shielded(dir.path().join("tree.sqlite")) - .await - .expect("configured"); + let path = dir.path().join("tree.sqlite"); + save_payment(&path, bound); + manager.configure_shielded(&path).await.expect("configured"); manager.load_from_persistor().await.expect("loaded"); assert!(!held(&manager, bound).await, "restored by the load"); + assert!(pair_held(&manager, bound).await, "the pair is held"); } } From 26de92325f059630d4700ee5e000d75568b97434 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 9 Nov 2026 20:22:39 +0700 Subject: [PATCH 103/117] fix(drive): drop the argument #5798 removed from add_document_for_contract in a proof plan test #5774 added proof_plan_equivalence_tests.rs calling add_document_for_contract with the argument #5798 removed, so drive's tests did not compile on v5.1-dev. Co-Authored-By: Claude Opus 5.5 --- packages/rs-drive/src/query/proof_plan_equivalence_tests.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/rs-drive/src/query/proof_plan_equivalence_tests.rs b/packages/rs-drive/src/query/proof_plan_equivalence_tests.rs index cba825d9984..19e4d800231 100644 --- a/packages/rs-drive/src/query/proof_plan_equivalence_tests.rs +++ b/packages/rs-drive/src/query/proof_plan_equivalence_tests.rs @@ -120,7 +120,6 @@ fn populated_drive(contract: &DataContract, platform_version: &PlatformVersion) true, None, platform_version, - None, ) .expect("insert a widget"); } From 8728bcd192e32a427f7312ce4071cac8addf60cc Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 9 Nov 2026 20:35:11 +0700 Subject: [PATCH 104/117] test(platform-wallet): rebind identically while a scan holds the shielded store An identical rebind returns without waiting for a store a scan holds: the wallet is held until the restoration it defers runs once the store frees, and the private payment's pair stays held throughout. Co-Authored-By: Claude Opus 5.5 --- .../network/private_contact_requests.rs | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/private_contact_requests.rs b/packages/rs-platform-wallet/src/wallet/identity/network/private_contact_requests.rs index 545ef31c3e7..0505621dcb7 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/private_contact_requests.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/private_contact_requests.rs @@ -6707,6 +6707,35 @@ mod tests { pending_to(&restarted, BOB, &alice).await, "the bind restores the reservation" ); + // An identical rebind while a scan holds the store returns without + // waiting for it: the wallet is held until the restoration, deferred + // to a task, runs once the store frees, and the pair stays held. + { + let scan = after.store().write().await; + tokio::time::timeout( + Duration::from_secs(5), + restarted.bind_shielded(&seed(BOB_PHRASE), &[0], &after), + ) + .await + .expect("the rebind does not wait for the store") + .expect("rebound"); + assert!( + restarted.private_payments_held().await, + "held until the deferred restoration" + ); + drop(scan); + } + for _ in 0..100 { + if !restarted.private_payments_held().await { + break; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + assert!( + !restarted.private_payments_held().await, + "the deferred restoration released the wallet" + ); + assert!(pending_to(&restarted, BOB, &alice).await); let accepted = restarted .identity() From 5c99f467fd3e24d23975bfe70e3f0dfd58dfbb37 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 9 Nov 2026 21:43:38 +0700 Subject: [PATCH 105/117] fix(platform-wallet): count no endpoint verdict as proof a shielded spend never left the wallet The SDK decodes a consensus error from an endpoint's dash-serialized-consensus-error-bin metadata with no proof or quorum signature behind it. A malicious endpoint could keep or forward a signed private contact request payment and answer with a valid-looking verdict: the first dispatch then counted as never delivered, which discharged the journal record, released the notes and freed the pair. - A broadcast is never delivered only when it was refused before anything was sent (no address, or the SDK's own refusal). Any endpoint answer, a consensus verdict included, keeps it maybe delivered: the spend waits for its result and stays unconfirmed, notes and journal record kept, until a read-back or a proven expiry resolves it. The same holds for a verdict on the result wait of every note spend, and of an identity create from the pool, which now falls through to its fetch-by-id fallback. - The tracker keeps the SDK's configured retry budget (one dispatch and its effective retries) instead of six dispatches, so a client configured with no retries dispatches once. Co-Authored-By: Claude Opus 5.5 --- packages/rs-platform-wallet-ffi/src/error.rs | 14 +- .../src/shielded_send.rs | 7 +- packages/rs-platform-wallet/README.md | 4 +- .../src/broadcast_outcome.rs | 189 ++++++++++++++---- .../network/private_contact_requests.rs | 118 +++++++++++ .../src/wallet/shielded/operations.rs | 135 ++++++------- 6 files changed, 342 insertions(+), 125 deletions(-) diff --git a/packages/rs-platform-wallet-ffi/src/error.rs b/packages/rs-platform-wallet-ffi/src/error.rs index 65fe2cbac3a..ab7f47a7aff 100644 --- a/packages/rs-platform-wallet-ffi/src/error.rs +++ b/packages/rs-platform-wallet-ffi/src/error.rs @@ -136,12 +136,14 @@ pub enum PlatformWalletFFIResultCode { ErrorWalletAlreadyExists = 15, /// Maps `PlatformWalletError::ShieldedBroadcastFailed`. The shielded /// transition (identity-create, unshield, transfer, or withdrawal) was - /// DEFINITIVELY not executed — either it never left the wallet (CheckTx - /// refused it with no earlier attempt that could have delivered it, or - /// there was nowhere to send it), or Platform reported the transition's - /// own execution error. A broadcast that may have been delivered is never - /// reported here: it is [`Self::ErrorShieldedSpendUnconfirmed`]. Any note - /// reservations were released and the caller is free to retry. + /// DEFINITIVELY not executed. For a shielded spend that consumes notes, it + /// never left the wallet: it was refused before anything was sent (no + /// address to send it to, or the SDK's own refusal). An endpoint's verdict + /// proves nothing for such a spend, as no proof backs it: that is + /// [`Self::ErrorShieldedSpendUnconfirmed`], as is any broadcast that may + /// have been delivered. A shield into the pool also reports here a CheckTx + /// or execution verdict. Any note reservations were released and the + /// caller is free to retry. /// For identity-create, the new identity does NOT exist and /// `out_identity_id` is left untouched (still zeroed). ErrorShieldedBroadcastFailed = 16, diff --git a/packages/rs-platform-wallet-ffi/src/shielded_send.rs b/packages/rs-platform-wallet-ffi/src/shielded_send.rs index 0fcd3b44f14..052c409558e 100644 --- a/packages/rs-platform-wallet-ffi/src/shielded_send.rs +++ b/packages/rs-platform-wallet-ffi/src/shielded_send.rs @@ -649,10 +649,9 @@ pub(crate) fn map_spend_result( PlatformWalletFFIResultCode::ErrorShieldedNoRecordedAnchor, format!("Wallet is still syncing to a confirmed state — try again shortly. ({e})"), ), - // Definitive failure: the transition never left the wallet, or Platform - // executed and rejected it; the notes were released and the host may - // retry. A private contact request it paid for may stay pending (71 - // on a retry, `managed_identity_get_private_reply_pending`). + // Definitive failure: the transition never left the wallet (for a note + // spend, refused before anything was sent); the notes were released + // and the host may retry. Err(e @ PlatformWalletError::ShieldedBroadcastFailed(_)) => PlatformWalletFFIResult::err( PlatformWalletFFIResultCode::ErrorShieldedBroadcastFailed, format!("{operation} failed: {e}"), diff --git a/packages/rs-platform-wallet/README.md b/packages/rs-platform-wallet/README.md index dbebdb951e8..8f48f9d23c7 100644 --- a/packages/rs-platform-wallet/README.md +++ b/packages/rs-platform-wallet/README.md @@ -123,7 +123,7 @@ Startup errors distinguish damaged recovery metadata or undecodable signed bytes ## Private contact request payments after a restart -Before a private contact request's payment can reach the network, the shielded store saves a record of it in its private payment journal: the sender and recipient identities, the request's account reference, the commitment of the entry it appends and the nullifiers its spend consumes. The file store writes the journal, and the payment's redrive record, in one transaction on a connection at `synchronous=FULL`, so the record survives a crash or a power loss; a record that cannot be saved refuses the payment before anything is broadcast. The journal is kept apart from the spend's notes and redrive record: no note release, redrive removal, undecodable transition, account rebind or purge removes it. A record is removed only when its entry is read back from DashPay's private store and recorded durably, or when the transition provably never left the wallet (the first node to answer refused it with Platform's consensus verdict, with no earlier attempt that could have delivered it). A payment that never lands otherwise keeps its pair held; that fails closed. +Before a private contact request's payment can reach the network, the shielded store saves a record of it in its private payment journal: the sender and recipient identities, the request's account reference, the commitment of the entry it appends and the nullifiers its spend consumes. The file store writes the journal, and the payment's redrive record, in one transaction on a connection at `synchronous=FULL`, so the record survives a crash or a power loss; a record that cannot be saved refuses the payment before anything is broadcast. The journal is kept apart from the spend's notes and redrive record: no note release, redrive removal, undecodable transition, account rebind or purge removes it. A record is removed only when its entry is read back from DashPay's private store and recorded durably, or when the transition provably never left the wallet (refused before anything was sent: no address to send it to, or the SDK's own refusal). An endpoint's answer, a consensus verdict included, proves nothing: no proof or quorum signature backs it, and an endpoint that kept or forwarded the signed transition can answer with any verdict. A payment that never lands otherwise keeps its pair held; that fails closed. Until a payment's reservation is restored from the journal, nothing shows that the pair is taken: the request's entry is not in DashPay's store until the payment executes. So a reloaded wallet sends and accepts no contact request, public or private, while such a payment may be unrestored, and returns `PrivateContactPaymentsNotRestored` (FFI code 72) with nothing read, signed or broadcast. The in-kind accept is held the same way. Two things hold it: @@ -138,7 +138,7 @@ A restoration fails closed, every hold staying, when the coordinator is for anot ### Shielded spends whose broadcast may have been delivered -Every shielded spend that consumes notes (shielded transfers, unshields, withdrawals, identity top-ups and creations from the pool, and private document creations) broadcasts in single dispatches and keeps what each one proved: the SDK's own retry loop returns only its last error, which cannot show that an earlier attempt was admitted with its response lost. Any dispatch that failed in a way that allows delivery, any transport error whatever its gRPC code, makes the whole broadcast possibly delivered, and a later refusal does not override it. Such a spend waits for its result, and reports `ShieldedSpendUnconfirmed` (FFI code 18) when that wait fails, its notes and redrive record kept so the redrive pass resolves it: a host must not pay again with other notes. Only a spend that never left the wallet reports `ShieldedBroadcastFailed` and releases its notes at once. A re-broadcast refused by a node does not prove that no other node holds the original, so the redrive pass keeps such a spend's notes reserved until it lands or its anchor is pruned. Shields into the pool (from platform addresses or an identity) keep their own handling. +Every shielded spend that consumes notes (shielded transfers, unshields, withdrawals, identity top-ups and creations from the pool, and private document creations) broadcasts in single dispatches and keeps what each one proved: the SDK's own retry loop returns only its last error, which cannot show that an earlier attempt was admitted with its response lost. Any dispatch that reached an endpoint, whatever it answered (a consensus verdict included, which is unauthenticated), or failed in a way that allows delivery, any transport error whatever its gRPC code, makes the whole broadcast possibly delivered. Such a spend waits for its result, and reports `ShieldedSpendUnconfirmed` (FFI code 18) when that wait fails or answers with a verdict, its notes and redrive record kept so the redrive pass resolves it: a host must not pay again with other notes. Only a spend refused before anything was sent reports `ShieldedBroadcastFailed` and releases its notes at once. The dispatches stay within the SDK's configured retry budget: one dispatch and its retries. A re-broadcast refused by a node does not prove that no other node holds the original, so the redrive pass keeps such a spend's notes reserved until it lands or its anchor is pruned. Shields into the pool (from platform addresses or an identity) keep their own handling. ## Shielded balance API migration diff --git a/packages/rs-platform-wallet/src/broadcast_outcome.rs b/packages/rs-platform-wallet/src/broadcast_outcome.rs index 5202b3d4cc0..bcb54c1983e 100644 --- a/packages/rs-platform-wallet/src/broadcast_outcome.rs +++ b/packages/rs-platform-wallet/src/broadcast_outcome.rs @@ -109,11 +109,6 @@ pub(crate) fn broadcast_definitely_failed(e: &dash_sdk::Error) -> bool { } } -/// How many single dispatches [`broadcast_tracking_delivery`] makes at most: -/// the SDK's default budget, one dispatch and five retries. -#[cfg(feature = "shielded")] -const SPEND_BROADCAST_DISPATCHES: usize = 6; - /// What the dispatches of one shielded spend's broadcast proved about its /// delivery ([`broadcast_tracking_delivery`]). #[cfg(feature = "shielded")] @@ -122,31 +117,46 @@ pub(crate) enum BroadcastDelivery { /// A node admitted the transition, or already held it (`AlreadyExists`). Admitted, /// No dispatch proved the transition was left out of every mempool: a - /// dispatch failed in transport, or in any other way that allows its - /// request to have been delivered. It may execute, so the caller waits - /// for its result, and an unconfirmed outcome keeps the notes reserved. + /// dispatch reached an endpoint, which may have kept or forwarded it, + /// whatever it answered (a consensus verdict included, which no proof + /// or quorum signature backs), or failed in a way that allows delivery. + /// It may execute, so the caller waits for its result, and an + /// unconfirmed outcome keeps the notes reserved. MaybeDelivered(dash_sdk::Error), - /// The transition never left the wallet: the first dispatch to reach a - /// node was refused with Platform's consensus verdict, after no dispatch - /// that could have delivered it, or there was no address to send it to, - /// or the SDK refused it before sending. Nothing holds its bytes but the - /// wallet, so it can never execute. + /// The transition never left the wallet: it was refused before its first + /// dispatch was sent, there being no address to send it to or the SDK + /// refusing it, with no dispatch sent before. Nothing holds its bytes but + /// the wallet, so it can never execute. NeverDelivered(dash_sdk::Error), } +/// How many single dispatches [`broadcast_tracking_delivery`] makes at most +/// on `sdk`: its configured overall budget, one dispatch and its effective +/// retries, so a client configured with no retries dispatches once. +#[cfg(feature = "shielded")] +pub(crate) fn spend_broadcast_dispatches(sdk: &dash_sdk::Sdk) -> usize { + sdk.query_settings() + .request_settings + .finalize() + .retries + .saturating_add(1) +} + /// Broadcast a shielded spend `state_transition` in single dispatches (no -/// SDK retry inside a dispatch) at most [`SPEND_BROADCAST_DISPATCHES`] -/// times, keeping what every dispatch proved: the SDK's own retry loop -/// returns only its last error, which cannot show that an earlier attempt -/// was delivered with its response lost. +/// SDK retry inside a dispatch), at most the SDK's configured budget +/// ([`spend_broadcast_dispatches`]), keeping what every dispatch proved: the +/// SDK's own retry loop returns only its last error, which cannot show that +/// an earlier attempt was delivered with its response lost. Centralizing that +/// evidence in the SDK is dashpay/platform#5556; what it means for a payment's +/// notes, journal and contact stays here. /// -/// Any dispatch that failed in a way that allows delivery (every transport -/// error, whatever its gRPC code, a timeout, a cause-less broadcast -/// envelope, an unknown error) makes the whole broadcast -/// [`BroadcastDelivery::MaybeDelivered`]: a later consensus rejection does -/// not override it, as a lagging node can refuse a transition another one -/// admitted. Only a rejection or a refusal with nothing possibly delivered -/// before it is [`BroadcastDelivery::NeverDelivered`]. +/// Any dispatch that reached an endpoint, or failed in a way that allows it +/// (every transport error, whatever its gRPC code, a timeout, a cause-less +/// broadcast envelope, an unknown error), makes the whole broadcast +/// [`BroadcastDelivery::MaybeDelivered`]. So does a consensus verdict: an +/// endpoint's answer is unauthenticated, and one that kept or forwarded the +/// signed transition can answer with any verdict. Only a refusal before +/// anything was sent is [`BroadcastDelivery::NeverDelivered`]. #[cfg(feature = "shielded")] pub(crate) async fn broadcast_tracking_delivery( sdk: &dash_sdk::Sdk, @@ -163,13 +173,17 @@ pub(crate) async fn broadcast_tracking_delivery( }, ..PutSettings::default() }; - track_delivery(|| state_transition.broadcast(sdk, Some(single_dispatch))).await + track_delivery(spend_broadcast_dispatches(sdk), || { + state_transition.broadcast(sdk, Some(single_dispatch)) + }) + .await } /// The classification [`broadcast_tracking_delivery`] makes, over the -/// outcomes `dispatch` returns, one call per single dispatch. +/// outcomes `dispatch` returns, one call per single dispatch, at most +/// `dispatches` of them. #[cfg(feature = "shielded")] -async fn track_delivery(mut dispatch: F) -> BroadcastDelivery +async fn track_delivery(dispatches: usize, mut dispatch: F) -> BroadcastDelivery where F: FnMut() -> Fut, Fut: std::future::Future>, @@ -177,12 +191,14 @@ where use dash_sdk::dapi_client::DapiClientError; let mut maybe_delivered: Option = None; - for _ in 0..SPEND_BROADCAST_DISPATCHES { + for _ in 0..dispatches.max(1) { let error = match dispatch().await { Ok(()) | Err(dash_sdk::Error::AlreadyExists(_)) => return BroadcastDelivery::Admitted, Err(error) => error, }; - let nothing_sent = matches!( + // Refused before anything was sent: no address, or the SDK's own + // refusal of a kind the network does not serve yet. + let never_sent = matches!( error, dash_sdk::Error::DapiClientError(DapiClientError::NoAvailableAddresses) | dash_sdk::Error::ProtocolVersionNotActive { .. } @@ -194,18 +210,21 @@ where dash_sdk::Error::DapiClientError(DapiClientError::NoAvailableAddresses) ) ); - if carries_consensus_rejection(&error) || nothing_sent { - // Final: a verdict, or nowhere left to send it. + if never_sent { return match maybe_delivered { Some(_) => BroadcastDelivery::MaybeDelivered(error), None => BroadcastDelivery::NeverDelivered(error), }; } + // An endpoint's verdict ends the dispatches, and proves nothing. + if carries_consensus_rejection(&error) { + return BroadcastDelivery::MaybeDelivered(error); + } maybe_delivered = Some(error); } match maybe_delivered { Some(error) => BroadcastDelivery::MaybeDelivered(error), - // Unreachable with a non-zero budget: every pass sets it or returns. + // Unreachable: every pass sets it or returns. None => BroadcastDelivery::MaybeDelivered(dash_sdk::Error::Generic( "no broadcast dispatch was made".to_string(), )), @@ -233,6 +252,21 @@ mod tests { dash_sdk::Error::NoAvailableAddressesToRetry(Box::new(copy(inner))) } dash_sdk::Error::Protocol(dpp::ProtocolError::ConsensusError(_)) => rejection(), + dash_sdk::Error::ProtocolVersionNotActive { + method, + required, + feature, + network, + pinned, + .. + } => dash_sdk::Error::ProtocolVersionNotActive { + method, + required: *required, + feature: *feature, + network: *network, + pinned: *pinned, + cause: None, + }, other => dash_sdk::Error::Generic(other.to_string()), } } @@ -260,7 +294,7 @@ mod tests { outcome: impl Fn(usize) -> Result<(), dash_sdk::Error>, ) -> (BroadcastDelivery, usize) { let dispatched = Mutex::new(0usize); - let delivery = track_delivery(|| { + let delivery = track_delivery(BUDGET, || { let mut count = dispatched.lock().expect("count"); let result = outcome(*count); *count += 1; @@ -271,6 +305,79 @@ mod tests { (delivery, dispatched) } + /// The dispatch budget the tests run with. + const BUDGET: usize = 6; + + /// An endpoint that kept or forwarded the transition can answer the + /// first dispatch with a consensus verdict, as serialized metadata the + /// SDK decodes with no proof behind it: the broadcast stays maybe + /// delivered, so its payment stays unconfirmed with its notes, journal + /// record and contact hold, and the dispatches end there. + #[tokio::test] + async fn should_keep_a_broadcast_an_endpoint_refused_maybe_delivered() { + use dash_sdk::dapi_grpc::tonic::metadata::{MetadataMap, MetadataValue}; + use dpp::serialization::PlatformSerializableWithPlatformVersion; + use dpp::version::PlatformVersion; + + let verdict = || { + let bytes = dpp::consensus::ConsensusError::BasicError( + dpp::consensus::basic::BasicError::UnsupportedFeatureError( + dpp::consensus::basic::UnsupportedFeatureError::new("test".to_string(), 1), + ), + ) + .serialize_to_bytes_with_platform_version(PlatformVersion::latest()) + .expect("serialized"); + let mut metadata = MetadataMap::new(); + metadata.insert_bin( + "dash-serialized-consensus-error-bin", + MetadataValue::from_bytes(&bytes), + ); + dash_sdk::Error::from(DapiClientError::Transport(TransportError::Grpc( + Status::with_metadata(Code::InvalidArgument, "rejected", metadata), + ))) + }; + assert!(carries_consensus_rejection(&verdict()), "{:?}", verdict()); + let (delivery, dispatched) = tracked(|_| Err(verdict())).await; + assert!( + matches!(delivery, BroadcastDelivery::MaybeDelivered(_)), + "{delivery:?}" + ); + assert_eq!(dispatched, 1); + } + + /// The tracker keeps the SDK's configured budget: a client configured + /// with no retries dispatches once, one with three retries four times. + #[tokio::test] + async fn should_keep_the_sdk_s_configured_retry_budget() { + use dash_sdk::dapi_client::RequestSettings; + + let no_retries = dash_sdk::SdkBuilder::new_mock() + .with_settings(RequestSettings { + retries: Some(0), + ..RequestSettings::default() + }) + .build() + .expect("an SDK"); + assert_eq!(spend_broadcast_dispatches(&no_retries), 1); + let three_retries = dash_sdk::SdkBuilder::new_mock() + .with_settings(RequestSettings { + retries: Some(3), + ..RequestSettings::default() + }) + .build() + .expect("an SDK"); + assert_eq!(spend_broadcast_dispatches(&three_retries), 4); + + let dispatched = Mutex::new(0usize); + let delivery = track_delivery(spend_broadcast_dispatches(&no_retries), || { + *dispatched.lock().expect("count") += 1; + std::future::ready(Err(grpc(Code::Unavailable))) + }) + .await; + assert!(matches!(delivery, BroadcastDelivery::MaybeDelivered(_))); + assert_eq!(*dispatched.lock().expect("count"), 1); + } + /// An attempt admitted with its response lost, then retries refused for /// any reason, leaves the broadcast maybe delivered: the last error /// alone cannot show the transition never left. @@ -314,16 +421,24 @@ mod tests { matches!(delivery, BroadcastDelivery::MaybeDelivered(_)), "{code:?}: {delivery:?}" ); - assert_eq!(dispatched, SPEND_BROADCAST_DISPATCHES, "{code:?}"); + assert_eq!(dispatched, BUDGET, "{code:?}"); } } - /// Only a consensus verdict, or nowhere to send the transition, before - /// any dispatch that could have delivered it shows it never left. + /// Only a refusal before anything was sent, nowhere to send the + /// transition or the SDK refusing it, with no dispatch sent before, + /// shows it never left. #[tokio::test] async fn should_count_a_broadcast_never_delivered_only_without_an_earlier_ambiguity() { for first in [ - rejection(), + dash_sdk::Error::ProtocolVersionNotActive { + method: "broadcast_state_transition", + required: 15, + feature: None, + network: 14, + pinned: false, + cause: None, + }, dash_sdk::Error::DapiClientError(DapiClientError::NoAvailableAddresses), dash_sdk::Error::NoAvailableAddressesToRetry(Box::new( dash_sdk::Error::DapiClientError(DapiClientError::NoAvailableAddresses), diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/private_contact_requests.rs b/packages/rs-platform-wallet/src/wallet/identity/network/private_contact_requests.rs index 0505621dcb7..05b6cc21eab 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/private_contact_requests.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/private_contact_requests.rs @@ -6023,6 +6023,124 @@ mod tests { assert_eq!(reply.lock().expect("reply").len(), 1, "the retry pays"); } + /// An endpoint that kept the submitted payment and answered with a + /// consensus verdict proves nothing, so the payment settles unconfirmed + /// with its journal record saved: a public request to the pair, which + /// would disclose it, and a replacement private payment, which would fund + /// a second request, are both refused while the original may execute. + #[cfg(feature = "shielded")] + #[tokio::test] + async fn should_keep_a_pair_held_when_an_endpoint_refuses_its_payment() { + let alice = identity(ALICE_PHRASE, ALICE); + let bob = identity(BOB_PHRASE, BOB); + let alice_crypto = SeedCryptoProvider::from_seed(seed(ALICE_PHRASE), Network::Testnet); + let alice_signer = signer(ALICE_PHRASE, &alice); + let (_manager, alice_wallet) = wallet(ALICE_PHRASE, &alice, &[&alice, &bob], vec![]).await; + let dir = tempfile::tempdir().expect("temporary shielded store"); + let store = Arc::new(tokio::sync::RwLock::new( + FileBackedShieldedStore::open_path(dir.path().join("tree.sqlite"), 100) + .expect("the store"), + )); + let subwallet = SubwalletId::new(alice_wallet.wallet_id(), 0); + let pay = { + let store = Arc::clone(&store); + let (sender, recipient) = (alice.id(), bob.id()); + move |entry: BinaryData, started: PaymentStarted| async move { + let transition = private_contact_request_payment(entry); + arm_redrive_before_broadcast( + &store, + subwallet, + Some([0x7A; 32]), + [7; 32], + vec![[1; 32]], + &transition, + "private document create from shielded pool", + Some(PrivateContactPayment { + sender: sender.to_buffer(), + recipient: recipient.to_buffer(), + account_reference: started + .account_reference() + .expect("the send names its request"), + }), + Some(&started), + ) + .await?; + // What the payment returns once the endpoint answered with a + // serialized consensus verdict: unconfirmed. + Err::(PlatformWalletError::ShieldedSpendUnconfirmed { + operation: "private document create from shielded pool", + reason: "the endpoint answered with a consensus verdict".to_string(), + }) + } + }; + let view = alice_wallet.identity().dashpay(); + let sent = view + .send_private_contact_request( + &alice.id(), + &bob.id(), + None, + AutoAcceptProofSource::None, + None, + &alice_signer, + &alice_crypto, + pay, + ) + .await; + assert!( + matches!( + sent, + Err(PlatformWalletError::ShieldedSpendUnconfirmed { .. }) + ), + "{sent:?}" + ); + assert_eq!( + store + .read() + .await + .private_payment_journal(alice_wallet.wallet_id()) + .expect("the journal") + .len(), + 1, + "the journal record stays" + ); + let public = view + .send_contact_request_with_external_signer( + &alice.id(), + &bob.id(), + None, + AutoAcceptProofSource::None, + &alice_signer, + &alice_crypto, + ) + .await; + assert!( + matches!( + public, + Err(PlatformWalletError::PrivateContactRequestPending(_)) + ), + "no disclosure: {public:?}" + ); + let replacement = view + .send_private_contact_request( + &alice.id(), + &bob.id(), + None, + AutoAcceptProofSource::None, + None, + &alice_signer, + &alice_crypto, + no_payment, + ) + .await; + assert!( + matches!( + replacement, + Err(PlatformWalletError::PrivateContactRequestPending(_)) + ), + "no replacement funding: {replacement:?}" + ); + } + /// A payment that may have reached the network keeps its pair pending /// whatever error it returns, as its transition may still execute; one /// whose transition provably never left the wallet releases it. diff --git a/packages/rs-platform-wallet/src/wallet/shielded/operations.rs b/packages/rs-platform-wallet/src/wallet/shielded/operations.rs index edf81fb1c66..571e4020e09 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/operations.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/operations.rs @@ -2662,8 +2662,8 @@ where .map_err(|e| PlatformWalletError::ShieldedBuildError(e.to_string()))?; // Broadcast (relay-ACK only). Only a transition that never left the wallet - // (`BroadcastDelivery::NeverDelivered`: a consensus verdict with no dispatch that could - // have delivered it before, or nowhere to send it) maps to `ShieldedBroadcastFailed`, + // (`BroadcastDelivery::NeverDelivered`: refused before anything was sent) maps to + // `ShieldedBroadcastFailed`, // which lets the outer match release the reservation. Every other failure may have // delivered it and falls through to the result wait below, whose ambiguous arm owns the // fetch-by-derived-id fallback — so the in-flight tx gets confirmed (or held as @@ -2683,18 +2683,14 @@ where } } - // Wait for proven execution. Classify the failure: - // - `StateTransitionBroadcastError` WITH a consensus `cause` = Platform DEFINITIVELY - // reported the transition's own execution error (it ran and was rejected on its - // merits; the serialized consensus error is the verdict). The identity does not - // exist; keep today's behavior (release the reservation via - // `ShieldedBroadcastFailed`). - // - any other error (DriveProofError / Proof / InvalidProvedResponse / TimeoutReached / - // DapiClientError / …) = AMBIGUOUS: the broadcast was accepted and the transition may - // have executed even though we couldn't fetch/verify its result proof. That includes - // cause-less `StateTransitionBroadcastError`s — DAPI encodes its own wait-side failures - // (timeouts, internal errors) that way, with empty consensus data. Fall back to - // fetching the identity by its pre-derived id before deciding it doesn't exist. + // Wait for proven execution. Every failure is AMBIGUOUS: the broadcast reached an + // endpoint and the transition may have executed even though we couldn't fetch or + // verify its result proof. That includes a `StateTransitionBroadcastError` with a + // consensus `cause`: the endpoint that reports it is unauthenticated, no proof or quorum + // signature backs it, and one that kept or forwarded the transition can answer with any + // verdict. It also includes cause-less ones, as DAPI encodes its own wait-side failures + // (timeouts, internal errors) that way. Fall back to fetching the identity by its + // pre-derived id before deciding it is unconfirmed. // An identity-create-from-shielded-pool proof authenticates the spent // nullifiers and resulting identity as a snapshot; it cannot bind the // complete Orchard request, so accept the affected-state outcome. @@ -2703,9 +2699,6 @@ where .await { Ok(result) => result, - Err(dash_sdk::Error::StateTransitionBroadcastError(e)) if e.cause.is_some() => { - return Err(PlatformWalletError::ShieldedBroadcastFailed(e.to_string())); - } Err(wait_err) => { warn!( derived_id = %identity_id, @@ -4143,48 +4136,42 @@ async fn broadcast_shielded_spend_classified( } /// The error a spend whose transition never left the wallet -/// ([`BroadcastDelivery::NeverDelivered`]) is reported as: a promoted -/// address-nonce error, or a failed broadcast. Its notes are released. Every -/// broadcast that may have been delivered waits for its result instead, and +/// ([`BroadcastDelivery::NeverDelivered`], refused before anything was sent) +/// is reported as: a failed broadcast. Its notes are released. Every +/// broadcast that reached an endpoint waits for its result instead, and /// reads as unconfirmed when that wait fails. fn definite_broadcast_failure(e: &dash_sdk::Error) -> PlatformWalletError { - crate::error::promote_address_nonce_error(e) - .unwrap_or_else(|| PlatformWalletError::ShieldedBroadcastFailed(e.to_string())) + PlatformWalletError::ShieldedBroadcastFailed(e.to_string()) } /// Classify a `wait_for_response` failure for an already-broadcast -/// shielded spend (see [`broadcast_shielded_spend`]). +/// shielded spend (see [`broadcast_shielded_spend`]): unconfirmed, whatever +/// it is, the notes staying reserved. /// -/// Only a consensus verdict ([`carries_consensus_rejection`]) proves -/// Platform executed the transition and rejected it on its merits — the -/// serialized consensus error is the verdict. DAPI encodes its own +/// A consensus verdict on the wait ([`carries_consensus_rejection`]) is no +/// proof either: the endpoint that sends it is unauthenticated, no proof or +/// quorum signature backs it, and one that kept or forwarded the signed +/// transition can answer with any verdict. DAPI also encodes its own /// wait-side failures (timeouts, internal errors; see /// `build_wait_for_state_transition_error_response` in rs-dapi) as -/// `StateTransitionBroadcastError`s with EMPTY consensus data, which -/// the SDK surfaces as `cause: None` — those are ambiguous, not -/// rejections, and must keep the note reservations. Erring this way is -/// the safe direction: misreading a rejection as ambiguous only delays -/// note release until the next sync or restart, while misreading a -/// timeout as a rejection re-frees notes whose nullifiers may already -/// be consumed on chain. +/// `StateTransitionBroadcastError`s with EMPTY consensus data. A spend +/// reported failed while it may still execute could be paid again with other +/// notes; one that never executes is released by the redrive and prune +/// passes once its anchor is pruned. fn classify_spend_wait_failure( operation: &'static str, wait_err: &dash_sdk::Error, ) -> PlatformWalletError { - if carries_consensus_rejection(wait_err) { - crate::error::promote_address_nonce_error(wait_err) - .unwrap_or_else(|| PlatformWalletError::ShieldedBroadcastFailed(wait_err.to_string())) - } else { - warn!( - operation, - error = %wait_err, - "Shielded spend broadcast accepted but result confirmation failed; \ - leaving the note reservations in place" - ); - PlatformWalletError::ShieldedSpendUnconfirmed { - operation, - reason: wait_err.to_string(), - } + warn!( + operation, + error = %wait_err, + rejected = carries_consensus_rejection(wait_err), + "Shielded spend broadcast but its result is unconfirmed; leaving the note reservations \ + in place" + ); + PlatformWalletError::ShieldedSpendUnconfirmed { + operation, + reason: wait_err.to_string(), } } @@ -5747,16 +5734,18 @@ mod classify_spend_wait_failure_tests { }) } + /// A broadcast error carrying a consensus cause comes from an + /// unauthenticated endpoint, with no proof behind it: the spend stays + /// unconfirmed and its reservation stays. #[test] - fn consensus_cause_is_a_definitive_rejection() { + fn should_read_a_wait_stage_consensus_verdict_as_unconfirmed() { let cause = ConsensusError::BasicError(BasicError::ProtocolVersionParsingError( ProtocolVersionParsingError::new("bad version".to_string()), )); let err = classify_spend_wait_failure("unshield", &broadcast_err(Some(cause))); assert!( - matches!(err, PlatformWalletError::ShieldedBroadcastFailed(_)), - "a broadcast error carrying a consensus cause means Platform ran and \ - rejected the transition; the caller may release the reservation" + matches!(err, PlatformWalletError::ShieldedSpendUnconfirmed { .. }), + "{err:?}" ); } @@ -5808,18 +5797,19 @@ mod classify_spend_wait_failure_tests { dash_sdk::Error::Protocol(dpp::ProtocolError::ConsensusError(Box::new(cause))) } - /// A CheckTx consensus rejection surfacing from `broadcast()` IS - /// definitive: the transition was evaluated and refused, it sits in no - /// mempool, and the caller may release the reservations and retry. + /// A consensus rejection surfacing from `broadcast()` stays definitive + /// for the shields into the pool that classify with + /// `broadcast_definitely_failed`, which move funds to the wallet's own + /// notes. On a note spend's wait the same verdict reads as unconfirmed: + /// no proof backs it. #[test] - fn broadcast_consensus_rejection_is_definitive() { + fn should_read_a_consensus_rejection_as_definitive_only_for_shields_into_the_pool() { assert!(broadcast_definitely_failed(&consensus_metadata_rejection())); - // The same verdict shape is definitive on the wait stage too. let err = classify_spend_wait_failure("transfer", &consensus_metadata_rejection()); - assert!(matches!( - err, - PlatformWalletError::ShieldedBroadcastFailed(_) - )); + assert!( + matches!(err, PlatformWalletError::ShieldedSpendUnconfirmed { .. }), + "{err:?}" + ); } fn grpc_err(code: dash_sdk::dapi_grpc::tonic::Code) -> dash_sdk::Error { @@ -5912,11 +5902,11 @@ mod classify_spend_wait_failure_tests { assert!(!carries_consensus_rejection(&wrapped)); } - /// A nonce rejection wrapped in the retry envelope must reach - /// `promote_address_nonce_error` and surface as the typed - /// `AddressNonceMismatch`, not fall through to `ShieldedSpendUnconfirmed`. + /// A nonce rejection on a note spend's wait, wrapped in the retry + /// envelope, is an unauthenticated verdict too: unconfirmed, the notes + /// kept, never a typed refusal a host could retry on. #[test] - fn wrapped_nonce_rejection_promotes_to_typed_mismatch() { + fn should_read_a_wrapped_nonce_rejection_on_the_wait_as_unconfirmed() { use dpp::address_funds::PlatformAddress; use dpp::consensus::state::address_funds::AddressInvalidNonceError; use dpp::consensus::state::state_error::StateError; @@ -5928,18 +5918,11 @@ mod classify_spend_wait_failure_tests { let inner = dash_sdk::Error::Protocol(dpp::ProtocolError::ConsensusError(Box::new(cause))); let wrapped = dash_sdk::Error::NoAvailableAddressesToRetry(Box::new(inner)); - match classify_spend_wait_failure("withdraw", &wrapped) { - PlatformWalletError::AddressNonceMismatch { - address: got, - provided_nonce, - expected_nonce, - } => { - assert_eq!(got, address); - assert_eq!(provided_nonce, 7); - assert_eq!(expected_nonce, 8); - } - other => panic!("expected AddressNonceMismatch, got {other:?}"), - } + let err = classify_spend_wait_failure("withdraw", &wrapped); + assert!( + matches!(err, PlatformWalletError::ShieldedSpendUnconfirmed { .. }), + "{err:?}" + ); } } From 94452e64b8d739039caf2838c6a4117479d5239e Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 9 Nov 2026 21:49:24 +0700 Subject: [PATCH 106/117] fix(platform-wallet): keep a stale restoration from reserving a discharged private payment again A restoration snapshots the private payment journal under the store lock, then waits for the wallet lock. A payment whose record was discharged and whose reservation was released in between (a never-delivered payment) was reserved again when the restoration applied, with no entry to ever settle it, blocking the pair for the session. The persister now keeps, for the session, the commitments whose journal records were discharged (never delivered, read back, and later proven expired). The restoration skips them under the wallet lock. The ones whose reservation is still held are released by the private send, the public claim and the private sync, which store the identity's marker again when it changed. Co-Authored-By: Claude Opus 5.5 --- .../identity/network/contact_requests.rs | 1 + .../network/private_contact_requests.rs | 188 ++++++++++++++++++ .../managed_identity/contact_requests.rs | 14 ++ .../src/wallet/persister.rs | 65 ++++++ .../src/wallet/shielded/operations.rs | 43 ++-- 5 files changed, 296 insertions(+), 15 deletions(-) diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs b/packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs index ec10fa03d51..b21d9c77ddc 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs @@ -764,6 +764,7 @@ impl DashPayView<'_, B> { { self.read_private_store_before_deciding(sender_identity_id, recipient_identity_id, crypto) .await?; + self.release_discharged_private_requests().await?; let Some(claim) = self .claim_public_pair(sender_identity_id, recipient_identity_id, adopt_reply) .await? diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/private_contact_requests.rs b/packages/rs-platform-wallet/src/wallet/identity/network/private_contact_requests.rs index 05b6cc21eab..27dc463f457 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/private_contact_requests.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/private_contact_requests.rs @@ -441,6 +441,10 @@ pub(crate) async fn restore_journaled_private_payments( let mut restored = Vec::new(); let mut absent = BTreeSet::new(); for &(sender, recipient, account_reference, commitment) in payments { + // Discharged since the snapshot was read: nothing will settle it. + if persister.private_entry_discharged(&commitment) { + continue; + } let Some(managed) = info.identity_manager.managed_identity_mut(&sender) else { absent.insert((sender, recipient)); continue; @@ -804,6 +808,7 @@ impl DashPayView<'_, B> { } self.read_private_store_before_sending(sender_identity_id, recipient_identity_id, crypto) .await?; + self.release_discharged_private_requests().await?; let absent_sender_held = self .persister .absent_sender_private_pair_held(sender_identity_id, recipient_identity_id); @@ -1104,6 +1109,47 @@ impl DashPayView<'_, B> { }) } + /// Release the pending private requests whose payment's journal record + /// was discharged (`WalletPersister::note_discharged_private_entries`): + /// one proven expired, its anchor pruned with an input still unspent, can + /// never be read back. Run before a private request is reserved, before a + /// public one claims its pair, and by the private sync, so a retry needs + /// no restart. An identity's marker is stored again when it changed. + pub(crate) async fn release_discharged_private_requests( + &self, + ) -> Result<(), PlatformWalletError> { + if !self.persister.has_unreleased_discharged_private_entries() { + return Ok(()); + } + // Taken only once the wallet is locked and found, and applied with + // nothing awaited in between. + let mut wm = self.wallet_manager.write().await; + let info = wm + .get_wallet_info_mut(&self.wallet_id) + .ok_or_else(|| PlatformWalletError::WalletNotFound(hex::encode(self.wallet_id)))?; + let discharged = self.persister.take_unreleased_discharged_private_entries(); + let identity_ids: Vec = info + .identity_manager + .all_identities() + .iter() + .map(|identity| identity.id()) + .collect(); + let mut changed = Vec::new(); + for identity_id in identity_ids { + if let Some(managed) = info.identity_manager.managed_identity_mut(&identity_id) { + let unresolved = managed.dashpay().private_payments_unresolved(); + managed.release_discharged_private_replies(&discharged); + if managed.dashpay().private_payments_unresolved() != unresolved { + changed.push(identity_id); + } + } + } + for identity_id in changed { + store_private_payment_marker(&self.persister, &wm, &self.wallet_id, &identity_id); + } + Ok(()) + } + /// Whether a private sync read DashPay's private contact request store /// to its end for `identity_id` since load. async fn private_store_read( @@ -1508,6 +1554,7 @@ impl DashPayView<'_, B> { where C: ContactCryptoProvider + Sync, { + self.release_discharged_private_requests().await?; self.reconcile_private_scan_keys().await?; let (mut identities, paths) = self.private_scan_identities().await?; let mut report = PrivateContactSyncReport::default(); @@ -6141,6 +6188,147 @@ mod tests { ); } + /// A restoration that read a payment's journal record before the record + /// was discharged and its reservation released, as a never-delivered + /// payment does, and applies only after, does not reserve the pair again: + /// no entry would ever settle that reservation. A retry to the pair pays. + #[cfg(feature = "shielded")] + #[tokio::test] + async fn should_not_restore_a_payment_discharged_while_the_restore_waited() { + use std::future::Future; + use std::task::{Context, Waker}; + + let alice = identity(ALICE_PHRASE, ALICE); + let bob = identity(BOB_PHRASE, BOB); + let alice_crypto = SeedCryptoProvider::from_seed(seed(ALICE_PHRASE), Network::Testnet); + let alice_signer = signer(ALICE_PHRASE, &alice); + let (_manager, alice_wallet) = wallet(ALICE_PHRASE, &alice, &[&alice, &bob], vec![]).await; + let dir = tempfile::tempdir().expect("temporary shielded store"); + let db_path = dir.path().join("tree.sqlite"); + let coordinator = Arc::new(NetworkShieldedCoordinator::new( + Arc::new(dash_sdk::Sdk::new_mock()), + Network::Testnet, + db_path.clone(), + FileBackedShieldedStore::open_path(&db_path, 100).expect("the store"), + )); + let subwallet = SubwalletId::new(alice_wallet.wallet_id(), 0); + let paid = Arc::new(Mutex::new(Vec::new())); + let pay = { + let store = Arc::clone(coordinator.store()); + let paid = Arc::clone(&paid); + let (sender, recipient) = (alice.id(), bob.id()); + move |entry: BinaryData, started: PaymentStarted| async move { + paid.lock().expect("paid").push(entry.clone()); + let transition = private_contact_request_payment(entry); + arm_redrive_before_broadcast( + &store, + subwallet, + Some([0x7B; 32]), + [7; 32], + vec![[1; 32]], + &transition, + "private document create from shielded pool", + Some(PrivateContactPayment { + sender: sender.to_buffer(), + recipient: recipient.to_buffer(), + account_reference: started + .account_reference() + .expect("the send names its request"), + }), + Some(&started), + ) + .await?; + Err::(PlatformWalletError::ShieldedSpendUnconfirmed { + operation: "private document create from shielded pool", + reason: "the result wait timed out".to_string(), + }) + } + }; + let view = alice_wallet.identity().dashpay(); + let sent = view + .send_private_contact_request( + &alice.id(), + &bob.id(), + None, + AutoAcceptProofSource::None, + None, + &alice_signer, + &alice_crypto, + pay, + ) + .await; + assert!( + matches!( + sent, + Err(PlatformWalletError::ShieldedSpendUnconfirmed { .. }) + ), + "{sent:?}" + ); + let commitment: [u8; 32] = paid.lock().expect("paid")[0].as_slice()[..32] + .try_into() + .expect("an entry starts with its commitment"); + + let mut cx = Context::from_waker(Waker::noop()); + let held_store = coordinator.store().write().await; + let mut restore = Box::pin(alice_wallet.restore_private_contact_payments(&coordinator)); + assert!( + restore.as_mut().poll(&mut cx).is_pending(), + "waits for the store" + ); + let mut held_wallet = alice_wallet.wallet_manager().write().await; + drop(held_store); + // The restoration reads its journal snapshot, then waits for the wallet. + assert!( + restore.as_mut().poll(&mut cx).is_pending(), + "waits for the wallet" + ); + // Meanwhile the record is discharged and the reservation released. + coordinator + .store() + .write() + .await + .discharge_private_payment(alice_wallet.wallet_id(), &commitment) + .expect("discharged"); + alice_wallet + .persister() + .note_discharged_private_entries([commitment]); + held_wallet + .get_wallet_info_mut(&alice_wallet.wallet_id()) + .expect("the wallet") + .identity_manager + .managed_identity_mut(&alice.id()) + .expect("alice") + .release_discharged_private_replies(&BTreeSet::from([commitment])); + drop(held_wallet); + restore.await.expect("the restoration"); + assert!( + !pending_to(&alice_wallet, ALICE, &bob).await, + "not reserved again" + ); + + let retry = Arc::new(Mutex::new(Vec::new())); + let retried = view + .send_private_contact_request( + &alice.id(), + &bob.id(), + None, + AutoAcceptProofSource::None, + None, + &alice_signer, + &alice_crypto, + keep_entry(&retry), + ) + .await; + assert!( + matches!( + retried, + Err(PlatformWalletError::PrivateContactRequestPending(_)) + ), + "paid, not read back: {retried:?}" + ); + assert_eq!(retry.lock().expect("retry").len(), 1, "the retry pays"); + } + /// A payment that may have reached the network keeps its pair pending /// whatever error it returns, as its transition may still execute; one /// whose transition provably never left the wallet releases it. diff --git a/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/contact_requests.rs b/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/contact_requests.rs index 8897d7274e2..d2e268f88c7 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/contact_requests.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/contact_requests.rs @@ -1093,6 +1093,20 @@ impl ManagedIdentity { } } + /// Release every pending request sealed as one of `commitments`, whose + /// payments' journal records were discharged (read back elsewhere, never + /// delivered, or proven expired), whichever attempt holds it. + pub(crate) fn release_discharged_private_replies( + &mut self, + commitments: &std::collections::BTreeSet<[u8; 32]>, + ) { + self.dashpay.pending_private_replies.retain(|_, pending| { + pending + .commitment + .is_none_or(|commitment| !commitments.contains(&commitment)) + }); + } + /// Settle the pending request to `recipient` sealed as `commitment`: the /// private sync read that entry back and recorded it. pub(crate) fn settle_private_reply(&mut self, recipient: &Identifier, commitment: &[u8; 32]) { diff --git a/packages/rs-platform-wallet/src/wallet/persister.rs b/packages/rs-platform-wallet/src/wallet/persister.rs index 0a357792134..92523601019 100644 --- a/packages/rs-platform-wallet/src/wallet/persister.rs +++ b/packages/rs-platform-wallet/src/wallet/persister.rs @@ -73,6 +73,11 @@ pub struct WalletPersister { /// identity added since holds them until the next restoration reserves /// them. Replaced by every restoration. In memory only. absent_sender_private_pairs: Arc>>, + /// The commitments of the private contact request payments whose journal + /// records were discharged in this session (read back, never delivered, + /// or proven expired). Shared by the clones the wallet and the + /// coordinator hold. In memory only, as are the reservations they guard. + discharged_private_entries: Arc>, /// Whether the reservations of this wallet's unconfirmed private contact /// request payments from an earlier process may still be unrestored: /// raised at load for a wallet that bound a shielded account before, and @@ -82,6 +87,22 @@ pub struct WalletPersister { private_payments_unrestored: Arc, } +/// The commitments [`WalletPersister::note_discharged_private_entries`] noted, +/// both sets updated under one lock. +#[derive(Default)] +struct DischargedPrivateEntries { + /// Noted since the last take, for the wallet to release the reservations + /// it holds for them ([`WalletPersister::take_unreleased_discharged_private_entries`]). + unreleased: BTreeSet<[u8; 32]>, + /// Every one noted in this session, kept after the take: a restoration + /// that read a record before it was discharged, and reserves its pair + /// after, checks it here first under the wallet lock + /// ([`WalletPersister::private_entry_discharged`]). Only a shielded build + /// keeps a journal to discharge. + #[cfg_attr(not(feature = "shielded"), allow(dead_code))] + session: BTreeSet<[u8; 32]>, +} + impl WalletPersister { pub fn new(wallet_id: WalletId, inner: Arc) -> Self { Self { @@ -90,6 +111,7 @@ impl WalletPersister { contact_payment_gate: Arc::new(tokio::sync::Mutex::new(())), read_back_private_entries: Arc::default(), absent_sender_private_pairs: Arc::default(), + discharged_private_entries: Arc::default(), private_payments_unrestored: Arc::default(), } } @@ -139,6 +161,49 @@ impl WalletPersister { .contains(&(*sender, *recipient)) } + /// Note `commitments`, whose private payments' journal records were just + /// discharged: a restoration that read them before skips them + /// ([`Self::private_entry_discharged`]), and the wallet releases what it + /// still reserves for them + /// ([`Self::take_unreleased_discharged_private_entries`]). + #[cfg(feature = "shielded")] + pub(crate) fn note_discharged_private_entries( + &self, + commitments: impl IntoIterator, + ) { + let mut discharged = self.discharged_entries(); + for commitment in commitments { + discharged.unreleased.insert(commitment); + discharged.session.insert(commitment); + } + } + + /// Whether the journal record of `commitment` was discharged in this + /// session, its reservation released or not. + #[cfg(feature = "shielded")] + pub(crate) fn private_entry_discharged(&self, commitment: &[u8; 32]) -> bool { + self.discharged_entries().session.contains(commitment) + } + + /// Whether [`Self::note_discharged_private_entries`] noted any commitment + /// since the last take. + pub(crate) fn has_unreleased_discharged_private_entries(&self) -> bool { + !self.discharged_entries().unreleased.is_empty() + } + + /// Take the commitments [`Self::note_discharged_private_entries`] noted + /// since the last take. Take them only where they are applied with + /// nothing awaited in between. + pub(crate) fn take_unreleased_discharged_private_entries(&self) -> BTreeSet<[u8; 32]> { + std::mem::take(&mut self.discharged_entries().unreleased) + } + + fn discharged_entries(&self) -> std::sync::MutexGuard<'_, DischargedPrivateEntries> { + self.discharged_private_entries + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + } + /// Whether [`Self::note_read_back_private_entries`] noted any commitment /// not taken yet. #[cfg(feature = "shielded")] diff --git a/packages/rs-platform-wallet/src/wallet/shielded/operations.rs b/packages/rs-platform-wallet/src/wallet/shielded/operations.rs index 571e4020e09..61894bb3228 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/operations.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/operations.rs @@ -1905,7 +1905,7 @@ async fn settle_private_document_payment( .await; if never_delivered { if let Some(commitments) = journaled { - if discharge_journal_records(store, wallet_id, commitments).await { + if discharge_journal_records(store, persister, wallet_id, commitments).await { payment_started.note_never_delivered(); } } @@ -1917,26 +1917,33 @@ async fn settle_private_document_payment( } /// Discharge `wallet_id`'s journal records of `commitments`, whose payment's -/// transition never left the wallet. Returns whether every one is gone; a -/// failure leaves the records, which over-holds their pairs. +/// transition never left the wallet, noting each one discharged on +/// `persister` so a restoration that read it before skips it. Returns whether +/// every one is gone; a failure leaves the records, which over-holds their +/// pairs. async fn discharge_journal_records( store: &Arc>, + persister: Option<&WalletPersister>, wallet_id: WalletId, commitments: &[[u8; 32]], ) -> bool { let mut store = store.write().await; - let mut discharged = true; + let mut discharged = Vec::with_capacity(commitments.len()); for commitment in commitments { - if let Err(e) = store.discharge_private_payment(wallet_id, commitment) { - discharged = false; - warn!( + match store.discharge_private_payment(wallet_id, commitment) { + Ok(_) => discharged.push(*commitment), + Err(e) => warn!( error = %e, "a never-delivered private payment's journal record was not discharged; its \ pair stays held" - ); + ), } } - discharged + let every_one = discharged.len() == commitments.len(); + if let Some(persister) = persister { + persister.note_discharged_private_entries(discharged); + } + every_one } // ------------------------------------------------------------------------- @@ -3624,15 +3631,21 @@ pub(crate) fn discharge_read_back_private_payments( wallet_id: WalletId, ) { let mut kept = Vec::new(); + let mut discharged = Vec::new(); for commitment in persister.take_read_back_private_entries() { - if let Err(e) = store.discharge_private_payment(wallet_id, &commitment) { - warn!( - error = %e, - "a read-back private payment's journal record was not discharged; retried later" - ); - kept.push(commitment); + match store.discharge_private_payment(wallet_id, &commitment) { + Ok(_) => discharged.push(commitment), + Err(e) => { + warn!( + error = %e, + "a read-back private payment's journal record was not discharged; retried \ + later" + ); + kept.push(commitment); + } } } + persister.note_discharged_private_entries(discharged); if !kept.is_empty() { persister.note_read_back_private_entries(kept); } From 2197ec6e54b0de10bab5ae4009d07dd3a6d2e6ce Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 9 Nov 2026 22:06:34 +0700 Subject: [PATCH 107/117] fix(platform-wallet): bind a public contact request to its wallet generation A public send could pause after claiming its pair (preparation, ECDH or signing) while its wallet was removed and re-created under the same seed-derived id; resumed, it still reached the SDK writer, and its id-keyed bookkeeping could change the replacement wallet. The identity wallet now keeps the generation it belongs to. A public send takes that generation's lifecycle gate before deciding its channel, checks that the generation is still the live one, and holds the gate through the submission and its bookkeeping: a removal waits for it, and a send through a handle whose wallet was removed and re-created is refused with WalletRemovedBeforeSend before it decides anything. Co-Authored-By: Claude Opus 5.5 --- .../identity/network/contact_requests.rs | 17 ++++ .../identity/network/dpns_marketplace.rs | 7 ++ .../identity/network/identity_handle.rs | 7 ++ .../src/wallet/identity/network/payments.rs | 3 + .../network/private_contact_requests.rs | 95 +++++++++++++++++++ .../src/wallet/platform_wallet.rs | 1 + 6 files changed, 130 insertions(+) diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs b/packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs index b21d9c77ddc..e3fada24bff 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/contact_requests.rs @@ -762,6 +762,23 @@ impl DashPayView<'_, B> { S: Signer + Send + Sync, C: ContactCryptoProvider + Sync, { + // Bound to this handle's wallet generation, under its lifecycle gate + // from before the channel is decided through the submission and its + // bookkeeping: a removal cannot complete in between, and a send + // started on a wallet since removed and re-created under the same id + // publishes nothing and records nothing on the replacement. + let _lifecycle = self.generation.payment_guard().await; + let live = self + .wallet_manager + .read() + .await + .get_wallet_info(&self.wallet_id) + .is_some_and(|info| Arc::ptr_eq(&info.generation, &self.generation)); + if !live { + return Err(PlatformWalletError::WalletRemovedBeforeSend(hex::encode( + self.wallet_id, + ))); + } self.read_private_store_before_deciding(sender_identity_id, recipient_identity_id, crypto) .await?; self.release_discharged_private_requests().await?; diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/dpns_marketplace.rs b/packages/rs-platform-wallet/src/wallet/identity/network/dpns_marketplace.rs index a448db29490..f54f1791730 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/dpns_marketplace.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/dpns_marketplace.rs @@ -3910,6 +3910,12 @@ mod tests { let wallet_id = wm .create_wallet_with_random_mnemonic(WalletAccountCreationOptions::None) .expect("create wallet"); + // The live generation, as `PlatformWallet::new` takes it. + let generation = Arc::clone( + &wm.get_wallet_info(&wallet_id) + .expect("the wallet's info") + .generation, + ); let wallet_manager = Arc::new(RwLock::new(wm)); let persister = WalletPersister::new(wallet_id, mirror); @@ -3934,6 +3940,7 @@ mod tests { persister, sync_fault: Arc::new(std::sync::atomic::AtomicBool::new(false)), durable_cursors: crate::changeset::DurableCursors::default(), + generation, broadcaster, sdk_writer: Arc::new(super::super::sdk_writer::SdkWriter::new(sdk)), dpns_operation_gate: Arc::new(tokio::sync::Mutex::new(())), diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/identity_handle.rs b/packages/rs-platform-wallet/src/wallet/identity/network/identity_handle.rs index 76634be498f..d252507076f 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/identity_handle.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/identity_handle.rs @@ -45,6 +45,7 @@ use zeroize::Zeroizing; use crate::broadcaster::{SpvBroadcaster, TransactionBroadcaster}; use crate::error::PlatformWalletError; use crate::wallet::asset_lock::manager::AssetLockManager; +use crate::wallet::core::WalletGeneration; use crate::wallet::platform_wallet::{PlatformWalletInfo, WalletId}; /// Default gap limit for identity discovery scanning. @@ -508,6 +509,11 @@ pub struct IdentityWallet { /// adapter and the DashPay rescan reconcile. See /// [`DurableCursors`](crate::changeset::DurableCursors). pub(crate) durable_cursors: crate::changeset::DurableCursors, + /// The wallet generation this handle belongs to: a public contact + /// request is sent only while it is the live one, under its lifecycle + /// gate, so a send started on a wallet since removed and re-created under + /// the same id never publishes or records on the replacement. + pub(crate) generation: Arc, /// Broadcaster for DashPay payment transactions. Distinct from the /// asset-lock broadcaster — the asset-lock manager is always /// `SpvBroadcaster`-pinned, while this one picks the broadcaster @@ -551,6 +557,7 @@ impl Clone for IdentityWallet { persister: self.persister.clone(), sync_fault: Arc::clone(&self.sync_fault), durable_cursors: Arc::clone(&self.durable_cursors), + generation: Arc::clone(&self.generation), broadcaster: Arc::clone(&self.broadcaster), sdk_writer: Arc::clone(&self.sdk_writer), dpns_operation_gate: Arc::clone(&self.dpns_operation_gate), diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/payments.rs b/packages/rs-platform-wallet/src/wallet/identity/network/payments.rs index 757b71290a6..5060aa6295d 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/payments.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/payments.rs @@ -13429,6 +13429,7 @@ mod tests { persister: real.persister.clone(), sync_fault: Arc::clone(&real.sync_fault), durable_cursors: Arc::clone(&real.durable_cursors), + generation: Arc::clone(&real.generation), broadcaster: Arc::new(GatedRejectingBroadcaster { entered, release }), sdk_writer: Arc::clone(&real.sdk_writer), dpns_operation_gate: Arc::clone(&real.dpns_operation_gate), @@ -13470,6 +13471,7 @@ mod tests { persister: real.persister.clone(), sync_fault: Arc::clone(&real.sync_fault), durable_cursors: Arc::clone(&real.durable_cursors), + generation: Arc::clone(&real.generation), broadcaster: Arc::new(GatedBroadcaster { entered, release }), sdk_writer: Arc::clone(&real.sdk_writer), dpns_operation_gate: Arc::clone(&real.dpns_operation_gate), @@ -13525,6 +13527,7 @@ mod tests { persister: real.persister.clone(), sync_fault: Arc::clone(&real.sync_fault), durable_cursors: Arc::clone(&real.durable_cursors), + generation: Arc::clone(&real.generation), broadcaster: Arc::new(broadcaster), sdk_writer: Arc::clone(&real.sdk_writer), dpns_operation_gate: Arc::clone(&real.dpns_operation_gate), diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/private_contact_requests.rs b/packages/rs-platform-wallet/src/wallet/identity/network/private_contact_requests.rs index 27dc463f457..f990df79f2f 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/private_contact_requests.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/private_contact_requests.rs @@ -4432,6 +4432,101 @@ mod tests { .await } + /// A public request is bound to its wallet's generation: while one is on + /// its way, paused here at its signature after claiming the pair, the + /// wallet cannot be removed; once the wallet is removed and re-created + /// under the same id, a send through the old handle is refused before it + /// decides anything, publishing nothing and changing nothing on the + /// replacement. + #[tokio::test] + async fn should_not_let_a_public_request_outlive_its_wallet() { + use std::future::Future; + use std::task::{Context, Waker}; + + let alice = identity(ALICE_PHRASE, ALICE); + let bob = identity(BOB_PHRASE, BOB); + let alice_crypto = SeedCryptoProvider::from_seed(seed(ALICE_PHRASE), Network::Testnet); + let alice_signer = signer(ALICE_PHRASE, &alice); + let (manager, old) = wallet(ALICE_PHRASE, &alice, &[&alice, &bob], vec![]).await; + let wallet_id = old.wallet_id(); + let (alice_id, bob_id) = (alice.id(), bob.id()); + { + // Paused right after it takes the wallet's lifecycle gate, at its + // check that the wallet is still live. + let held = old.wallet_manager().write().await; + let view = old.identity().dashpay(); + let mut send = Box::pin(view.send_contact_request_with_external_signer( + &alice_id, + &bob_id, + None, + AutoAcceptProofSource::None, + &alice_signer, + &alice_crypto, + )); + let mut cx = Context::from_waker(Waker::noop()); + assert!(send.as_mut().poll(&mut cx).is_pending()); + // In flight, it holds the wallet's lifecycle gate, which a removal + // takes exclusively before anything else: no removal can complete. + assert!( + tokio::time::timeout( + Duration::from_millis(100), + old.generation().teardown_guard() + ) + .await + .is_err(), + "a removal waits for the public request" + ); + // Dropped, the send gives the wallet up. + drop(send); + drop(held); + } + + manager.remove_wallet(&wallet_id).await.expect("removed"); + let replacement = manager + .create_wallet_from_seed_bytes( + Network::Testnet, + &seed(ALICE_PHRASE), + WalletAccountCreationOptions::Default, + Some(0), + ) + .await + .expect("re-created"); + assert_eq!(replacement.wallet_id(), wallet_id); + { + let iw = replacement.identity(); + let mut wm = iw.wallet_manager.write().await; + wm.get_wallet_info_mut(&wallet_id) + .expect("the replacement's info") + .identity_manager + .add_identity(alice.clone(), 0, wallet_id, &iw.persister) + .expect("alice"); + } + let resumed = old + .identity() + .dashpay() + .send_contact_request_with_external_signer( + &alice.id(), + &bob.id(), + None, + AutoAcceptProofSource::None, + &signer(ALICE_PHRASE, &alice), + &alice_crypto, + ) + .await; + assert!( + matches!( + resumed, + Err(PlatformWalletError::WalletRemovedBeforeSend(_)) + ), + "{resumed:?}" + ); + managed(&replacement, ALICE, |managed| { + assert!(managed.dashpay().outgoing_request(&bob.id()).is_none()); + assert!(!managed.dashpay().private_reply_pending(&bob.id())); + }) + .await; + } + /// A send dropped before it pays, here while its signer never answers, /// leaves no reservation behind: nothing was paid, and no sync could /// settle one. The recipient is free for the next send. diff --git a/packages/rs-platform-wallet/src/wallet/platform_wallet.rs b/packages/rs-platform-wallet/src/wallet/platform_wallet.rs index b6aa68b5167..f37a0dd5d0e 100644 --- a/packages/rs-platform-wallet/src/wallet/platform_wallet.rs +++ b/packages/rs-platform-wallet/src/wallet/platform_wallet.rs @@ -773,6 +773,7 @@ impl PlatformWallet { persister: wallet_persister.clone(), sync_fault, durable_cursors, + generation: Arc::clone(&generation), broadcaster: dashpay_broadcaster, // DashPay write helper: forwards to the live SDK, erasing its // generic write signatures behind concrete by-value methods. From 8ede7918f8816b97d114eec64b6bc9cb30a16ca8 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 9 Nov 2026 22:28:37 +0700 Subject: [PATCH 108/117] fix(platform-wallet): discharge a private payment proven expired and free its pair A private payment that never executes kept its journal record, and so its pair held, forever: the stranded-release pass proved such a spend dead and freed its notes, but clear_pending keeps the journal, and the journal did not even keep the anchor the proof needs once the redrive was deleted. - The journal record now keeps the spend's anchor (format version 2; version 1 records, written by unreleased builds only, do not read back and keep their wallet held). - After the pass's spent-note reconcile, the coordinator discharges a record, durably, when its anchor is absent from the recorded set fetched before the pass, one of its input notes is still unspent, and its entry is absent from DashPay's private contact request store read raw after that. The anchor set is fetched whenever the journal holds records, not only while a reservation does. The discharge is noted on the wallet's persister, so the next private send, public claim or private sync releases the pair and stores its marker. A relay refusal or a missing redrive record is never taken as proof. - A never-landing payment's pair is therefore freed once its anchor ages out (shielded_anchor_retention_blocks 1000, pruned every shielded_anchor_pruning_interval 100). README and docs updated. Co-Authored-By: Claude Opus 5.5 --- packages/rs-platform-wallet/README.md | 2 +- .../network/private_contact_requests.rs | 208 +++++++++++++++++- .../src/wallet/platform_wallet.rs | 1 + .../src/wallet/shielded/coordinator.rs | 145 +++++++++++- .../src/wallet/shielded/file_store.rs | 2 + .../src/wallet/shielded/store.rs | 37 +++- 6 files changed, 380 insertions(+), 15 deletions(-) diff --git a/packages/rs-platform-wallet/README.md b/packages/rs-platform-wallet/README.md index 8f48f9d23c7..f893ac6b246 100644 --- a/packages/rs-platform-wallet/README.md +++ b/packages/rs-platform-wallet/README.md @@ -123,7 +123,7 @@ Startup errors distinguish damaged recovery metadata or undecodable signed bytes ## Private contact request payments after a restart -Before a private contact request's payment can reach the network, the shielded store saves a record of it in its private payment journal: the sender and recipient identities, the request's account reference, the commitment of the entry it appends and the nullifiers its spend consumes. The file store writes the journal, and the payment's redrive record, in one transaction on a connection at `synchronous=FULL`, so the record survives a crash or a power loss; a record that cannot be saved refuses the payment before anything is broadcast. The journal is kept apart from the spend's notes and redrive record: no note release, redrive removal, undecodable transition, account rebind or purge removes it. A record is removed only when its entry is read back from DashPay's private store and recorded durably, or when the transition provably never left the wallet (refused before anything was sent: no address to send it to, or the SDK's own refusal). An endpoint's answer, a consensus verdict included, proves nothing: no proof or quorum signature backs it, and an endpoint that kept or forwarded the signed transition can answer with any verdict. A payment that never lands otherwise keeps its pair held; that fails closed. +Before a private contact request's payment can reach the network, the shielded store saves a record of it in its private payment journal: the sender and recipient identities, the request's account reference, the commitment of the entry it appends and the nullifiers its spend consumes. The file store writes the journal, and the payment's redrive record, in one transaction on a connection at `synchronous=FULL`, so the record survives a crash or a power loss; a record that cannot be saved refuses the payment before anything is broadcast. The journal is kept apart from the spend's notes and redrive record: no note release, redrive removal, undecodable transition, account rebind or purge removes it. A record is removed only when its entry is read back from DashPay's private store and recorded durably, or when the transition provably never left the wallet (refused before anything was sent: no address to send it to, or the SDK's own refusal). An endpoint's answer, a consensus verdict included, proves nothing: no proof or quorum signature backs it, and an endpoint that kept or forwarded the signed transition can answer with any verdict. A record is also discharged once its payment is proven expired: the shielded sync pass finds the spend's anchor, which the record keeps, absent from Platform's recorded anchors fetched before the pass, one of its input notes still unspent after the pass's spent-note reconcile, and its entry absent from DashPay's private store read after that. A payment that never lands therefore frees its pair once its anchor ages out (Platform keeps an anchor for `shielded_anchor_retention_blocks`, 1000 blocks, and prunes every `shielded_anchor_pruning_interval`, 100 blocks). Nothing else, a relay's refusal or a missing redrive record included, discharges a record. Until a payment's reservation is restored from the journal, nothing shows that the pair is taken: the request's entry is not in DashPay's store until the payment executes. So a reloaded wallet sends and accepts no contact request, public or private, while such a payment may be unrestored, and returns `PrivateContactPaymentsNotRestored` (FFI code 72) with nothing read, signed or broadcast. The in-kind accept is held the same way. Two things hold it: diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/private_contact_requests.rs b/packages/rs-platform-wallet/src/wallet/identity/network/private_contact_requests.rs index f990df79f2f..f267955811c 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/private_contact_requests.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/private_contact_requests.rs @@ -2247,7 +2247,7 @@ mod tests { #[cfg(feature = "shielded")] use crate::wallet::shielded::{ FileBackedShieldedStore, NetworkShieldedCoordinator, OrchardKeySet, PrivateContactPayment, - ShieldedStore, SubwalletId, + ShieldedNote, ShieldedStore, SubwalletId, }; use crate::PlatformWalletManager; use dash_sdk::error::ContextProviderError; @@ -2271,6 +2271,8 @@ mod tests { use platform_encryption::{ account_reference_version, decrypt_account_label, decrypt_extended_public_key, }; + #[cfg(feature = "shielded")] + use std::collections::HashSet; use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; use std::sync::Mutex; use std::time::Duration; @@ -6424,6 +6426,210 @@ mod tests { assert_eq!(retry.lock().expect("retry").len(), 1, "the retry pays"); } + /// A private payment that never executes is proven expired once its + /// anchor is pruned, one of its input notes still unspent and its entry + /// absent from the store: the coordinator discharges its journal record, + /// the pair is freed, a retry to it pays, and a reopened store restores + /// no hold. A still-recorded anchor, a spent input or an entry in the + /// store each keep the record and the hold. + #[cfg(feature = "shielded")] + #[tokio::test] + async fn should_free_a_pair_once_its_private_payment_provably_expired() { + #[derive(Clone, Copy, Debug, PartialEq)] + enum Case { + Expired, + AnchorRecorded, + InputSpent, + EntryAppended, + } + let alice = identity(ALICE_PHRASE, ALICE); + let bob = identity(BOB_PHRASE, BOB); + let alice_crypto = SeedCryptoProvider::from_seed(seed(ALICE_PHRASE), Network::Testnet); + let alice_signer = signer(ALICE_PHRASE, &alice); + let anchor = [0x7C; 32]; + for case in [ + Case::Expired, + Case::AnchorRecorded, + Case::InputSpent, + Case::EntryAppended, + ] { + // A backend that keeps the viewing key, so the wallet binds. + let (_manager, alice_wallet) = wallet_on( + RecordingPersistence::attesting( + PersistenceCapabilities::SHIELDED_FVK_RESTART + .union(PersistenceCapabilities::PRIVATE_CONTACT_SCAN_STATE_RESTART), + ), + ALICE_PHRASE, + &alice, + &[&alice, &bob], + vec![], + ) + .await; + let dir = tempfile::tempdir().expect("temporary shielded store"); + let db_path = dir.path().join("tree.sqlite"); + let coordinator = Arc::new(NetworkShieldedCoordinator::new( + Arc::clone(&alice_wallet.identity().sdk), + Network::Testnet, + db_path.clone(), + FileBackedShieldedStore::open_path(&db_path, 100).expect("the store"), + )); + // Bound, the coordinator holds the wallet's persister, on which it + // notes the discharge. + alice_wallet + .bind_shielded(&seed(ALICE_PHRASE), &[0], &coordinator) + .await + .expect("bound"); + let subwallet = SubwalletId::new(alice_wallet.wallet_id(), 0); + let input = ShieldedNote { + position: 0, + cmx: [0x11; 32], + nullifier: [0x12; 32], + block_height: 1, + is_spent: false, + value: 1_000_000_000_000, + note_data: vec![], + }; + coordinator + .store() + .write() + .await + .save_note(subwallet, &input) + .expect("the input note"); + let paid = Arc::new(Mutex::new(Vec::new())); + let pay = { + let store = Arc::clone(coordinator.store()); + let paid = Arc::clone(&paid); + let (sender, recipient) = (alice.id(), bob.id()); + move |entry: BinaryData, started: PaymentStarted| async move { + paid.lock().expect("paid").push(entry.clone()); + let transition = private_contact_request_payment(entry); + arm_redrive_before_broadcast( + &store, + subwallet, + Some([0x7D; 32]), + anchor, + vec![[0x12; 32]], + &transition, + "private document create from shielded pool", + Some(PrivateContactPayment { + sender: sender.to_buffer(), + recipient: recipient.to_buffer(), + account_reference: started + .account_reference() + .expect("the send names its request"), + }), + Some(&started), + ) + .await?; + Err::(PlatformWalletError::ShieldedSpendUnconfirmed { + operation: "private document create from shielded pool", + reason: "the result wait timed out".to_string(), + }) + } + }; + let view = alice_wallet.identity().dashpay(); + let sent = view + .send_private_contact_request( + &alice.id(), + &bob.id(), + None, + AutoAcceptProofSource::None, + None, + &alice_signer, + &alice_crypto, + pay, + ) + .await; + assert!( + matches!( + sent, + Err(PlatformWalletError::ShieldedSpendUnconfirmed { .. }) + ), + "{sent:?}" + ); + let entry = paid.lock().expect("paid")[0].clone(); + match case { + Case::InputSpent => { + coordinator + .store() + .write() + .await + .mark_spent(subwallet, &input.nullifier) + .expect("spent"); + } + Case::EntryAppended => publish(&alice_wallet, 0, vec![entry.to_vec()]).await, + Case::Expired | Case::AnchorRecorded => {} + } + let recorded: HashSet<[u8; 32]> = match case { + Case::AnchorRecorded => HashSet::from([anchor]), + _ => HashSet::new(), + }; + coordinator + .discharge_expired_private_payments([alice_wallet.wallet_id()], &recorded) + .await; + + let journaled = coordinator + .store() + .read() + .await + .private_payment_journal(alice_wallet.wallet_id()) + .expect("the journal") + .len(); + if case != Case::Expired { + assert_eq!(journaled, 1, "{case:?}: the record stays"); + let held = view + .send_private_contact_request( + &alice.id(), + &bob.id(), + None, + AutoAcceptProofSource::None, + None, + &alice_signer, + &alice_crypto, + no_payment, + ) + .await; + assert!( + matches!( + held, + Err(PlatformWalletError::PrivateContactRequestPending(_)) + ), + "{case:?}: {held:?}" + ); + continue; + } + assert_eq!(journaled, 0, "discharged"); + let retry = Arc::new(Mutex::new(Vec::new())); + let retried = view + .send_private_contact_request( + &alice.id(), + &bob.id(), + None, + AutoAcceptProofSource::None, + None, + &alice_signer, + &alice_crypto, + keep_entry(&retry), + ) + .await; + assert!( + matches!( + retried, + Err(PlatformWalletError::PrivateContactRequestPending(_)) + ), + "paid, not read back: {retried:?}" + ); + assert_eq!(retry.lock().expect("retry").len(), 1, "the retry pays"); + // Reopened, the store keeps the discharge. + drop(coordinator); + let reopened = FileBackedShieldedStore::open_path(&db_path, 100).expect("reopened"); + assert!(reopened + .private_payment_journal(alice_wallet.wallet_id()) + .expect("the journal") + .is_empty()); + } + } + /// A payment that may have reached the network keeps its pair pending /// whatever error it returns, as its transition may still execute; one /// whose transition provably never left the wallet releases it. diff --git a/packages/rs-platform-wallet/src/wallet/platform_wallet.rs b/packages/rs-platform-wallet/src/wallet/platform_wallet.rs index f37a0dd5d0e..f604e33bce7 100644 --- a/packages/rs-platform-wallet/src/wallet/platform_wallet.rs +++ b/packages/rs-platform-wallet/src/wallet/platform_wallet.rs @@ -3096,6 +3096,7 @@ where migrated.push(PrivatePaymentRecord { account_index: id.account_index, activity_id: redrive.activity_id, + anchor: redrive.anchor, commitment, payment: *payment, nullifiers: redrive.nullifiers.clone(), diff --git a/packages/rs-platform-wallet/src/wallet/shielded/coordinator.rs b/packages/rs-platform-wallet/src/wallet/shielded/coordinator.rs index df53e957352..25293d121f2 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/coordinator.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/coordinator.rs @@ -96,11 +96,16 @@ use super::store::{ use super::CAUGHT_UP_COOLDOWN; use crate::error::PlatformWalletError; use crate::manager::shielded_sync::{ShieldedSyncPassSummary, WalletShieldedOutcome}; +use crate::wallet::identity::network::dashpay_contract; use crate::wallet::persister::WalletPersister; use crate::wallet::platform_wallet::WalletId; +use dash_sdk::platform::dashpay::PRIVATE_CONTACT_REQUEST_DOCUMENT_TYPE_NAME; +use dash_sdk::platform::private_document_scan::MAX_CONCURRENT_PRIVATE_DOCUMENT_PAGE_FETCHES; +use dpp::data_contract::accessors::v0::DataContractV0Getters; use dpp::serialization::PlatformDeserializableTrusted; use dpp::state_transition::shield_from_identity_transition::accessors::ShieldFromIdentityTransitionAccessorsV0; use dpp::state_transition::StateTransition; +use futures::StreamExt; /// Network-scoped shielded coordinator. /// @@ -1520,6 +1525,11 @@ impl NetworkShieldedCoordinator { } if !cancel.is_cancelled() { self.release_stranded_spends(snapshot, &recorded).await; + self.discharge_expired_private_payments( + subwallets.iter().map(|(id, _)| id.wallet_id), + &recorded, + ) + .await; } } @@ -1703,7 +1713,18 @@ impl NetworkShieldedCoordinator { } acc }; - if stale.is_empty() { + // A private payment's journal record outlives its reservation: its + // expiry is judged against the same pre-scan anchor set + // (`Self::discharge_expired_private_payments`). + let journaled = { + let store = self.store.read().await; + subwallets.iter().any(|(id, _)| { + store + .private_payment_journal(id.wallet_id) + .is_ok_and(|records| !records.is_empty()) + }) + }; + if stale.is_empty() && !journaled { return None; } @@ -1856,6 +1877,128 @@ impl NetworkShieldedCoordinator { } } + /// Discharge the private payment journal records of payments proven + /// expired, durably, and note them on their wallet's persister so the + /// wallet releases the pairs they hold + /// (`WalletPersister::note_discharged_private_entries`). A payment is + /// proven never to execute when, after this pass's spent-note reconcile: + /// + /// 1. its anchor is absent from the PRE-scan `recorded` set: a pruned + /// root never re-enters it, so the transition can never execute from + /// then on (`shielded_anchor_retention_blocks` 1000, pruned every + /// `shielded_anchor_pruning_interval` 100 blocks); + /// 2. one of its input notes is still unspent: had it executed before + /// the anchor was pruned, the reconcile would have seen its nullifiers + /// spent; + /// 3. its entry is absent from DashPay's private contact request store, + /// read raw after that anchor set was fetched: an entry starts with its + /// commitment, so none needs opening. + /// + /// A relay refusal or a missing redrive record is never taken as proof. + /// A store read that fails discharges nothing; the next pass retries, + /// the journal record keeping the anchor. + pub(crate) async fn discharge_expired_private_payments( + &self, + wallet_ids: impl IntoIterator, + recorded: &HashSet<[u8; 32]>, + ) { + let wallets: std::collections::BTreeSet = wallet_ids.into_iter().collect(); + let candidates: Vec<(WalletId, super::store::PrivatePaymentRecord)> = { + let store = self.store.read().await; + let mut candidates = Vec::new(); + for wallet_id in &wallets { + let Ok(records) = store.private_payment_journal(*wallet_id) else { + continue; + }; + for record in records { + if recorded.contains(&record.anchor) { + continue; + } + let notes = store + .get_all_notes(SubwalletId::new(*wallet_id, record.account_index)) + .unwrap_or_default(); + let input_unspent = record.nullifiers.iter().any(|nullifier| { + notes + .iter() + .any(|note| note.nullifier == *nullifier && !note.is_spent) + }); + if input_unspent { + candidates.push((*wallet_id, record)); + } + } + } + candidates + }; + if candidates.is_empty() { + return; + } + let appended = match self.private_contact_request_commitments().await { + Ok(appended) => appended, + Err(error) => { + tracing::warn!( + %error, + "private payment expiry: the private contact request store was not read; \ + retried next pass" + ); + return; + } + }; + let persisters = self.persisters.read().await; + let mut store = self.store.write().await; + for (wallet_id, record) in candidates { + if appended.contains(&record.commitment) { + continue; + } + match store.discharge_private_payment(wallet_id, &record.commitment) { + Ok(_) => { + tracing::info!( + wallet_id = %hex::encode(wallet_id), + commitment = %hex::encode(record.commitment), + "a private payment's anchor was pruned with an input unspent and its \ + entry absent from the store: it can never execute, its pair is freed" + ); + if let Some(persister) = persisters.get(&wallet_id) { + persister.note_discharged_private_entries([record.commitment]); + } + } + Err(error) => tracing::warn!( + wallet_id = %hex::encode(wallet_id), + %error, + "an expired private payment's journal record was not discharged; retried \ + next pass" + ), + } + } + } + + /// The commitments of every entry of DashPay's private contact request + /// store, read now from its start, raw: an entry starts with its + /// commitment. + async fn private_contact_request_commitments( + &self, + ) -> Result, PlatformWalletError> { + let contract_id = dashpay_contract()?.id(); + let mut pages = std::pin::pin!(self.sdk.private_document_pages( + contract_id, + PRIVATE_CONTACT_REQUEST_DOCUMENT_TYPE_NAME, + 0, + MAX_CONCURRENT_PRIVATE_DOCUMENT_PAGE_FETCHES, + )); + let mut commitments = HashSet::new(); + while let Some(page) = pages.next().await { + for entry in page?.entries.entries { + if let Some(commitment) = entry + .data + .get(..32) + .and_then(|commitment| <[u8; 32]>::try_from(commitment).ok()) + { + commitments.insert(commitment); + } + } + } + Ok(commitments) + } + /// Derive best-effort activity entries from each subwallet's /// persisted scan data and add the new ones to `changeset`. /// diff --git a/packages/rs-platform-wallet/src/wallet/shielded/file_store.rs b/packages/rs-platform-wallet/src/wallet/shielded/file_store.rs index 4bc5f2acb89..068ad8a4906 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/file_store.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/file_store.rs @@ -999,6 +999,7 @@ impl ShieldedStore for FileBackedShieldedStore { .map(|commitment| PrivatePaymentRecord { account_index: id.account_index, activity_id: redrive.activity_id, + anchor: redrive.anchor, commitment: *commitment, payment, nullifiers: redrive.nullifiers.clone(), @@ -2814,6 +2815,7 @@ mod tests { let expected = PrivatePaymentRecord { account_index: 1, activity_id: redrive.activity_id, + anchor: redrive.anchor, commitment: [0xC3; 32], payment, nullifiers: redrive.nullifiers.clone(), diff --git a/packages/rs-platform-wallet/src/wallet/shielded/store.rs b/packages/rs-platform-wallet/src/wallet/shielded/store.rs index d7b1ec888ee..2348f1a0e98 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/store.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/store.rs @@ -226,6 +226,11 @@ pub struct PrivatePaymentRecord { pub account_index: u32, /// The spend's activity id, which keys its redrive record. pub activity_id: [u8; 32], + /// The recorded anchor the spend was built against: once Platform no + /// longer records it, the spend can never execute, which with an input + /// note still unspent and the entry absent from the store proves the + /// payment expired. + pub anchor: [u8; 32], /// The commitment of the entry it appends, which keys the record. pub commitment: [u8; 32], /// The request it pays for. @@ -235,17 +240,20 @@ pub struct PrivatePaymentRecord { } impl PrivatePaymentRecord { - /// The format version [`Self::to_bytes`] writes. - const FORMAT_VERSION: u8 = 1; + /// The format version [`Self::to_bytes`] writes. Version 1, written before + /// the anchor was kept, does not read back: its wallet's holds stay. + const FORMAT_VERSION: u8 = 2; /// The bytes before the nullifiers: version, account index, activity id, - /// commitment, sender, recipient, account reference and nullifier count. - const HEADER_LEN: usize = 1 + 4 + 32 + 32 + 32 + 32 + 4 + 2; + /// anchor, commitment, sender, recipient, account reference and nullifier + /// count. + const HEADER_LEN: usize = 1 + 4 + 32 + 32 + 32 + 32 + 32 + 4 + 2; /// The bytes the file store keeps it as: [`Self::FORMAT_VERSION`], the - /// account index little-endian, the activity id, the commitment, the - /// sender, the recipient, the account reference little-endian, the - /// nullifier count as a little-endian `u16`, then the nullifiers. `None` - /// when it has no nullifier or more than a `u16` counts. + /// account index little-endian, the activity id, the anchor, the + /// commitment, the sender, the recipient, the account reference + /// little-endian, the nullifier count as a little-endian `u16`, then the + /// nullifiers. `None` when it has no nullifier or more than a `u16` + /// counts. pub(super) fn to_bytes(&self) -> Option> { let count = u16::try_from(self.nullifiers.len()).ok()?; if count == 0 { @@ -255,6 +263,7 @@ impl PrivatePaymentRecord { bytes.push(Self::FORMAT_VERSION); bytes.extend_from_slice(&self.account_index.to_le_bytes()); bytes.extend_from_slice(&self.activity_id); + bytes.extend_from_slice(&self.anchor); bytes.extend_from_slice(&self.commitment); bytes.extend_from_slice(&self.payment.sender); bytes.extend_from_slice(&self.payment.recipient); @@ -276,6 +285,7 @@ impl PrivatePaymentRecord { } let (account_index, rest) = rest.split_first_chunk::<4>()?; let (activity_id, rest) = rest.split_first_chunk::<32>()?; + let (anchor, rest) = rest.split_first_chunk::<32>()?; let (commitment, rest) = rest.split_first_chunk::<32>()?; let (sender, rest) = rest.split_first_chunk::<32>()?; let (recipient, rest) = rest.split_first_chunk::<32>()?; @@ -289,6 +299,7 @@ impl PrivatePaymentRecord { Some(Self { account_index: u32::from_le_bytes(*account_index), activity_id: *activity_id, + anchor: *anchor, commitment: *commitment, payment: PrivateContactPayment { sender: *sender, @@ -514,10 +525,11 @@ pub trait ShieldedStore: Send + Sync + 'static { ) -> Result<(), Self::Error>; /// Remove `wallet_id`'s journal record of the entry `commitment`, once - /// its payment provably resolved: the entry was read back, or the - /// transition never left the wallet. Returns whether there was one. No - /// other call removes a journal record: not a note release, a redrive's - /// removal or a purge. + /// its payment provably resolved: the entry was read back, the + /// transition never left the wallet, or the payment is proven expired + /// (its anchor pruned, an input note still unspent, its entry absent + /// from the store). Returns whether there was one. No other call removes + /// a journal record: not a note release, a redrive's removal or a purge. fn discharge_private_payment( &mut self, wallet_id: WalletId, @@ -1270,6 +1282,7 @@ impl ShieldedStore for InMemoryShieldedStore { PrivatePaymentRecord { account_index: id.account_index, activity_id: redrive.activity_id, + anchor: redrive.anchor, commitment: *commitment, payment, nullifiers: redrive.nullifiers.clone(), From ac4459a80f572010ab0ba00b626f9afb2f8a38c6 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 9 Nov 2026 23:09:24 +0700 Subject: [PATCH 109/117] feat(sdk): stream private document pages with their proved heights Sdk::private_document_pages_with_heights yields the pages of private_document_pages, in the same walk and order, each with the height of the response metadata its proof was verified with. A caller that must show every page, and the store's end the first page reports, at or above a height uses it; private_document_pages is unchanged. Co-Authored-By: Claude Opus 5.5 --- .../src/platform/private_document_scan.rs | 136 ++++++++++++++++++ 1 file changed, 136 insertions(+) diff --git a/packages/rs-sdk/src/platform/private_document_scan.rs b/packages/rs-sdk/src/platform/private_document_scan.rs index d34ecebd882..23235900685 100644 --- a/packages/rs-sdk/src/platform/private_document_scan.rs +++ b/packages/rs-sdk/src/platform/private_document_scan.rs @@ -14,8 +14,10 @@ //! decode and validate. `crate::platform::dashpay` does both for DashPay's private contact //! requests. +use std::collections::BTreeMap; use std::future::Future; use std::pin::Pin; +use std::sync::{Arc, Mutex, PoisonError}; use crate::error::StaleNodeError; use crate::platform::private_document_entries::{ @@ -754,6 +756,87 @@ impl Sdk { }) } + /// [`Self::private_document_pages`], with the height each page's proof was made at, from + /// the response metadata verified with it: for a caller that must show every page, and + /// the store's end the first page reports, at or above a height. The pages and the walk + /// are those of [`Self::private_document_pages`]. + pub fn private_document_pages_with_heights( + &self, + data_contract_id: Identifier, + document_type_name: &str, + start_position: u64, + max_concurrent: usize, + ) -> impl Stream> + Send { + let sdk = self.clone(); + let document_type_name = document_type_name.to_string(); + // Each page is fetched once, from its own start: the height of the + // proof read from a start is kept under it until the page is yielded. + let heights: Arc>> = Arc::default(); + let resolve = { + let sdk = sdk.clone(); + let document_type_name = document_type_name.clone(); + async move { + sdk.fetch_private_document_contract(data_contract_id, &document_type_name) + .await + } + }; + let page_heights = Arc::clone(&heights); + stream::once(resolve) + .flat_map(move |resolved| -> PageStream { + match resolved { + Err(error) => Box::pin(stream::once(async move { Err(error) })), + Ok(_) => { + let sdk = sdk.clone(); + let document_type_name = document_type_name.clone(); + let heights = Arc::clone(&page_heights); + let fetch = move |start: u64| -> PageFuture { + let sdk = sdk.clone(); + let heights = Arc::clone(&heights); + let query = PrivateDocumentEntriesQuery::new( + data_contract_id, + document_type_name.clone(), + start, + ); + Box::pin(async move { + let (entries, metadata) = + PrivateDocumentEntries::fetch_with_metadata(&sdk, query, None) + .await?; + heights + .lock() + .unwrap_or_else(PoisonError::into_inner) + .insert(start, metadata.height); + entries.ok_or_else(|| { + Error::Generic(format!( + "no page of the private document type of contract \ + {data_contract_id} from position {start}" + )) + }) + }) + }; + Box::pin(private_document_page_walk( + fetch, + start_position, + max_concurrent, + )) + } + } + }) + .map(move |page| { + let page = page?; + let height = heights + .lock() + .unwrap_or_else(PoisonError::into_inner) + .remove(&page.start_position) + .ok_or_else(|| { + Error::Generic(format!( + "no proved height for the private document page from position {}", + page.start_position + )) + })?; + Ok((page, height)) + }) + } + /// Fetches the proved page of the store of the private type `document_type_name` of /// `data_contract_id` from `start_position`, and sorts its entries with `keys`. The context /// provider is given the contract with the type first when it lacks it @@ -1688,6 +1771,59 @@ mod tests { ); } + /// The pages read with their proved heights are those of the plain stream, in its order, + /// each with the height of the response its proof came with. + #[cfg(feature = "mocks")] + #[tokio::test] + async fn should_stream_each_page_with_its_proved_height() { + use crate::platform::private_document_entries::held_contract_provider::HeldContractProvider; + use crate::SdkBuilder; + use dpp::version::PlatformVersion; + + let mut sdk: Sdk = SdkBuilder::new_mock() + .with_version(PlatformVersion::latest()) + .with_context_provider(HeldContractProvider::new( + Some(contract_with_the_private_type()), + false, + )) + .build() + .expect("expected a mock sdk"); + let contract_id = Identifier::from(CONTRACT_ID); + for start in [2, 4, 8, 12] { + sdk.mock() + .expect_fetch( + PrivateDocumentEntriesQuery::new(contract_id, TYPE_NAME, start), + Some(drive_page(4, 14, start)), + ) + .await + .expect("expected to register the page"); + } + let plain: Vec = sdk + .private_document_pages(contract_id, TYPE_NAME, 2, 3) + .map(|page| page.expect("every page reads")) + .collect() + .await; + let with_heights: Vec<(PrivateDocumentPage, u64)> = sdk + .private_document_pages_with_heights(contract_id, TYPE_NAME, 2, 3) + .map(|page| page.expect("every page reads")) + .collect() + .await; + assert_eq!( + with_heights + .iter() + .map(|(page, _)| page.clone()) + .collect::>(), + plain + ); + // The mock answers every page with the same response metadata. + let heights: Vec = with_heights.iter().map(|(_, height)| *height).collect(); + assert_eq!(heights.len(), 4); + assert!( + heights.windows(2).all(|pair| pair[0] == pair[1]), + "{heights:?}" + ); + } + /// DashPay as the latest version has it, under the test contract's id: a contract that /// declares the private type the test entries are sealed for. #[cfg(feature = "mocks")] From d0d337bd014e3160b4af97c47451bb417a64b283 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 9 Nov 2026 23:20:49 +0700 Subject: [PATCH 110/117] fix(sdk): give a completed private document page the lowest height of its reads A page the walk completes from where an older snapshot ended holds two reads: the first, from a node behind, and the rest of its chunk. private_document_pages_with_heights now yields it with the lower of their heights, rather than the first read's alone, and a page fetched again keeps the height of the read the walk kept. The heights are taken in a walk helper over a fetch that returns each read's proved height, which the tests drive with heights of their own. Co-Authored-By: Claude Opus 5.5 --- .../src/platform/private_document_scan.rs | 216 +++++++++++++----- 1 file changed, 158 insertions(+), 58 deletions(-) diff --git a/packages/rs-sdk/src/platform/private_document_scan.rs b/packages/rs-sdk/src/platform/private_document_scan.rs index 23235900685..c7717488172 100644 --- a/packages/rs-sdk/src/platform/private_document_scan.rs +++ b/packages/rs-sdk/src/platform/private_document_scan.rs @@ -440,6 +440,11 @@ pub struct PrivateDocumentPage { type PageFuture = Pin> + Send>>; type PageStream = Pin> + Send>>; +/// A page read with the height of the proof it was read with. +type HeightedPageFuture = + Pin> + Send>>; +type HeightedPageStream = + Pin> + Send>>; /// Where a walk over a store's pages is. enum PageWalk { @@ -575,6 +580,60 @@ where ) } +/// [`private_document_page_walk`] over `fetch`, which reads a page with the height of its +/// proof, each page yielded with the lowest height of the reads it holds: the read from its +/// start, and those completing it from where an older snapshot ended +/// ([`whole_chunk_pages`]). A start fetched again keeps the height of its last read, the one +/// the walk kept. +fn private_document_page_walk_with_heights( + fetch: F, + start_position: u64, + max_concurrent: usize, +) -> impl Stream> + Send +where + F: Fn(u64) -> HeightedPageFuture + Clone + Send + Sync + 'static, +{ + // The height of the last read from each start, until the page holding + // that start is yielded. Pages hold disjoint ranges of positions, and + // every read a page holds starts inside its range. + let heights: Arc>> = Arc::default(); + let read_heights = Arc::clone(&heights); + let fetch = move |start: u64| -> PageFuture { + let fetch = fetch.clone(); + let heights = Arc::clone(&read_heights); + Box::pin(async move { + let (entries, height) = fetch(start).await?; + heights + .lock() + .unwrap_or_else(PoisonError::into_inner) + .insert(start, height); + Ok(entries) + }) + }; + private_document_page_walk(fetch, start_position, max_concurrent).map(move |page| { + let page = page?; + let start = page.start_position; + let end = start + .saturating_add(page.entries.entries.len() as u64) + .max(start.saturating_add(1)); + let mut heights = heights.lock().unwrap_or_else(PoisonError::into_inner); + let mut read = heights.split_off(&start); + let mut later = read.split_off(&end); + heights.append(&mut later); + drop(heights); + let height = read + .contains_key(&start) + .then(|| read.into_values().min()) + .flatten() + .ok_or_else(|| { + Error::Generic(format!( + "no proved height for the private document page from position {start}" + )) + })?; + Ok((page, height)) + }) +} + /// How many more times a page whose snapshot of the store ends at or before its start is /// fetched before the walk gives up on it ([`fetch_ahead_of`]). const BEHIND_PAGE_REFETCHES: usize = 2; @@ -758,8 +817,9 @@ impl Sdk { /// [`Self::private_document_pages`], with the height each page's proof was made at, from /// the response metadata verified with it: for a caller that must show every page, and - /// the store's end the first page reports, at or above a height. The pages and the walk - /// are those of [`Self::private_document_pages`]. + /// the store's end the first page reports, at or above a height. A page completed from + /// more than one read, the first from a node behind, carries the lowest of their heights. + /// The pages and the walk are those of [`Self::private_document_pages`]. pub fn private_document_pages_with_heights( &self, data_contract_id: Identifier, @@ -769,9 +829,6 @@ impl Sdk { ) -> impl Stream> + Send { let sdk = self.clone(); let document_type_name = document_type_name.to_string(); - // Each page is fetched once, from its own start: the height of the - // proof read from a start is kept under it until the page is yielded. - let heights: Arc>> = Arc::default(); let resolve = { let sdk = sdk.clone(); let document_type_name = document_type_name.clone(); @@ -780,61 +837,40 @@ impl Sdk { .await } }; - let page_heights = Arc::clone(&heights); - stream::once(resolve) - .flat_map(move |resolved| -> PageStream { - match resolved { - Err(error) => Box::pin(stream::once(async move { Err(error) })), - Ok(_) => { + stream::once(resolve).flat_map(move |resolved| -> HeightedPageStream { + match resolved { + Err(error) => Box::pin(stream::once(async move { Err(error) })), + Ok(_) => { + let sdk = sdk.clone(); + let document_type_name = document_type_name.clone(); + let fetch = move |start: u64| -> HeightedPageFuture { let sdk = sdk.clone(); - let document_type_name = document_type_name.clone(); - let heights = Arc::clone(&page_heights); - let fetch = move |start: u64| -> PageFuture { - let sdk = sdk.clone(); - let heights = Arc::clone(&heights); - let query = PrivateDocumentEntriesQuery::new( - data_contract_id, - document_type_name.clone(), - start, - ); - Box::pin(async move { - let (entries, metadata) = - PrivateDocumentEntries::fetch_with_metadata(&sdk, query, None) - .await?; - heights - .lock() - .unwrap_or_else(PoisonError::into_inner) - .insert(start, metadata.height); - entries.ok_or_else(|| { - Error::Generic(format!( - "no page of the private document type of contract \ - {data_contract_id} from position {start}" - )) - }) - }) - }; - Box::pin(private_document_page_walk( - fetch, - start_position, - max_concurrent, - )) - } + let query = PrivateDocumentEntriesQuery::new( + data_contract_id, + document_type_name.clone(), + start, + ); + Box::pin(async move { + let (entries, metadata) = + PrivateDocumentEntries::fetch_with_metadata(&sdk, query, None) + .await?; + let entries = entries.ok_or_else(|| { + Error::Generic(format!( + "no page of the private document type of contract \ + {data_contract_id} from position {start}" + )) + })?; + Ok((entries, metadata.height)) + }) + }; + Box::pin(private_document_page_walk_with_heights( + fetch, + start_position, + max_concurrent, + )) } - }) - .map(move |page| { - let page = page?; - let height = heights - .lock() - .unwrap_or_else(PoisonError::into_inner) - .remove(&page.start_position) - .ok_or_else(|| { - Error::Generic(format!( - "no proved height for the private document page from position {}", - page.start_position - )) - })?; - Ok((page, height)) - }) + } + }) } /// Fetches the proved page of the store of the private type `document_type_name` of @@ -1521,6 +1557,70 @@ mod tests { assert_eq!(positions, (0..30).collect::>()); } + /// A page completed from where an older snapshot ended carries the lower height of its two + /// reads; every other page, the height of its own. + #[tokio::test] + async fn should_yield_a_completed_page_at_the_lowest_height_of_its_reads() { + let fetch = |start: u64| -> HeightedPageFuture { + Box::pin(async move { + // The node answering the page from 12 is behind, at height 7: its store held + // 14 entries. The rest of that chunk, and every other page, come at height 9. + if start == 12 { + return Ok((drive_page(4, 14, start), 7)); + } + Ok((drive_page(4, 30, start), 9)) + }) + }; + let pages: Vec<(PrivateDocumentPage, u64)> = private_document_page_walk_with_heights( + fetch, + 0, + MAX_CONCURRENT_PRIVATE_DOCUMENT_PAGE_FETCHES, + ) + .map(|page| page.expect("every page reads")) + .collect() + .await; + let mut positions = Vec::new(); + for (page, height) in &pages { + let expected = if page.start_position == 12 { 7 } else { 9 }; + assert_eq!(*height, expected, "page from {}", page.start_position); + positions.extend(page.entries.entries.iter().map(|entry| entry.position)); + } + assert_eq!(positions, (0..30).collect::>()); + } + + /// A page fetched again, its first read from a node behind that held no entry from its + /// start, carries the height of the read the walk kept. + #[tokio::test] + async fn should_yield_a_page_fetched_again_at_the_height_of_its_kept_read() { + let behind_once = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(true)); + let fetch = move |start: u64| -> HeightedPageFuture { + let behind_once = std::sync::Arc::clone(&behind_once); + Box::pin(async move { + if start == 8 && behind_once.swap(false, std::sync::atomic::Ordering::SeqCst) { + return Ok((drive_page(4, 8, start), 5)); + } + Ok((drive_page(4, 30, start), 9)) + }) + }; + let pages: Vec<(PrivateDocumentPage, u64)> = private_document_page_walk_with_heights( + fetch, + 0, + MAX_CONCURRENT_PRIVATE_DOCUMENT_PAGE_FETCHES, + ) + .map(|page| page.expect("every page reads")) + .collect() + .await; + assert!( + pages.iter().all(|(_, height)| *height == 9), + "{:?}", + pages + .iter() + .map(|(page, height)| (page.start_position, *height)) + .collect::>() + ); + assert_eq!(pages.len(), 8); + } + /// A fetch of Drive's pages of a store of 30 entries in chunks of 4 where the fetch from /// `behind` is answered, its first `times` times, by a node whose store held `behind` /// entries: no entry from there. From 0996db2f925ebaa9f9aa68928026b73a924554ad Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 9 Nov 2026 23:56:49 +0700 Subject: [PATCH 111/117] feat(sdk): let the mock SDK report a chosen response height MockDashPlatformSdk::set_response_height sets the block height the metadata of every response answered from an expectation reports, as set_response_time_ms sets its time: for code under test that reads the height a response was proved at. It stays 0 unless set, so existing mocks are unchanged. Co-Authored-By: Claude Opus 5.5 --- packages/rs-sdk/src/mock/sdk.rs | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/packages/rs-sdk/src/mock/sdk.rs b/packages/rs-sdk/src/mock/sdk.rs index f4da24120ff..a74f922faea 100644 --- a/packages/rs-sdk/src/mock/sdk.rs +++ b/packages/rs-sdk/src/mock/sdk.rs @@ -48,6 +48,9 @@ pub struct MockDashPlatformSdk { /// The block time the metadata of a response answered from an expectation reports /// ([Self::set_response_time_ms]); 0 unless set. response_time_ms: u64, + /// The block height the metadata of a response answered from an expectation reports + /// ([Self::set_response_height]); 0 unless set. + response_height: u64, } impl MockDashPlatformSdk { @@ -80,6 +83,7 @@ impl MockDashPlatformSdk { dapi, sdk: ArcSwapOption::new(None), response_time_ms: 0, + response_height: 0, } } @@ -90,6 +94,13 @@ impl MockDashPlatformSdk { self } + /// Sets the block height the metadata of every response answered from an expectation + /// reports, for code under test that reads the height a response was proved at. + pub fn set_response_height(&mut self, height: u64) -> &mut Self { + self.response_height = height; + self + } + pub(crate) fn set_sdk(&mut self, sdk: Sdk) { self.sdk.store(Some(Arc::new(sdk))); } @@ -608,6 +619,7 @@ impl MockDashPlatformSdk { ResponseMetadata { protocol_version: dpp::version::LATEST_VERSION, time_ms: self.response_time_ms, + height: self.response_height, ..Default::default() }, Proof::default(), From ad25184cf6bcae2e858bd4f5b5ced32fc68b9801 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 9 Nov 2026 23:57:00 +0700 Subject: [PATCH 112/117] fix(platform-wallet): prove a private payment expired only on evidence at its anchor set's height An expired private payment's journal record was judged against the live journal after the scan, and against reads of any height. A payment journaled during the pass, against an anchor newer than the set fetched, looked pruned; a note scan or a store read from a node behind the anchor set's proof could miss the payment executing. Now a pass snapshots the journal records before it fetches the anchor set, keeps the set's proved height H, and discharges a snapshot record only when all of these hold, failing closed and retrying next pass otherwise: - the record still reads exactly as snapshotted, so one journaled or armed again since is no candidate; - its anchor was read from a proof at or below H (the record keeps that height, journal format v3; a record migrated from before the journal has none and is never proven expired), and it is absent from the set; - the note scan covers H: a completed pass reports the highest proved height of a batch in its gap-free run whose proof shows the tree ending within it, and an input note is still unspent; - DashPay's private store is read through its end with every page proved at or above H, and holds no entry with the record's commitment. Only candidate commitments are kept from that read. Co-Authored-By: Claude Opus 5.5 --- .../rs-platform-wallet/src/manager/load.rs | 1 + .../network/private_contact_requests.rs | 415 ++++++++++++++---- .../src/wallet/platform_wallet.rs | 3 + .../src/wallet/shielded/coordinator.rs | 295 +++++++++---- .../src/wallet/shielded/file_store.rs | 11 +- .../src/wallet/shielded/operations.rs | 52 ++- .../src/wallet/shielded/store.rs | 36 +- .../src/wallet/shielded/sync.rs | 23 + .../src/wallet/shielded/sync/cancel_tests.rs | 61 ++- 9 files changed, 693 insertions(+), 204 deletions(-) diff --git a/packages/rs-platform-wallet/src/manager/load.rs b/packages/rs-platform-wallet/src/manager/load.rs index cad98556b14..784ed989b70 100644 --- a/packages/rs-platform-wallet/src/manager/load.rs +++ b/packages/rs-platform-wallet/src/manager/load.rs @@ -2105,6 +2105,7 @@ mod private_payment_hold_tests { account_reference: 0, }, &[[0xC1; 32]], + 0, ) .expect("the payment saved"); } diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/private_contact_requests.rs b/packages/rs-platform-wallet/src/wallet/identity/network/private_contact_requests.rs index f267955811c..56e738ba7eb 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/private_contact_requests.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/private_contact_requests.rs @@ -2243,6 +2243,8 @@ mod tests { use crate::wallet::identity::network::SeedCryptoProvider; use crate::wallet::platform_wallet::{PlatformWallet, WalletId}; #[cfg(feature = "shielded")] + use crate::wallet::shielded::coordinator::private_payment_journal_snapshot; + #[cfg(feature = "shielded")] use crate::wallet::shielded::operations::arm_redrive_before_broadcast; #[cfg(feature = "shielded")] use crate::wallet::shielded::{ @@ -3567,6 +3569,7 @@ mod tests { "private document create from shielded pool", Some(payment), Some(&started), + 0, ) .await?; Err::(PlatformWalletError::ShieldedSpendUnconfirmed { @@ -6207,6 +6210,7 @@ mod tests { .expect("the send names its request"), }), Some(&started), + 0, ) .await?; // What the payment returns once the endpoint answered with a @@ -6333,6 +6337,7 @@ mod tests { .expect("the send names its request"), }), Some(&started), + 0, ) .await?; Err::(PlatformWalletError::ShieldedSpendUnconfirmed { @@ -6426,35 +6431,31 @@ mod tests { assert_eq!(retry.lock().expect("retry").len(), 1, "the retry pays"); } - /// A private payment that never executes is proven expired once its - /// anchor is pruned, one of its input notes still unspent and its entry - /// absent from the store: the coordinator discharges its journal record, - /// the pair is freed, a retry to it pays, and a reopened store restores - /// no hold. A still-recorded anchor, a spent input or an entry in the - /// store each keep the record and the hold. + /// Alice's wallet, bound to a coordinator over a new store, holding one + /// unspent input note: the private payment expiry tests pay from it. #[cfg(feature = "shielded")] - #[tokio::test] - async fn should_free_a_pair_once_its_private_payment_provably_expired() { - #[derive(Clone, Copy, Debug, PartialEq)] - enum Case { - Expired, - AnchorRecorded, - InputSpent, - EntryAppended, - } - let alice = identity(ALICE_PHRASE, ALICE); - let bob = identity(BOB_PHRASE, BOB); - let alice_crypto = SeedCryptoProvider::from_seed(seed(ALICE_PHRASE), Network::Testnet); - let alice_signer = signer(ALICE_PHRASE, &alice); - let anchor = [0x7C; 32]; - for case in [ - Case::Expired, - Case::AnchorRecorded, - Case::InputSpent, - Case::EntryAppended, - ] { + struct ExpiryFixture { + _manager: PlatformWalletManager, + wallet: Arc, + coordinator: Arc, + subwallet: SubwalletId, + input: ShieldedNote, + db_path: std::path::PathBuf, + _dir: tempfile::TempDir, + } + + #[cfg(feature = "shielded")] + impl ExpiryFixture { + /// The proved height of the anchor set the expiry tests judge by. + const ANCHOR_SET_HEIGHT: u64 = 10; + /// The anchor the payment's record names. + const ANCHOR: [u8; 32] = [0x7C; 32]; + + async fn new() -> Self { + let alice = identity(ALICE_PHRASE, ALICE); + let bob = identity(BOB_PHRASE, BOB); // A backend that keeps the viewing key, so the wallet binds. - let (_manager, alice_wallet) = wallet_on( + let (manager, wallet) = wallet_on( RecordingPersistence::attesting( PersistenceCapabilities::SHIELDED_FVK_RESTART .union(PersistenceCapabilities::PRIVATE_CONTACT_SCAN_STATE_RESTART), @@ -6468,18 +6469,18 @@ mod tests { let dir = tempfile::tempdir().expect("temporary shielded store"); let db_path = dir.path().join("tree.sqlite"); let coordinator = Arc::new(NetworkShieldedCoordinator::new( - Arc::clone(&alice_wallet.identity().sdk), + Arc::clone(&wallet.identity().sdk), Network::Testnet, db_path.clone(), FileBackedShieldedStore::open_path(&db_path, 100).expect("the store"), )); // Bound, the coordinator holds the wallet's persister, on which it // notes the discharge. - alice_wallet + wallet .bind_shielded(&seed(ALICE_PHRASE), &[0], &coordinator) .await .expect("bound"); - let subwallet = SubwalletId::new(alice_wallet.wallet_id(), 0); + let subwallet = SubwalletId::new(wallet.wallet_id(), 0); let input = ShieldedNote { position: 0, cmx: [0x11; 32], @@ -6495,10 +6496,38 @@ mod tests { .await .save_note(subwallet, &input) .expect("the input note"); + Self { + _manager: manager, + wallet, + coordinator, + subwallet, + input, + db_path, + _dir: dir, + } + } + + /// Every response the mock answers is proved at `height`. + fn prove_at(&self, height: u64) { + let mut sdk = (*self.wallet.identity().sdk).clone(); + sdk.mock().set_response_height(height); + } + + /// Send a private request from Alice to Bob whose payment, spending + /// the input note against `anchor` read from a proof at + /// `anchor_height`, is journaled and times out unconfirmed. Returns + /// the entry paid for. + async fn pay_unconfirmed(&self, anchor: [u8; 32], anchor_height: u64) -> BinaryData { + let alice = identity(ALICE_PHRASE, ALICE); + let bob = identity(BOB_PHRASE, BOB); + let alice_crypto = SeedCryptoProvider::from_seed(seed(ALICE_PHRASE), Network::Testnet); + let alice_signer = signer(ALICE_PHRASE, &alice); let paid = Arc::new(Mutex::new(Vec::new())); let pay = { - let store = Arc::clone(coordinator.store()); + let store = Arc::clone(self.coordinator.store()); let paid = Arc::clone(&paid); + let subwallet = self.subwallet; + let nullifier = self.input.nullifier; let (sender, recipient) = (alice.id(), bob.id()); move |entry: BinaryData, started: PaymentStarted| async move { paid.lock().expect("paid").push(entry.clone()); @@ -6508,7 +6537,7 @@ mod tests { subwallet, Some([0x7D; 32]), anchor, - vec![[0x12; 32]], + vec![nullifier], &transition, "private document create from shielded pool", Some(PrivateContactPayment { @@ -6519,6 +6548,7 @@ mod tests { .expect("the send names its request"), }), Some(&started), + anchor_height, ) .await?; Err::(PlatformWalletError::ShieldedSpendUnconfirmed { @@ -6527,8 +6557,10 @@ mod tests { }) } }; - let view = alice_wallet.identity().dashpay(); - let sent = view + let sent = self + .wallet + .identity() + .dashpay() .send_private_contact_request( &alice.id(), &bob.id(), @@ -6548,59 +6580,62 @@ mod tests { "{sent:?}" ); let entry = paid.lock().expect("paid")[0].clone(); - match case { - Case::InputSpent => { - coordinator - .store() - .write() - .await - .mark_spent(subwallet, &input.nullifier) - .expect("spent"); - } - Case::EntryAppended => publish(&alice_wallet, 0, vec![entry.to_vec()]).await, - Case::Expired | Case::AnchorRecorded => {} - } - let recorded: HashSet<[u8; 32]> = match case { - Case::AnchorRecorded => HashSet::from([anchor]), - _ => HashSet::new(), - }; - coordinator - .discharge_expired_private_payments([alice_wallet.wallet_id()], &recorded) + entry + } + + /// The journal records a pass reads before it fetches the anchor set. + async fn journal_snapshot( + &self, + ) -> Vec<(WalletId, crate::wallet::shielded::PrivatePaymentRecord)> { + private_payment_journal_snapshot( + &*self.coordinator.store().read().await, + [self.wallet.wallet_id()], + ) + } + + /// The discharge a pass runs after a note scan covering + /// `scan_covered_height`, against `journal` and the anchor set + /// `recorded` proved at [`Self::ANCHOR_SET_HEIGHT`]. + async fn discharge( + &self, + journal: &[(WalletId, crate::wallet::shielded::PrivatePaymentRecord)], + recorded: &HashSet<[u8; 32]>, + scan_covered_height: Option, + ) { + self.coordinator + .discharge_expired_private_payments( + journal, + recorded, + Self::ANCHOR_SET_HEIGHT, + scan_covered_height, + ) .await; + } - let journaled = coordinator + /// How many records the wallet's journal holds. + async fn journaled(&self) -> usize { + self.coordinator .store() .read() .await - .private_payment_journal(alice_wallet.wallet_id()) + .private_payment_journal(self.wallet.wallet_id()) .expect("the journal") - .len(); - if case != Case::Expired { - assert_eq!(journaled, 1, "{case:?}: the record stays"); - let held = view - .send_private_contact_request( - &alice.id(), - &bob.id(), - None, - AutoAcceptProofSource::None, - None, - &alice_signer, - &alice_crypto, - no_payment, - ) - .await; - assert!( - matches!( - held, - Err(PlatformWalletError::PrivateContactRequestPending(_)) - ), - "{case:?}: {held:?}" - ); - continue; - } - assert_eq!(journaled, 0, "discharged"); - let retry = Arc::new(Mutex::new(Vec::new())); - let retried = view + .len() + } + + /// Send Alice's request to Bob again with `pay`. + async fn send_again(&self, pay: F) -> Result + where + F: FnOnce(BinaryData, PaymentStarted) -> Fut + Send, + Fut: Future> + Send, + { + let alice = identity(ALICE_PHRASE, ALICE); + let bob = identity(BOB_PHRASE, BOB); + let alice_crypto = SeedCryptoProvider::from_seed(seed(ALICE_PHRASE), Network::Testnet); + let alice_signer = signer(ALICE_PHRASE, &alice); + self.wallet + .identity() + .dashpay() .send_private_contact_request( &alice.id(), &bob.id(), @@ -6609,27 +6644,221 @@ mod tests { None, &alice_signer, &alice_crypto, - keep_entry(&retry), + pay, ) - .await; + .await + } + + /// The record stays and the pair it holds stays held. + async fn assert_kept(&self, case: &str) { + assert_eq!(self.journaled().await, 1, "{case}: the record stays"); + let held = self.send_again(no_payment).await; + assert!( + matches!( + held, + Err(PlatformWalletError::PrivateContactRequestPending(_)) + ), + "{case}: {held:?}" + ); + } + + /// The record is discharged, durably, and a retry to the pair pays. + async fn assert_discharged(&self, case: &str) { + assert_eq!(self.journaled().await, 0, "{case}: discharged"); + let retry = Arc::new(Mutex::new(Vec::new())); + let retried = self.send_again(keep_entry(&retry)).await; assert!( matches!( retried, Err(PlatformWalletError::PrivateContactRequestPending(_)) ), - "paid, not read back: {retried:?}" + "{case}: paid, not read back: {retried:?}" + ); + assert_eq!( + retry.lock().expect("retry").len(), + 1, + "{case}: the retry pays" + ); + let reopened = + FileBackedShieldedStore::open_path(&self.db_path, 100).expect("reopened"); + assert!( + reopened + .private_payment_journal(self.wallet.wallet_id()) + .expect("the journal") + .is_empty(), + "{case}: the reopened store keeps the discharge" ); - assert_eq!(retry.lock().expect("retry").len(), 1, "the retry pays"); - // Reopened, the store keeps the discharge. - drop(coordinator); - let reopened = FileBackedShieldedStore::open_path(&db_path, 100).expect("reopened"); - assert!(reopened - .private_payment_journal(alice_wallet.wallet_id()) - .expect("the journal") - .is_empty()); } } + /// A private payment that never executes is proven expired once its + /// anchor is pruned, one of its input notes still unspent and its entry + /// absent from the store: the coordinator discharges its journal record, + /// the pair is freed, a retry to it pays, and a reopened store restores + /// no hold. A still-recorded anchor, a spent input or an entry in the + /// store each keep the record and the hold. + #[cfg(feature = "shielded")] + #[tokio::test] + async fn should_free_a_pair_once_its_private_payment_provably_expired() { + #[derive(Clone, Copy, Debug, PartialEq)] + enum Case { + Expired, + AnchorRecorded, + InputSpent, + EntryAppended, + } + let height = ExpiryFixture::ANCHOR_SET_HEIGHT; + for case in [ + Case::Expired, + Case::AnchorRecorded, + Case::InputSpent, + Case::EntryAppended, + ] { + let fixture = ExpiryFixture::new().await; + fixture.prove_at(height); + let entry = fixture.pay_unconfirmed(ExpiryFixture::ANCHOR, height).await; + match case { + Case::InputSpent => { + fixture + .coordinator + .store() + .write() + .await + .mark_spent(fixture.subwallet, &fixture.input.nullifier) + .expect("spent"); + } + Case::EntryAppended => publish(&fixture.wallet, 0, vec![entry.to_vec()]).await, + Case::Expired | Case::AnchorRecorded => {} + } + let recorded: HashSet<[u8; 32]> = match case { + Case::AnchorRecorded => HashSet::from([ExpiryFixture::ANCHOR]), + _ => HashSet::new(), + }; + let journal = fixture.journal_snapshot().await; + fixture.discharge(&journal, &recorded, Some(height)).await; + if case == Case::Expired { + fixture.assert_discharged(&format!("{case:?}")).await; + } else { + fixture.assert_kept(&format!("{case:?}")).await; + } + } + } + + /// The expiry evidence is pinned to the anchor set's proved height H and + /// to the journal records read before it: a record journaled after the + /// snapshot, one armed again since, one whose anchor was read above H, a + /// note scan or a store read below H each discharge nothing. The payment + /// whose scan or store read was below H is discharged once both cover H, + /// and a retry to its pair pays. + #[cfg(feature = "shielded")] + #[tokio::test] + async fn should_discharge_an_expired_private_payment_only_on_evidence_at_its_anchor_set_height() + { + #[derive(Clone, Copy, Debug, PartialEq)] + enum Case { + JournaledAfterSnapshot, + ArmedAgainSinceSnapshot, + AnchorReadAboveSet, + ScanBelowSet, + StoreBelowSet, + } + let height = ExpiryFixture::ANCHOR_SET_HEIGHT; + let recorded = HashSet::new(); + for case in [ + Case::JournaledAfterSnapshot, + Case::ArmedAgainSinceSnapshot, + Case::AnchorReadAboveSet, + Case::ScanBelowSet, + Case::StoreBelowSet, + ] { + let fixture = ExpiryFixture::new().await; + // Read before the anchor set: the payment is journaled after it. + let before = fixture.journal_snapshot().await; + fixture.prove_at(match case { + Case::StoreBelowSet => height - 1, + _ => height, + }); + let anchor_height = match case { + Case::AnchorReadAboveSet => height + 1, + _ => height, + }; + let entry = fixture + .pay_unconfirmed(ExpiryFixture::ANCHOR, anchor_height) + .await; + let journal = match case { + Case::JournaledAfterSnapshot => before, + _ => fixture.journal_snapshot().await, + }; + assert!( + case == Case::JournaledAfterSnapshot || journal.len() == 1, + "{case:?}: the snapshot holds the record" + ); + if case == Case::ArmedAgainSinceSnapshot { + // A retry armed the same entry again, against a newer anchor + // the set may not hold yet. + let payment = journal[0].1.payment; + arm_redrive_before_broadcast( + fixture.coordinator.store(), + fixture.subwallet, + Some([0x7D; 32]), + [0x7E; 32], + vec![fixture.input.nullifier], + &private_contact_request_payment(entry.clone()), + "private document create from shielded pool", + Some(payment), + None, + height, + ) + .await + .expect("armed again"); + } + let scan_covered_height = match case { + Case::ScanBelowSet => Some(height - 1), + _ => Some(height), + }; + fixture + .discharge(&journal, &recorded, scan_covered_height) + .await; + fixture.assert_kept(&format!("{case:?}")).await; + if matches!(case, Case::ScanBelowSet | Case::StoreBelowSet) { + // The same payment, once the scan and the store read cover H. + fixture.prove_at(height); + fixture.discharge(&journal, &recorded, Some(height)).await; + fixture + .assert_discharged(&format!("{case:?} at the set's height")) + .await; + } + } + } + + /// The store read for expiry keeps only the candidates it finds, however + /// many other entries it reads to show them absent. + #[cfg(feature = "shielded")] + #[tokio::test] + async fn should_keep_only_candidate_commitments_from_the_store_read_for_expiry() { + let fixture = ExpiryFixture::new().await; + let held = [0xC1; 32]; + let absent = [0xC2; 32]; + let mut entries: Vec> = (0u32..2_000) + .map(|index| { + let mut entry = vec![0u8; 64]; + entry[..4].copy_from_slice(&index.to_be_bytes()); + entry[4] = 0xEE; + entry + }) + .collect(); + let mut candidate_entry = held.to_vec(); + candidate_entry.extend_from_slice(&[0u8; 32]); + entries.insert(1_234, candidate_entry); + publish(&fixture.wallet, 0, entries).await; + let kept = fixture + .coordinator + .private_contact_requests_held(&HashSet::from([held, absent]), 0) + .await + .expect("the store reads"); + assert_eq!(kept, HashSet::from([held])); + } + /// A payment that may have reached the network keeps its pair pending /// whatever error it returns, as its transition may still execute; one /// whose transition provably never left the wallet releases it. @@ -6755,6 +6984,7 @@ mod tests { "private document create from shielded pool", None, Some(&started), + 0, ) .await?; std::future::pending::<()>().await; @@ -7227,6 +7457,7 @@ mod tests { "private document create from shielded pool", Some(payment), Some(&started), + 0, ) .await?; Err::(PlatformWalletError::ShieldedSpendUnconfirmed { diff --git a/packages/rs-platform-wallet/src/wallet/platform_wallet.rs b/packages/rs-platform-wallet/src/wallet/platform_wallet.rs index f604e33bce7..6ead3e8aa83 100644 --- a/packages/rs-platform-wallet/src/wallet/platform_wallet.rs +++ b/packages/rs-platform-wallet/src/wallet/platform_wallet.rs @@ -3097,6 +3097,9 @@ where account_index: id.account_index, activity_id: redrive.activity_id, anchor: redrive.anchor, + // Unknown for a payment saved before the journal: its + // expiry is never proven, so its pair stays held. + anchor_height: u64::MAX, commitment, payment: *payment, nullifiers: redrive.nullifiers.clone(), diff --git a/packages/rs-platform-wallet/src/wallet/shielded/coordinator.rs b/packages/rs-platform-wallet/src/wallet/shielded/coordinator.rs index 25293d121f2..f454abaf532 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/coordinator.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/coordinator.rs @@ -90,8 +90,8 @@ use super::balance::{ use super::file_store::FileBackedShieldedStore; use super::keys::AccountViewingKeys; use super::store::{ - IdentityDebitRecoveryRecord, IdentityDebitRecoveryStatus, ShieldedStore, StalePendingSpend, - SubwalletId, + IdentityDebitRecoveryRecord, IdentityDebitRecoveryStatus, PrivatePaymentRecord, ShieldedStore, + StalePendingSpend, SubwalletId, }; use super::CAUGHT_UP_COOLDOWN; use crate::error::PlatformWalletError; @@ -1506,7 +1506,13 @@ impl NetworkShieldedCoordinator { // Runs before the balance read so freed notes are reflected in // this pass's balances. Skipped on a cancelled pass: the re-drive // broadcasts, and a skipped release simply waits for the next pass. - if let Some((snapshot, recorded)) = stranded_release.filter(|_| !cancelled) { + if let Some(PreScanEvidence { + stale, + journal, + recorded, + recorded_height, + }) = stranded_release.filter(|_| !cancelled) + { for (id, _) in &subwallets { // Network broadcasts: a stop that lands after the scan's // snapshot above stops the loop here, and the helper @@ -1524,10 +1530,12 @@ impl NetworkShieldedCoordinator { .await; } if !cancel.is_cancelled() { - self.release_stranded_spends(snapshot, &recorded).await; + self.release_stranded_spends(stale, &recorded).await; self.discharge_expired_private_payments( - subwallets.iter().map(|(id, _)| id.wallet_id), + &journal, &recorded, + recorded_height, + notes.covered_height, ) .await; } @@ -1679,30 +1687,37 @@ impl NetworkShieldedCoordinator { .unwrap_or(0) } - /// Pre-scan half of the stranded-reservation release: snapshot the - /// anchored reservations armed right now and fetch Platform's recorded - /// anchor set, or `None` when there is nothing to do. + /// Pre-scan half of the stranded-reservation release and of the private + /// payment expiry: snapshot the anchored reservations armed right now and + /// the wallets' private payment journal records, then fetch Platform's + /// recorded anchor set with the height of its proof, or `None` when there + /// is nothing to do. /// /// MUST run before the pass's note scan — the release's fund-safety /// argument ([`Self::release_stranded_spends`]) rests on both the - /// snapshot and the set predating the scan's spent-note reconcile. + /// snapshot and the set predating the scan's spent-note reconcile. The + /// journal is read before the set: a payment journaled after the fetch + /// may name a newer anchor than the set holds, so it is no candidate this + /// pass ([`Self::discharge_expired_private_payments`]). /// - /// Skips the network round-trip when no anchored reservation exists (the - /// common case), and treats a recorded-anchor fetch failure as - /// non-fatal — a sync must not fail because that query hiccupped; the - /// release simply waits for the next pass. + /// Skips the network round-trip when neither an anchored reservation nor + /// a journal record exists (the common case), and treats a + /// recorded-anchor fetch failure as non-fatal: a sync must not fail + /// because that query hiccupped, and the release simply waits for the + /// next pass. async fn prefetch_stranded_release( &self, subwallets: &[(SubwalletId, AccountViewingKeys)], - ) -> Option<(Vec<(SubwalletId, StalePendingSpend)>, HashSet<[u8; 32]>)> { - // Gather anchored reservations across every synced subwallet. The - // common case is none — then the network round-trip is skipped. - let stale: Vec<(SubwalletId, StalePendingSpend)> = { + ) -> Option { + // Gather anchored reservations across every synced subwallet, and the + // journal records of their wallets. The common case is none, and then + // the network round-trip is skipped. + let (stale, journal) = { let store = self.store.read().await; - let mut acc = Vec::new(); + let mut stale: Vec<(SubwalletId, StalePendingSpend)> = Vec::new(); for (id, _) in subwallets { match store.stale_pending_spends(*id) { - Ok(entries) => acc.extend(entries.into_iter().map(|entry| (*id, entry))), + Ok(entries) => stale.extend(entries.into_iter().map(|entry| (*id, entry))), Err(e) => tracing::warn!( wallet_id = %hex::encode(id.wallet_id), account = id.account_index, @@ -1711,27 +1726,25 @@ impl NetworkShieldedCoordinator { ), } } - acc - }; - // A private payment's journal record outlives its reservation: its - // expiry is judged against the same pre-scan anchor set - // (`Self::discharge_expired_private_payments`). - let journaled = { - let store = self.store.read().await; - subwallets.iter().any(|(id, _)| { - store - .private_payment_journal(id.wallet_id) - .is_ok_and(|records| !records.is_empty()) - }) + let journal = private_payment_journal_snapshot( + &*store, + subwallets.iter().map(|(id, _)| id.wallet_id), + ); + (stale, journal) }; - if stale.is_empty() && !journaled { + if stale.is_empty() && journal.is_empty() { return None; } use dash_sdk::platform::fetch_current_no_parameters::FetchCurrent; - match dash_sdk::query_types::ShieldedAnchors::fetch_current(&self.sdk).await { - Ok(dash_sdk::query_types::ShieldedAnchors(anchors)) => { - Some((stale, anchors.into_iter().collect())) + match dash_sdk::query_types::ShieldedAnchors::fetch_current_with_metadata(&self.sdk).await { + Ok((dash_sdk::query_types::ShieldedAnchors(anchors), metadata)) => { + Some(PreScanEvidence { + stale, + journal, + recorded: anchors.into_iter().collect(), + recorded_height: metadata.height, + }) } Err(e) => { tracing::warn!( @@ -1880,65 +1893,84 @@ impl NetworkShieldedCoordinator { /// Discharge the private payment journal records of payments proven /// expired, durably, and note them on their wallet's persister so the /// wallet releases the pairs they hold - /// (`WalletPersister::note_discharged_private_entries`). A payment is - /// proven never to execute when, after this pass's spent-note reconcile: + /// (`WalletPersister::note_discharged_private_entries`). /// - /// 1. its anchor is absent from the PRE-scan `recorded` set: a pruned - /// root never re-enters it, so the transition can never execute from - /// then on (`shielded_anchor_retention_blocks` 1000, pruned every - /// `shielded_anchor_pruning_interval` 100 blocks); - /// 2. one of its input notes is still unspent: had it executed before - /// the anchor was pruned, the reconcile would have seen its nullifiers - /// spent; - /// 3. its entry is absent from DashPay's private contact request store, - /// read raw after that anchor set was fetched: an entry starts with its + /// The candidates are `journal`, the records the pass read before it + /// fetched `recorded`, the anchor set proved at `recorded_height` (H). + /// A payment is proven never to execute when: + /// + /// 1. its record still reads in the journal exactly as `journal` has it: + /// a payment journaled since, or armed again, may name an anchor newer + /// than the set; + /// 2. its anchor was read from a proof at or below H (`anchor_height`), so + /// the set at H holds it unless it was pruned, and it is absent from + /// the set: a pruned root never re-enters it, so the transition cannot + /// execute above H (`shielded_anchor_retention_blocks` 1000, pruned + /// every `shielded_anchor_pruning_interval` 100 blocks); + /// 3. one of its input notes is still unspent after this pass's note scan, + /// which covers H (`scan_covered_height`, + /// `MultiSyncNotesResult::covered_height`): had it executed at or below + /// H, the scan would have seen its nullifiers spent; + /// 4. its entry is absent from DashPay's private contact request store, + /// read raw through its end with every page proved at or above H + /// ([`Self::private_contact_requests_held`]): an entry starts with its /// commitment, so none needs opening. /// - /// A relay refusal or a missing redrive record is never taken as proof. - /// A store read that fails discharges nothing; the next pass retries, - /// the journal record keeping the anchor. + /// Anything short of that discharges nothing this pass, and the next pass + /// retries: a scan or a store read below H, a read that fails, a record + /// changed since. A relay refusal or a missing redrive record is never + /// taken as proof. Points 1 and 3 are checked again under the store write + /// guard that discharges the record. pub(crate) async fn discharge_expired_private_payments( &self, - wallet_ids: impl IntoIterator, + journal: &[(WalletId, PrivatePaymentRecord)], recorded: &HashSet<[u8; 32]>, + recorded_height: u64, + scan_covered_height: Option, ) { - let wallets: std::collections::BTreeSet = wallet_ids.into_iter().collect(); - let candidates: Vec<(WalletId, super::store::PrivatePaymentRecord)> = { + let pruned: Vec<&(WalletId, PrivatePaymentRecord)> = journal + .iter() + .filter(|(_, record)| { + record.anchor_height <= recorded_height && !recorded.contains(&record.anchor) + }) + .collect(); + if pruned.is_empty() { + return; + } + if !matches!(scan_covered_height, Some(covered) if covered >= recorded_height) { + tracing::debug!( + ?scan_covered_height, + recorded_height, + "private payment expiry: the note scan does not cover the anchor set's height; \ + retried next pass" + ); + return; + } + let candidates: Vec<(WalletId, PrivatePaymentRecord)> = { let store = self.store.read().await; - let mut candidates = Vec::new(); - for wallet_id in &wallets { - let Ok(records) = store.private_payment_journal(*wallet_id) else { - continue; - }; - for record in records { - if recorded.contains(&record.anchor) { - continue; - } - let notes = store - .get_all_notes(SubwalletId::new(*wallet_id, record.account_index)) - .unwrap_or_default(); - let input_unspent = record.nullifiers.iter().any(|nullifier| { - notes - .iter() - .any(|note| note.nullifier == *nullifier && !note.is_spent) - }); - if input_unspent { - candidates.push((*wallet_id, record)); - } - } - } - candidates + pruned + .into_iter() + .filter(|(wallet_id, record)| expirable(&*store, *wallet_id, record)) + .cloned() + .collect() }; if candidates.is_empty() { return; } - let appended = match self.private_contact_request_commitments().await { - Ok(appended) => appended, + let commitments: HashSet<[u8; 32]> = candidates + .iter() + .map(|(_, record)| record.commitment) + .collect(); + let held = match self + .private_contact_requests_held(&commitments, recorded_height) + .await + { + Ok(held) => held, Err(error) => { tracing::warn!( %error, - "private payment expiry: the private contact request store was not read; \ - retried next pass" + "private payment expiry: the private contact request store was not read at \ + the anchor set's height; retried next pass" ); return; } @@ -1946,7 +1978,7 @@ impl NetworkShieldedCoordinator { let persisters = self.persisters.read().await; let mut store = self.store.write().await; for (wallet_id, record) in candidates { - if appended.contains(&record.commitment) { + if held.contains(&record.commitment) || !expirable(&*store, wallet_id, &record) { continue; } match store.discharge_private_payment(wallet_id, &record.commitment) { @@ -1971,32 +2003,53 @@ impl NetworkShieldedCoordinator { } } - /// The commitments of every entry of DashPay's private contact request - /// store, read now from its start, raw: an entry starts with its - /// commitment. - async fn private_contact_request_commitments( + /// Which of `candidates` DashPay's private contact request store holds, + /// read now from its start through the end its first page shows, raw: an + /// entry starts with its commitment. Only the matches are kept, though + /// every entry is read to show the others absent. Every page must be + /// proved at or above `floor`, the first page's end with it: a page below + /// fails the read, as the store it shows may predate the floor. + pub(crate) async fn private_contact_requests_held( &self, + candidates: &HashSet<[u8; 32]>, + floor: u64, ) -> Result, PlatformWalletError> { let contract_id = dashpay_contract()?.id(); - let mut pages = std::pin::pin!(self.sdk.private_document_pages( + let mut pages = std::pin::pin!(self.sdk.private_document_pages_with_heights( contract_id, PRIVATE_CONTACT_REQUEST_DOCUMENT_TYPE_NAME, 0, MAX_CONCURRENT_PRIVATE_DOCUMENT_PAGE_FETCHES, )); - let mut commitments = HashSet::new(); + let mut held = HashSet::new(); + let mut read = false; while let Some(page) = pages.next().await { - for entry in page?.entries.entries { + let (page, height) = page?; + if height < floor { + return Err(PlatformWalletError::ShieldedSyncFailed(format!( + "the private contact request store's page from position {} is proved at \ + height {height}, below {floor}", + page.start_position + ))); + } + read = true; + for entry in page.entries.entries { if let Some(commitment) = entry .data .get(..32) .and_then(|commitment| <[u8; 32]>::try_from(commitment).ok()) + .filter(|commitment| candidates.contains(commitment)) { - commitments.insert(commitment); + held.insert(commitment); } } } - Ok(commitments) + if !read { + return Err(PlatformWalletError::ShieldedSyncFailed( + "the private contact request store returned no page".to_string(), + )); + } + Ok(held) } /// Derive best-effort activity entries from each subwallet's @@ -2397,6 +2450,70 @@ fn is_own_orchard_recipient(views: &AccountViewingKeys, recipient: &[u8]) -> boo .is_some() } +/// What a sync pass reads before its note scan, for the steps after it that +/// may act only on evidence from before the scan +/// ([`NetworkShieldedCoordinator::prefetch_stranded_release`]). +struct PreScanEvidence { + /// The anchored reservations, for + /// [`NetworkShieldedCoordinator::release_stranded_spends`]. + stale: Vec<(SubwalletId, StalePendingSpend)>, + /// The private payment journal records of the synced wallets, read before + /// the anchor set was fetched, for + /// [`NetworkShieldedCoordinator::discharge_expired_private_payments`]. + journal: Vec<(WalletId, PrivatePaymentRecord)>, + /// Platform's recorded anchor set. + recorded: HashSet<[u8; 32]>, + /// The height of the proof the anchor set was read from. + recorded_height: u64, +} + +/// The private payment journal records of `wallet_ids`, each wallet once, in +/// order. A wallet whose journal does not read has none here: nothing of it is +/// a candidate for expiry. +pub(crate) fn private_payment_journal_snapshot( + store: &S, + wallet_ids: impl IntoIterator, +) -> Vec<(WalletId, PrivatePaymentRecord)> { + let wallets: std::collections::BTreeSet = wallet_ids.into_iter().collect(); + let mut journal = Vec::new(); + for wallet_id in wallets { + match store.private_payment_journal(wallet_id) { + Ok(records) => journal.extend(records.into_iter().map(|record| (wallet_id, record))), + Err(error) => tracing::warn!( + wallet_id = %hex::encode(wallet_id), + %error, + "private payment expiry: the journal does not read; no payment of the wallet \ + is a candidate this pass" + ), + } + } + journal +} + +/// Whether `wallet_id`'s journal still holds `record` exactly as a pass's +/// snapshot read it, with one of its input notes unspent. A store read that +/// fails says no. +fn expirable( + store: &S, + wallet_id: WalletId, + record: &PrivatePaymentRecord, +) -> bool { + let unchanged = store + .private_payment_journal(wallet_id) + .is_ok_and(|records| records.contains(record)); + if !unchanged { + return false; + } + let Ok(notes) = store.get_all_notes(SubwalletId::new(wallet_id, record.account_index)) else { + return false; + }; + record.nullifiers.iter().any(|nullifier| { + notes + .iter() + .any(|note| note.nullifier == *nullifier && !note.is_spent) + }) +} + #[cfg(test)] mod tests { use super::*; diff --git a/packages/rs-platform-wallet/src/wallet/shielded/file_store.rs b/packages/rs-platform-wallet/src/wallet/shielded/file_store.rs index 068ad8a4906..5acedeb6c42 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/file_store.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/file_store.rs @@ -993,6 +993,7 @@ impl ShieldedStore for FileBackedShieldedStore { redrive: PendingRedrive, payment: PrivateContactPayment, commitments: &[[u8; 32]], + anchor_height: u64, ) -> Result<(), Self::Error> { let records: Vec = commitments .iter() @@ -1000,6 +1001,7 @@ impl ShieldedStore for FileBackedShieldedStore { account_index: id.account_index, activity_id: redrive.activity_id, anchor: redrive.anchor, + anchor_height, commitment: *commitment, payment, nullifiers: redrive.nullifiers.clone(), @@ -1625,7 +1627,7 @@ mod tests { { let mut store = FileBackedShieldedStore::open_path(&path, 100).expect("open"); store - .arm_private_contact_payment(id, record(1, 3), payment, &[[0xC1; 32]]) + .arm_private_contact_payment(id, record(1, 3), payment, &[[0xC1; 32]], 0) .expect("arm the payment"); store.arm_redrive(id, record(4, 6)).expect("arm a spend"); store.bump_redrive_attempts(id, &[1u8; 32]).expect("bump"); @@ -1695,6 +1697,7 @@ mod tests { record(activity, nullifier), payment, &[[activity; 32]], + 0, ) .expect("armed"); }; @@ -1920,7 +1923,7 @@ mod tests { let arm = || { let mut store = FileBackedShieldedStore::open_path(&path, 100).unwrap(); store - .arm_private_contact_payment(id, record.clone(), payment, &[[0xC2; 32]]) + .arm_private_contact_payment(id, record.clone(), payment, &[[0xC2; 32]], 0) .unwrap(); }; let set = |assignment: &str| { @@ -2816,6 +2819,7 @@ mod tests { account_index: 1, activity_id: redrive.activity_id, anchor: redrive.anchor, + anchor_height: 0, commitment: [0xC3; 32], payment, nullifiers: redrive.nullifiers.clone(), @@ -2828,7 +2832,7 @@ mod tests { store.mark_pending(id, nullifier).expect("reserved"); } store - .arm_private_contact_payment(id, redrive.clone(), payment, &[[0xC3; 32]]) + .arm_private_contact_payment(id, redrive.clone(), payment, &[[0xC3; 32]], 0) .expect("armed"); assert!(store .private_payment_journal_open(wallet_id) @@ -2911,6 +2915,7 @@ mod tests { }, payment, &[[0xD4; 32]], + 0, ) .expect("armed"); store diff --git a/packages/rs-platform-wallet/src/wallet/shielded/operations.rs b/packages/rs-platform-wallet/src/wallet/shielded/operations.rs index 61894bb3228..88a40ab0a7d 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/operations.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/operations.rs @@ -1648,7 +1648,8 @@ pub(in crate::wallet) async fn private_document_create_from_pool_with_metadata< .collect::>() }); let built = async { - let (spends, anchor) = extract_spends_and_anchor(sdk, store, &selected_notes).await?; + let (spends, anchor, anchor_height) = + extract_spends_and_recorded_anchor(sdk, store, &selected_notes).await?; let (state_transition, fee_used) = build_private_document_create_from_shielded_pool_transition( spends, @@ -1667,10 +1668,10 @@ pub(in crate::wallet) async fn private_document_create_from_pool_with_metadata< fee_used, exact_fee, "builder fee must match the reserved private document create fee" ); - Ok::<_, PlatformWalletError>((state_transition, fee_used, anchor.to_bytes())) + Ok::<_, PlatformWalletError>((state_transition, fee_used, anchor.to_bytes(), anchor_height)) } .await; - let (state_transition, fee_used, anchor_bytes) = match built { + let (state_transition, fee_used, anchor_bytes, anchor_height) = match built { Ok(built) => built, Err(e) => { // Nothing was saved or broadcast. @@ -1712,6 +1713,7 @@ pub(in crate::wallet) async fn private_document_create_from_pool_with_metadata< reservation, state_transition, anchor: anchor_bytes, + anchor_height, private_contact, journaled: journaled.as_deref(), payment_started, @@ -1742,6 +1744,8 @@ struct PrivateDocumentSubmission<'a, S: ShieldedStore> { reservation: NoteReservation, state_transition: StateTransition, anchor: [u8; 32], + /// The proved height of the recorded-anchor set `anchor` was chosen from. + anchor_height: u64, private_contact: Option, journaled: Option<&'a [[u8; 32]]>, payment_started: &'a PaymentStarted, @@ -1779,6 +1783,7 @@ where mut reservation, state_transition, anchor, + anchor_height, private_contact, journaled, payment_started, @@ -1796,6 +1801,7 @@ where "private document create from shielded pool", private_contact, Some(payment_started), + anchor_height, ) .await?; if anchored { @@ -2998,6 +3004,19 @@ async fn extract_spends_and_anchor( store: &Arc>, notes: &[ShieldedNote], ) -> Result<(Vec, Anchor), PlatformWalletError> { + extract_spends_and_recorded_anchor(sdk, store, notes) + .await + .map(|(spends, anchor, _)| (spends, anchor)) +} + +/// [`extract_spends_and_anchor`], with the proved height of the recorded +/// anchor set the anchor was chosen from: the anchor was recorded at that +/// height. +async fn extract_spends_and_recorded_anchor( + sdk: &Arc, + store: &Arc>, + notes: &[ShieldedNote], +) -> Result<(Vec, Anchor, u64), PlatformWalletError> { // Nothing selected — fail before the network round-trip. if notes.is_empty() { return Err(PlatformWalletError::ShieldedBuildError( @@ -3008,8 +3027,8 @@ async fn extract_spends_and_anchor( // Fetch the recorded anchor set OUTSIDE the store lock so the network // round-trip doesn't serialize with other store users, and so the lock is // held only for the mutually-consistent depth/witness probe below. - let dash_sdk::query_types::ShieldedAnchors(recorded_anchors) = - dash_sdk::query_types::ShieldedAnchors::fetch_current(sdk).await?; + let (dash_sdk::query_types::ShieldedAnchors(recorded_anchors), metadata) = + dash_sdk::query_types::ShieldedAnchors::fetch_current_with_metadata(sdk).await?; let recorded: HashSet<[u8; 32]> = recorded_anchors.into_iter().collect(); // Hold a single read lock across the whole probe so the checkpoint depths @@ -3018,6 +3037,7 @@ async fn extract_spends_and_anchor( // under us. let store = store.read().await; select_recorded_spends(&*store, notes, &recorded) + .map(|(spends, anchor)| (spends, anchor, metadata.height)) } /// Pick the shallowest checkpoint depth whose tree root is in `recorded`, @@ -3555,6 +3575,9 @@ async fn broadcast_shielded_spend_with_redrive_with_metadata( /// not be recovered. `payment_started`, when given, is marked right after /// the record is saved, under the same store lock: dropped while it waits /// for the lock, the spend leaves neither a record nor a mark. +/// `anchor_recorded_at` is the proved height of the recorded-anchor set the +/// spend's anchor was chosen from, kept in the journal record: an expiry +/// proof needs the anchor absent from a set proved at or above it. #[allow(clippy::too_many_arguments)] pub(crate) async fn arm_redrive_before_broadcast( store: &Arc>, @@ -3566,6 +3589,7 @@ pub(crate) async fn arm_redrive_before_broadcast( operation: &'static str, private_contact: Option, payment_started: Option<&PaymentStarted>, + anchor_recorded_at: u64, ) -> Result<(), PlatformWalletError> { use dpp::serialization::PlatformSerializable; @@ -3604,9 +3628,13 @@ pub(crate) async fn arm_redrive_before_broadcast( }; let mut store = store.write().await; match journal { - Some((payment, commitments)) => { - store.arm_private_contact_payment(id, redrive, payment, &commitments) - } + Some((payment, commitments)) => store.arm_private_contact_payment( + id, + redrive, + payment, + &commitments, + anchor_recorded_at, + ), None => store.arm_redrive(id, redrive), } .map_err(|e| { @@ -4771,6 +4799,7 @@ mod redrive_tests { reservation, state_transition: transition, anchor: [7; 32], + anchor_height: 0, private_contact: Some(payment), journaled: Some(&[[9; 32]]), payment_started: &started, @@ -4915,6 +4944,7 @@ mod redrive_tests { reservation, state_transition: transition, anchor: [7; 32], + anchor_height: 0, private_contact: Some(PrivateContactPayment { sender: [0xB1; 32], recipient: [0xA1; 32], @@ -5030,6 +5060,7 @@ mod redrive_tests { }, payment, &[[0xC4; 32]], + 0, ) .expect("the newer payment saved"); drop(held); @@ -5161,6 +5192,7 @@ mod redrive_tests { "private document create from shielded pool", Some(payment), Some(&started), + 0, ) .await .expect("saved before the broadcast"); @@ -5546,6 +5578,7 @@ mod redrive_tests { "test spend", None, Some(&started), + 0, ); let mut save = Box::pin(save); assert!( @@ -5572,6 +5605,7 @@ mod redrive_tests { "test spend", None, Some(&started), + 0, ) .await .expect("saved"); @@ -5606,6 +5640,7 @@ mod redrive_tests { "test spend", None, None, + 0, ) .await; assert!( @@ -5629,6 +5664,7 @@ mod redrive_tests { "test spend", None, None, + 0, ) .await .expect("saved"); diff --git a/packages/rs-platform-wallet/src/wallet/shielded/store.rs b/packages/rs-platform-wallet/src/wallet/shielded/store.rs index 2348f1a0e98..14a935e8aba 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/store.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/store.rs @@ -231,6 +231,11 @@ pub struct PrivatePaymentRecord { /// note still unspent and the entry absent from the store proves the /// payment expired. pub anchor: [u8; 32], + /// The proved height of the recorded-anchor set `anchor` was chosen from: + /// the anchor was recorded then, so only its absence from a set proved at + /// or above this height shows it pruned. `u64::MAX` for a payment saved + /// before the journal kept it, whose expiry is never proven. + pub anchor_height: u64, /// The commitment of the entry it appends, which keys the record. pub commitment: [u8; 32], /// The request it pays for. @@ -240,20 +245,21 @@ pub struct PrivatePaymentRecord { } impl PrivatePaymentRecord { - /// The format version [`Self::to_bytes`] writes. Version 1, written before - /// the anchor was kept, does not read back: its wallet's holds stay. - const FORMAT_VERSION: u8 = 2; + /// The format version [`Self::to_bytes`] writes. Versions 1 and 2, written + /// before the anchor and its height were kept, do not read back: their + /// wallet's holds stay. + const FORMAT_VERSION: u8 = 3; /// The bytes before the nullifiers: version, account index, activity id, - /// anchor, commitment, sender, recipient, account reference and nullifier - /// count. - const HEADER_LEN: usize = 1 + 4 + 32 + 32 + 32 + 32 + 32 + 4 + 2; + /// anchor, anchor height, commitment, sender, recipient, account reference + /// and nullifier count. + const HEADER_LEN: usize = 1 + 4 + 32 + 32 + 8 + 32 + 32 + 32 + 4 + 2; /// The bytes the file store keeps it as: [`Self::FORMAT_VERSION`], the - /// account index little-endian, the activity id, the anchor, the - /// commitment, the sender, the recipient, the account reference - /// little-endian, the nullifier count as a little-endian `u16`, then the - /// nullifiers. `None` when it has no nullifier or more than a `u16` - /// counts. + /// account index little-endian, the activity id, the anchor, the anchor + /// height little-endian, the commitment, the sender, the recipient, the + /// account reference little-endian, the nullifier count as a + /// little-endian `u16`, then the nullifiers. `None` when it has no + /// nullifier or more than a `u16` counts. pub(super) fn to_bytes(&self) -> Option> { let count = u16::try_from(self.nullifiers.len()).ok()?; if count == 0 { @@ -264,6 +270,7 @@ impl PrivatePaymentRecord { bytes.extend_from_slice(&self.account_index.to_le_bytes()); bytes.extend_from_slice(&self.activity_id); bytes.extend_from_slice(&self.anchor); + bytes.extend_from_slice(&self.anchor_height.to_le_bytes()); bytes.extend_from_slice(&self.commitment); bytes.extend_from_slice(&self.payment.sender); bytes.extend_from_slice(&self.payment.recipient); @@ -286,6 +293,7 @@ impl PrivatePaymentRecord { let (account_index, rest) = rest.split_first_chunk::<4>()?; let (activity_id, rest) = rest.split_first_chunk::<32>()?; let (anchor, rest) = rest.split_first_chunk::<32>()?; + let (anchor_height, rest) = rest.split_first_chunk::<8>()?; let (commitment, rest) = rest.split_first_chunk::<32>()?; let (sender, rest) = rest.split_first_chunk::<32>()?; let (recipient, rest) = rest.split_first_chunk::<32>()?; @@ -300,6 +308,7 @@ impl PrivatePaymentRecord { account_index: u32::from_le_bytes(*account_index), activity_id: *activity_id, anchor: *anchor, + anchor_height: u64::from_le_bytes(*anchor_height), commitment: *commitment, payment: PrivateContactPayment { sender: *sender, @@ -478,12 +487,15 @@ pub trait ShieldedStore: Send + Sync + 'static { /// durable once this returns, across a crash or power loss too: the /// spend is broadcast only after it. A store that cannot keep all of it /// durably fails, and the payment is refused. + /// `anchor_height` is the proved height of the recorded-anchor set the + /// spend's anchor was chosen from ([`PrivatePaymentRecord::anchor_height`]). fn arm_private_contact_payment( &mut self, id: SubwalletId, redrive: PendingRedrive, payment: PrivateContactPayment, commitments: &[[u8; 32]], + anchor_height: u64, ) -> Result<(), Self::Error>; /// The armed records of `wallet_id` that pay for a private contact @@ -1267,6 +1279,7 @@ impl ShieldedStore for InMemoryShieldedStore { redrive: PendingRedrive, payment: PrivateContactPayment, commitments: &[[u8; 32]], + anchor_height: u64, ) -> Result<(), Self::Error> { if redrive.nullifiers.is_empty() { return Err(InMemoryStoreError( @@ -1283,6 +1296,7 @@ impl ShieldedStore for InMemoryShieldedStore { account_index: id.account_index, activity_id: redrive.activity_id, anchor: redrive.anchor, + anchor_height, commitment: *commitment, payment, nullifiers: redrive.nullifiers.clone(), diff --git a/packages/rs-platform-wallet/src/wallet/shielded/sync.rs b/packages/rs-platform-wallet/src/wallet/shielded/sync.rs index 7d4cb149951..1fbbc4e4418 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/sync.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/sync.rs @@ -153,6 +153,15 @@ pub struct MultiSyncNotesResult { /// subwallet. The caller decides whether to queue it on the /// shared `WalletPersister`. pub changeset: ShieldedChangeSet, + /// The highest proved height this pass's scan covers: every action + /// appended at or below it, and so every nullifier spent there, was + /// scanned, by this pass or an earlier one, and replayed against the + /// notes. It is the height of a batch in the run scanned without a gap + /// from the pass's start whose proof, and every proof read before it, + /// shows the commitment tree ending within that run (the batch's + /// `total_count` is the highest seen). `None` when no batch shows that: + /// a proof of a longer tree than the run reached. + pub covered_height: Option, } impl MultiSyncNotesResult { @@ -471,6 +480,12 @@ where // The stream lives in this block so it — and the up-to-`max_concurrent` // network fetches it holds in flight — is dropped before the finalize // below runs, whether the loop ran to the end or was cancelled. + // What this pass's scan covers (`MultiSyncNotesResult::covered_height`): + // the end of the run of positions scanned without a gap from + // `aligned_start`, and the highest proved height of a batch in it whose + // proof shows the tree ending within it. + let mut scanned_to = aligned_start; + let mut covered_height: Option = None; let cancelled = { let stream = make_stream(aligned_start, tree_size); futures::pin_mut!(stream); @@ -501,6 +516,13 @@ where // it. Stays 0 (indeterminate) only if no batch is ever produced. total_target = total_target.max(batch.total_count); total_notes_scanned += batch.notes.len() as u64; + if batch.start_index <= scanned_to { + let end = batch.start_index.saturating_add(batch.notes.len() as u64); + scanned_to = scanned_to.max(end); + if end >= batch.total_count { + covered_height = covered_height.max(Some(batch.block_height)); + } + } if !batch.notes.is_empty() { last_nonempty = Some((batch.start_index, batch.is_partial)); } @@ -839,6 +861,7 @@ where per_subwallet_newly_spent: newly_spent_per_subwallet, total_scanned: scanned_volume, changeset, + covered_height, }) } diff --git a/packages/rs-platform-wallet/src/wallet/shielded/sync/cancel_tests.rs b/packages/rs-platform-wallet/src/wallet/shielded/sync/cancel_tests.rs index a683da2d53c..8c89aeb5c1a 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/sync/cancel_tests.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/sync/cancel_tests.rs @@ -1,4 +1,5 @@ -//! Cancellation of a note-sync pass (dashpay/platform#5200). +//! Cancellation of a note-sync pass (dashpay/platform#5200), and the +//! height a completed pass covers. //! //! Feeds `sync_notes_from_stream` a synthetic batch stream so the //! cancel path runs without a network. The stream yields real-shaped @@ -351,3 +352,61 @@ async fn should_return_while_waiting_for_the_store_lock_when_cancelled() { let _ = std::fs::remove_file(&path); } + +/// A batch of `len` notes at `start_index`, proved at `block_height`, whose +/// proof (with every one read before it) shows `total_count` notes. +fn proved_batch( + start_index: u64, + len: u64, + total_count: u64, + block_height: u64, +) -> ShieldedChunkBatch { + ShieldedChunkBatch { + block_height, + ..batch(start_index, len, total_count) + } +} + +/// A completed pass covers the proved height of the last batch of its run +/// whose proof shows the tree ending within that run, and no height when +/// every proof shows a longer tree than the run reached. +#[tokio::test] +async fn should_report_the_height_a_completed_scan_covers() { + let cases: [(&str, Vec, Option); 4] = [ + ( + "the last batch ends the tree", + vec![proved_batch(0, 4, 6, 1_000), proved_batch(4, 2, 6, 1_004)], + Some(1_004), + ), + ( + "a proof of a longer tree than the run reached", + vec![proved_batch(0, 4, 9, 1_000), proved_batch(4, 2, 9, 1_004)], + None, + ), + ( + "an empty chunk past a full one, at a later height", + vec![proved_batch(0, 4, 4, 1_000), proved_batch(4, 0, 4, 1_005)], + Some(1_005), + ), + ( + "a batch past a gap in the run", + vec![proved_batch(0, 4, 4, 1_000), proved_batch(8, 0, 4, 1_009)], + Some(1_000), + ), + ]; + for (case, batches, covered) in cases { + let path = temp_tree_path("covered"); + let store = Arc::new(RwLock::new( + FileBackedShieldedStore::open_path(&path, 100).expect("open store"), + )); + let result = run( + &store, + &one_subwallet(), + &CancellationToken::new(), + futures::stream::iter(batches.into_iter().map(Ok)), + ) + .await; + assert_eq!(result.covered_height, covered, "{case}"); + let _ = std::fs::remove_file(&path); + } +} From c81424fb432298eb3b05c166aaeae480bb0f79b3 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Tue, 10 Nov 2026 00:00:00 +0700 Subject: [PATCH 113/117] fix(platform-wallet): restore a private payment's reservation through one generation-fenced step The legacy restoration from the redrive records (restore_pending_private_contact_requests) reserved a pair without checking the session's discharge record: a payment whose journal record was discharged, and its reservation released, while that restoration waited to open its entry was reserved again, and no entry would ever settle it. The check a private send makes before it pays, retargeting the pair to a journaled payment, had the same gap. Both now reserve through reserve_unresolved_private_payments, the step the journal restoration takes, under the wallet manager's write lock: a payment discharged in this session is left alone, as is one its sender's tracked request records or supersedes, and the step refuses a wallet removed or re-created under its id since the caller's handle was taken, reserving nothing on the replacement. Co-Authored-By: Claude Opus 5.5 --- .../network/private_contact_requests.rs | 395 ++++++++++++++---- .../src/wallet/persister.rs | 4 +- .../src/wallet/platform_wallet.rs | 1 + 3 files changed, 326 insertions(+), 74 deletions(-) diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/private_contact_requests.rs b/packages/rs-platform-wallet/src/wallet/identity/network/private_contact_requests.rs index 56e738ba7eb..64360232142 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/private_contact_requests.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/private_contact_requests.rs @@ -71,6 +71,7 @@ use super::{dashpay_contract, DashPayView, IdentityWallet}; use crate::broadcaster::TransactionBroadcaster; use crate::changeset::{ContactChangeSet, PersistenceError, PrivateContactRequestsPosition}; use crate::error::PlatformWalletError; +use crate::wallet::core::WalletGeneration; use crate::wallet::identity::types::dashpay::contact_request::ContactRequest; use crate::wallet::identity::types::dashpay::established_contact::EstablishedContact; use crate::wallet::identity::ManagedIdentity; @@ -417,36 +418,63 @@ fn note_read_back_once_durable(persister: &WalletPersister, read_back: Vec<[u8; persister.note_read_back_private_entries(read_back); } -/// A private contact request payment the shielded store's journal keeps, as -/// a restoration reads it: (sender, recipient, account reference, the entry's -/// commitment). -#[cfg(feature = "shielded")] +/// A private contact request payment a restoration reads, from the shielded +/// store's journal or from the redrive record of its spend: (sender, +/// recipient, account reference, the entry's commitment). pub(crate) type JournaledPrivatePayment = (Identifier, Identifier, u32, [u8; 32]); -/// The body of [`DashPayView::restore_private_payments`], over the wallet's -/// state alone, so a restoration deferred to a task while the shielded store -/// is busy runs it too. -#[cfg(feature = "shielded")] -pub(crate) async fn restore_journaled_private_payments( - wallet_manager: &Arc>>, - persister: &WalletPersister, +/// What [`reserve_unresolved_private_payments`] reserved. +#[derive(Debug, Default)] +pub(crate) struct ReservedPrivatePayments { + /// The (sender, recipient) pairs reserved, each once, in the payments' + /// order. + pub(crate) restored: Vec<(Identifier, Identifier)>, + /// The pairs of payments whose sender the wallet does not manage. + #[cfg_attr(not(feature = "shielded"), allow(dead_code))] + pub(crate) absent: BTreeSet<(Identifier, Identifier)>, +} + +/// The one step every restoration of a private contact request payment's +/// reservation takes, and every retarget of one: reserve the recipient of each +/// of `payments` that may still execute, in `info`, which the caller holds +/// under the wallet manager's write lock. +/// +/// Fenced to `generation`, the wallet generation the caller's handle belongs +/// to: a wallet removed, or re-created under its id, since then is refused +/// with [`PlatformWalletError::WalletNotFound`] and nothing is reserved. The +/// check and the reservations are under the one lock a removal takes. +/// +/// Left alone: +/// - a payment whose journal record was discharged in this session +/// ([`WalletPersister::private_entry_discharged`]): a restoration that read +/// it before the discharge, and reserves after the reservation was released, +/// would hold a pair no entry ever settles; +/// - one whose request the sender's tracked request to its recipient records +/// or supersedes, as a private sync that recorded it settled it; +/// - one whose sender the wallet does not manage, returned in +/// [`ReservedPrivatePayments::absent`]. +/// +/// The pair `paying` is retargeted to the entry of its first payment left to +/// reserve, as the entry about to be paid is not paid for; every other pair +/// keeps the entry it is reserved for, and gets one only when it has none. +pub(crate) fn reserve_unresolved_private_payments( + info: &mut PlatformWalletInfo, + generation: &Arc, wallet_id: WalletId, + persister: &WalletPersister, payments: &[JournaledPrivatePayment], - journal_authoritative: bool, -) -> Result, PlatformWalletError> { - let mut wm = wallet_manager.write().await; - let info = wm - .get_wallet_info_mut(&wallet_id) - .ok_or_else(|| PlatformWalletError::WalletNotFound(hex::encode(wallet_id)))?; - let mut restored = Vec::new(); - let mut absent = BTreeSet::new(); + paying: Option<(Identifier, Identifier)>, +) -> Result { + if !Arc::ptr_eq(&info.generation, generation) { + return Err(PlatformWalletError::WalletNotFound(hex::encode(wallet_id))); + } + let mut reserved = ReservedPrivatePayments::default(); for &(sender, recipient, account_reference, commitment) in payments { - // Discharged since the snapshot was read: nothing will settle it. if persister.private_entry_discharged(&commitment) { continue; } let Some(managed) = info.identity_manager.managed_identity_mut(&sender) else { - absent.insert((sender, recipient)); + reserved.absent.insert((sender, recipient)); continue; }; if managed @@ -456,11 +484,40 @@ pub(crate) async fn restore_journaled_private_payments( { continue; } - managed.restore_private_reply(recipient, commitment); - if !restored.contains(&(sender, recipient)) { - restored.push((sender, recipient)); + let first = !reserved.restored.contains(&(sender, recipient)); + if paying == Some((sender, recipient)) { + if first { + managed.retarget_private_reply(recipient, commitment); + } + } else { + managed.restore_private_reply(recipient, commitment); + } + if first { + reserved.restored.push((sender, recipient)); } } + Ok(reserved) +} + +/// The body of [`DashPayView::restore_private_payments`], over the wallet's +/// state alone, so a restoration deferred to a task while the shielded store +/// is busy runs it too. +#[cfg(feature = "shielded")] +pub(crate) async fn restore_journaled_private_payments( + wallet_manager: &Arc>>, + generation: &Arc, + persister: &WalletPersister, + wallet_id: WalletId, + payments: &[JournaledPrivatePayment], + journal_authoritative: bool, +) -> Result, PlatformWalletError> { + let mut wm = wallet_manager.write().await; + let info = wm + .get_wallet_info_mut(&wallet_id) + .ok_or_else(|| PlatformWalletError::WalletNotFound(hex::encode(wallet_id)))?; + let ReservedPrivatePayments { restored, absent } = reserve_unresolved_private_payments( + info, generation, wallet_id, persister, payments, None, + )?; persister.hold_absent_sender_private_pairs(absent); // Under the same lock, after the reservations: a send that sees an // identity's hold gone sees them. @@ -2047,6 +2104,7 @@ impl DashPayView<'_, B> { ) -> Result, PlatformWalletError> { restore_journaled_private_payments( &self.wallet_manager, + &self.generation, &self.persister, self.wallet_id, payments, @@ -2071,22 +2129,28 @@ impl DashPayView<'_, B> { if journaled.is_empty() { return Ok(false); } - self.with_managed(sender, |managed| { - let unresolved = journaled.iter().find(|(account_reference, _)| { - !managed - .dashpay() - .outgoing_request(recipient) - .is_some_and(|tracked| records_or_supersedes(tracked, *account_reference)) - }); - match unresolved { - Some((_, commitment)) => { - managed.retarget_private_reply(*recipient, *commitment); - true - } - None => false, - } - }) - .await + let payments: Vec = journaled + .iter() + .map(|&(account_reference, commitment)| { + (*sender, *recipient, account_reference, commitment) + }) + .collect(); + let mut wm = self.wallet_manager.write().await; + let info = wm + .get_wallet_info_mut(&self.wallet_id) + .ok_or_else(|| PlatformWalletError::WalletNotFound(hex::encode(self.wallet_id)))?; + if info.identity_manager.managed_identity(sender).is_none() { + return Err(PlatformWalletError::IdentityNotFound(*sender)); + } + let reserved = reserve_unresolved_private_payments( + info, + &self.generation, + self.wallet_id, + &self.persister, + &payments, + Some((*sender, *recipient)), + )?; + Ok(!reserved.restored.is_empty()) } /// Whether a request from `sender` to `recipient` among `entries`, paid @@ -2117,11 +2181,11 @@ impl DashPayView<'_, B> { /// Open `entries` as one page of the store and reserve the recipient of /// each the wallet's identities sent whose request is neither recorded /// nor superseded yet; the pair `paying` is retargeted to its restored - /// entry instead. An entry whose payment the shielded coordinator settled - /// as never executing in this session is left alone, though the - /// settlement came while the entries were opened - /// (`WalletPersister::private_entry_rejected`, checked under the wallet - /// lock). + /// entry instead. Reserved through [`reserve_unresolved_private_payments`], + /// as a journal restoration reserves: fenced to this handle's wallet + /// generation, and leaving alone an entry whose payment's journal record + /// was discharged in this session, though the discharge came while the + /// entries were opened (checked under the wallet lock). async fn restore_private_reservations( &self, entries: &[BinaryData], @@ -2171,11 +2235,7 @@ impl DashPayView<'_, B> { ) .await?; - let mut restored = Vec::new(); - let mut wm = self.wallet_manager.write().await; - let info = wm - .get_wallet_info_mut(&self.wallet_id) - .ok_or_else(|| PlatformWalletError::WalletNotFound(hex::encode(self.wallet_id)))?; + let mut payments: Vec = Vec::new(); for sent in scan.sent { let sender = sent.sender_identity_id; // The wallet's own entries name the identity that sealed them. @@ -2205,28 +2265,28 @@ impl DashPayView<'_, B> { continue; } }; - let Some(managed) = info.identity_manager.managed_identity_mut(&sender) else { - continue; - }; - // Recorded, or superseded by a later request: discharged. - if managed - .dashpay() - .outgoing_request(&recipient) - .is_some_and(|tracked| records_or_supersedes(tracked, request.account_reference)) - { - continue; - } - let commitment = *sent.entry.commitment(); - if paying == Some((sender, recipient)) { - managed.retarget_private_reply(recipient, commitment); - } else { - managed.restore_private_reply(recipient, commitment); - } - if !restored.contains(&(sender, recipient)) { - restored.push((sender, recipient)); - } + payments.push(( + sender, + recipient, + request.account_reference, + *sent.entry.commitment(), + )); } - Ok(restored) + let mut wm = self.wallet_manager.write().await; + let info = wm + .get_wallet_info_mut(&self.wallet_id) + .ok_or_else(|| PlatformWalletError::WalletNotFound(hex::encode(self.wallet_id)))?; + // A sender the wallet does not manage is not held here: the journal + // restoration holds those. + let reserved = reserve_unresolved_private_payments( + info, + &self.generation, + self.wallet_id, + &self.persister, + &payments, + paying, + )?; + Ok(reserved.restored) } } @@ -6431,6 +6491,199 @@ mod tests { assert_eq!(retry.lock().expect("retry").len(), 1, "the retry pays"); } + /// The legacy restoration from the redrive records + /// (`PlatformWallet::restore_pending_private_contact_requests`) that read + /// a payment's transition before its journal record was discharged, and + /// the discharge notice taken and its reservation released while it + /// waited to open the entry, does not reserve the pair again: it reserves + /// through the journal restoration's step, which checks the discharge + /// under the wallet lock. A retry to the pair pays. + #[cfg(feature = "shielded")] + #[tokio::test] + async fn should_not_restore_from_a_redrive_a_payment_discharged_while_the_restore_waited() { + use std::future::Future; + use std::task::{Context, Waker}; + + let alice = identity(ALICE_PHRASE, ALICE); + let bob = identity(BOB_PHRASE, BOB); + let alice_crypto = SeedCryptoProvider::from_seed(seed(ALICE_PHRASE), Network::Testnet); + let fixture = ExpiryFixture::new().await; + let entry = fixture + .pay_unconfirmed(ExpiryFixture::ANCHOR, ExpiryFixture::ANCHOR_SET_HEIGHT) + .await; + let commitment: [u8; 32] = entry.as_slice()[..32] + .try_into() + .expect("an entry starts with its commitment"); + let wallet = &fixture.wallet; + + let mut cx = Context::from_waker(Waker::noop()); + let held_store = fixture.coordinator.store().write().await; + let mut restore = Box::pin( + wallet.restore_pending_private_contact_requests(&fixture.coordinator, &alice_crypto), + ); + assert!( + restore.as_mut().poll(&mut cx).is_pending(), + "waits for the store" + ); + let mut held_wallet = wallet.wallet_manager().write().await; + drop(held_store); + // The restoration reads the redrive records, then waits for the + // wallet before it opens the entry. + assert!( + restore.as_mut().poll(&mut cx).is_pending(), + "waits for the wallet" + ); + // Meanwhile the record is discharged, and the wallet takes the + // discharge notice and releases the reservation. + fixture + .coordinator + .store() + .write() + .await + .discharge_private_payment(wallet.wallet_id(), &commitment) + .expect("discharged"); + wallet + .persister() + .note_discharged_private_entries([commitment]); + let taken = wallet + .persister() + .take_unreleased_discharged_private_entries(); + assert_eq!(taken, BTreeSet::from([commitment]), "the notice is taken"); + held_wallet + .get_wallet_info_mut(&wallet.wallet_id()) + .expect("the wallet") + .identity_manager + .managed_identity_mut(&alice.id()) + .expect("alice") + .release_discharged_private_replies(&taken); + drop(held_wallet); + let restored = restore.await.expect("the restoration"); + assert!(restored.is_empty(), "{restored:?}"); + assert!(!pending_to(wallet, ALICE, &bob).await, "not reserved again"); + + let retry = Arc::new(Mutex::new(Vec::new())); + let retried = fixture.send_again(keep_entry(&retry)).await; + assert!( + matches!( + retried, + Err(PlatformWalletError::PrivateContactRequestPending(_)) + ), + "paid, not read back: {retried:?}" + ); + assert_eq!(retry.lock().expect("retry").len(), 1, "the retry pays"); + } + + /// The check a private send makes right before it pays, over the journal + /// it read, does not hold the pair for a payment whose record was + /// discharged since: it reserves through the restorations' step. One + /// still journaled holds it. + #[cfg(feature = "shielded")] + #[tokio::test] + async fn should_not_hold_a_pair_for_a_journaled_payment_discharged_since_the_read() { + let alice = identity(ALICE_PHRASE, ALICE); + let bob = identity(BOB_PHRASE, BOB); + for discharged in [true, false] { + let (_manager, alice_wallet) = + wallet(ALICE_PHRASE, &alice, &[&alice, &bob], vec![]).await; + let commitment = [0x5D; 32]; + if discharged { + alice_wallet + .persister() + .note_discharged_private_entries([commitment]); + } + let held = alice_wallet + .identity() + .dashpay() + .hold_for_journaled_private_payment(&alice.id(), &bob.id(), &[(0, commitment)]) + .await + .expect("the check"); + assert_eq!(held, !discharged, "discharged: {discharged}"); + assert_eq!( + pending_to(&alice_wallet, ALICE, &bob).await, + !discharged, + "discharged: {discharged}" + ); + } + } + + /// A restoration of a private payment's reservation through a handle + /// whose wallet was removed and re-created under its id reserves nothing + /// on the replacement: the step every restoration takes is fenced to the + /// handle's wallet generation. The replacement's own handle restores it. + #[tokio::test] + async fn should_not_restore_through_a_handle_whose_wallet_was_replaced() { + let alice = identity(ALICE_PHRASE, ALICE); + let bob = identity(BOB_PHRASE, BOB); + let alice_crypto = SeedCryptoProvider::from_seed(seed(ALICE_PHRASE), Network::Testnet); + let alice_signer = signer(ALICE_PHRASE, &alice); + let (manager, old) = wallet(ALICE_PHRASE, &alice, &[&alice, &bob], vec![]).await; + let wallet_id = old.wallet_id(); + let kept = Arc::new(Mutex::new(Vec::new())); + let sent = old + .identity() + .dashpay() + .send_private_contact_request( + &alice.id(), + &bob.id(), + None, + AutoAcceptProofSource::None, + None, + &alice_signer, + &alice_crypto, + keep_entry(&kept), + ) + .await; + assert!( + matches!( + sent, + Err(PlatformWalletError::PrivateContactRequestPending(_)) + ), + "paid, not read back: {sent:?}" + ); + let entry = kept.lock().expect("kept")[0].clone(); + + manager.remove_wallet(&wallet_id).await.expect("removed"); + let replacement = manager + .create_wallet_from_seed_bytes( + Network::Testnet, + &seed(ALICE_PHRASE), + WalletAccountCreationOptions::Default, + Some(0), + ) + .await + .expect("re-created"); + assert_eq!(replacement.wallet_id(), wallet_id); + { + let iw = replacement.identity(); + let mut wm = iw.wallet_manager.write().await; + wm.get_wallet_info_mut(&wallet_id) + .expect("the replacement's info") + .identity_manager + .add_identity(alice.clone(), 0, wallet_id, &iw.persister) + .expect("alice"); + } + let stale = old + .identity() + .dashpay() + .restore_pending_private_contact_requests(std::slice::from_ref(&entry), &alice_crypto) + .await; + assert!( + matches!(stale, Err(PlatformWalletError::WalletNotFound(_))), + "{stale:?}" + ); + managed(&replacement, ALICE, |managed| { + assert!(!managed.dashpay().private_reply_pending(&bob.id())); + }) + .await; + let restored = replacement + .identity() + .dashpay() + .restore_pending_private_contact_requests(&[entry], &alice_crypto) + .await + .expect("restored"); + assert_eq!(restored, vec![(alice.id(), bob.id())]); + } + /// Alice's wallet, bound to a coordinator over a new store, holding one /// unspent input note: the private payment expiry tests pay from it. #[cfg(feature = "shielded")] diff --git a/packages/rs-platform-wallet/src/wallet/persister.rs b/packages/rs-platform-wallet/src/wallet/persister.rs index 92523601019..f2f3ba3297b 100644 --- a/packages/rs-platform-wallet/src/wallet/persister.rs +++ b/packages/rs-platform-wallet/src/wallet/persister.rs @@ -98,8 +98,7 @@ struct DischargedPrivateEntries { /// that read a record before it was discharged, and reserves its pair /// after, checks it here first under the wallet lock /// ([`WalletPersister::private_entry_discharged`]). Only a shielded build - /// keeps a journal to discharge. - #[cfg_attr(not(feature = "shielded"), allow(dead_code))] + /// keeps a journal to discharge: empty otherwise. session: BTreeSet<[u8; 32]>, } @@ -180,7 +179,6 @@ impl WalletPersister { /// Whether the journal record of `commitment` was discharged in this /// session, its reservation released or not. - #[cfg(feature = "shielded")] pub(crate) fn private_entry_discharged(&self, commitment: &[u8; 32]) -> bool { self.discharged_entries().session.contains(commitment) } diff --git a/packages/rs-platform-wallet/src/wallet/platform_wallet.rs b/packages/rs-platform-wallet/src/wallet/platform_wallet.rs index 6ead3e8aa83..00e9e5d8e65 100644 --- a/packages/rs-platform-wallet/src/wallet/platform_wallet.rs +++ b/packages/rs-platform-wallet/src/wallet/platform_wallet.rs @@ -2975,6 +2975,7 @@ impl PrivatePaymentRestore { }; let restored = restore_journaled_private_payments( &self.wallet_manager, + &self.generation, &self.persister, self.wallet_id, &payments, From f7510b4b9d6badc3dc3cf7b8925be8a91e473b31 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Tue, 10 Nov 2026 00:38:32 +0700 Subject: [PATCH 114/117] fix(platform-wallet): retire a never-delivered payment's record and journal in one write A private payment whose transition never left the wallet discharged its journal records in one durable write and removed its saved redrive record in another, awaited separately. An exit in between left an empty, authoritative journal beside a signed transition still saved: on reopen the restoration released the pair's holds while the redrive pass could still broadcast the transition. The pass also broadcast from a snapshot read outside the store lock, so a record retired meanwhile could still go out. ShieldedStore::retire_undelivered_spend now removes the saved record and the journal records of the entries it would have appended in one transaction, on the file store's FULL-sync recovery connection, and the notes are released under the same store guard. The redrive pass claims each re-broadcast first: under the store write lock, from the record as it is then, it counts the attempt durably before broadcasting. A record retired since its snapshot is not broadcast, and a retirement after a claim is refused: the payment is then settled as unconfirmed, keeping its notes, record and journal records. Co-Authored-By: Claude Opus 5.5 --- .../src/wallet/shielded/file_store.rs | 145 +++++++++ .../src/wallet/shielded/operations.rs | 293 +++++++++++++++--- .../src/wallet/shielded/store.rs | 51 ++- 3 files changed, 437 insertions(+), 52 deletions(-) diff --git a/packages/rs-platform-wallet/src/wallet/shielded/file_store.rs b/packages/rs-platform-wallet/src/wallet/shielded/file_store.rs index 5acedeb6c42..5758a2ca42d 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/file_store.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/file_store.rs @@ -1087,6 +1087,72 @@ impl ShieldedStore for FileBackedShieldedStore { Ok(()) } + fn retire_undelivered_spend( + &mut self, + id: SubwalletId, + activity_id: &[u8; 32], + commitments: &[[u8; 32]], + ) -> Result { + if self + .subwallets + .get(&id) + .and_then(|subwallet| subwallet.redrive_attempts(activity_id)) + != Some(0) + { + return Ok(false); + } + // Durable first, in one transaction: a failure removes nothing, so the + // record and the journal records stay together, which over-holds. + { + let mut conn = self + .pending_conn + .lock() + .map_err(|e| FileShieldedStoreError::Storage(format!("pending_conn mutex: {e}")))?; + let tx = conn + .transaction() + .map_err(|e| FileShieldedStoreError::Storage(format!("begin retirement: {e}")))?; + // Only a record no re-broadcast was claimed for: the durable + // counter is written before memory's. + let retired = tx + .execute( + "DELETE FROM shielded_pending_spends \ + WHERE wallet_id = ?1 AND account_index = ?2 AND activity_id = ?3 \ + AND attempts = 0", + rusqlite::params![ + id.wallet_id.as_slice(), + id.account_index, + activity_id.as_slice(), + ], + ) + .map_err(|e| FileShieldedStoreError::Storage(format!("retire redrive: {e}")))?; + if retired != 1 { + // Dropped uncommitted: nothing is removed. + return Ok(false); + } + for commitment in commitments { + tx.execute( + "DELETE FROM shielded_private_payment_journal \ + WHERE wallet_id = ?1 AND commitment = ?2", + rusqlite::params![id.wallet_id.as_slice(), commitment.as_slice()], + ) + .map_err(|e| { + FileShieldedStoreError::Storage(format!("discharge retired payment: {e}")) + })?; + } + tx.commit() + .map_err(|e| FileShieldedStoreError::Storage(format!("commit retirement: {e}")))?; + } + if let Some(subwallet) = self.subwallets.get_mut(&id) { + subwallet.clear_redrive(activity_id); + } + self.private_contact_payments.remove(&(id, *activity_id)); + for commitment in commitments { + self.private_payment_journal + .remove(&(id.wallet_id, *commitment)); + } + Ok(true) + } + fn discharge_private_payment( &mut self, wallet_id: WalletId, @@ -2872,6 +2938,85 @@ mod tests { let _ = std::fs::remove_file(&path); } + /// A never-delivered spend's saved record and its journal records are + /// retired by one durable write: after it, a reopened store holds + /// neither. A write that fails part way, here at the journal's delete, + /// removes neither, and one whose re-broadcast was claimed is refused and + /// keeps both; so after any reopen, the transition the redrive pass could + /// broadcast and the journal record that holds its pair stay together. + #[test] + fn should_retire_an_undelivered_spend_with_its_journal_records_in_one_write() { + let payment = PrivateContactPayment { + sender: [0xB1; 32], + recipient: [0xA1; 32], + account_reference: 7, + }; + let commitment = [0xC4; 32]; + for case in ["retired", "write fails", "re-broadcast claimed"] { + let path = temp_tree_path("retire_undelivered"); + let wallet_id = [0x5C; 32]; + let id = SubwalletId::new(wallet_id, 0); + let redrive = PendingRedrive { + activity_id: [0x31; 32], + anchor: [0x32; 32], + nullifiers: vec![[0x33; 32]], + st_bytes: private_document_create_bytes(0xC4), + attempts: 0, + identity_nonce_finalized: false, + identity_user_abandoned: false, + }; + let mut store = FileBackedShieldedStore::open_path(&path, 100).expect("open"); + store.mark_pending(id, &[0x33; 32]).expect("reserved"); + store + .arm_private_contact_payment(id, redrive.clone(), payment, &[commitment], 0) + .expect("armed"); + match case { + "write fails" => store + .pending_conn + .lock() + .expect("pending_conn mutex") + .execute_batch( + "CREATE TRIGGER reject_journal_delete BEFORE DELETE ON \ + shielded_private_payment_journal BEGIN SELECT RAISE(FAIL, \ + 'injected delete failure'); END;", + ) + .expect("trigger"), + "re-broadcast claimed" => { + assert_eq!( + store + .bump_redrive_attempts(id, &redrive.activity_id) + .expect("claimed"), + 1 + ); + } + _ => {} + } + let retired = store.retire_undelivered_spend(id, &redrive.activity_id, &[commitment]); + match case { + "retired" => assert!(retired.expect("retired"), "{case}"), + "write fails" => assert!(retired.is_err(), "{case}: {retired:?}"), + _ => assert!(!retired.expect("refused"), "{case}"), + } + let kept = case != "retired"; + let both = |store: &FileBackedShieldedStore| { + ( + store.pending_redrives(id).expect("records").len(), + store + .private_payment_journal(wallet_id) + .expect("journal") + .len(), + ) + }; + let expected = if kept { (1, 1) } else { (0, 0) }; + assert_eq!(both(&store), expected, "{case}: in memory"); + drop(store); + let reopened = FileBackedShieldedStore::open_path(&path, 100).expect("reopened"); + assert_eq!(both(&reopened), expected, "{case}: reopened"); + drop(reopened); + let _ = std::fs::remove_file(&path); + } + } + /// A journal record that does not read back stays, and fails reading /// its wallet's journal, or every wallet's when its wallet does not read /// either; the store still opens. diff --git a/packages/rs-platform-wallet/src/wallet/shielded/operations.rs b/packages/rs-platform-wallet/src/wallet/shielded/operations.rs index 88a40ab0a7d..902d52b5796 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/operations.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/operations.rs @@ -1813,12 +1813,14 @@ where } .await; + let activity_id = pending_entry.as_ref().map(|entry| entry.id); settle_private_document_payment( store, persister, wallet_id, id, &pending_entry, + activity_id, reservation, selected_notes, journaled, @@ -1842,6 +1844,14 @@ where /// and `payment_started` tells the caller so /// ([`PaymentStarted::note_never_delivered`]). Otherwise they stay until the /// entry is read back. +/// +/// A transition that never left the wallet has its saved record, keyed by +/// `activity_id`, retired with the journal records in one durable write, +/// under the store lock the redrive pass claims each re-broadcast under +/// ([`NoteReservation::retire_undelivered`]). One whose record a redrive +/// pass claimed a re-broadcast of meanwhile, or that cannot be retired, may +/// leave the wallet after all: it is settled as unconfirmed, keeping its +/// notes, record and journal records. #[allow(clippy::too_many_arguments)] async fn settle_private_document_payment( store: &Arc>, @@ -1849,6 +1859,7 @@ async fn settle_private_document_payment( wallet_id: WalletId, id: SubwalletId, pending_entry: &Option, + activity_id: Option<[u8; 32]>, mut reservation: NoteReservation, selected_notes: &[ShieldedNote], journaled: Option<&[[u8; 32]]>, @@ -1894,8 +1905,50 @@ async fn settle_private_document_payment( } Err(SpendFailure { error, - never_delivered, + never_delivered: true, }) => { + let commitments = journaled.unwrap_or_default(); + let retired = match activity_id { + Some(activity_id) => { + reservation + .retire_undelivered(&activity_id, commitments) + .await + } + None => { + reservation.hand_over(); + false + } + }; + if !retired { + // It may leave the wallet after all: everything stays, as for + // an unconfirmed payment. + return Err(PlatformWalletError::ShieldedSpendUnconfirmed { + operation: "private document create from shielded pool", + reason: format!( + "its broadcast was refused before delivery ({error}), but its saved \ + transition was claimed for a re-broadcast or could not be retired" + ), + }); + } + record_activity_status( + store, + persister, + wallet_id, + id, + pending_entry, + ShieldedActivityStatus::Failed, + None, + ) + .await; + if journaled.is_some() { + if let Some(persister) = persister { + persister.note_discharged_private_entries(commitments.iter().copied()); + } + payment_started.note_never_delivered(); + } + Err(error) + } + Err(SpendFailure { error, .. }) => { // It did not execute: the guard owns the notes until they are // released, so a drop on the way still releases them. reservation.reclaim(); @@ -1909,49 +1962,12 @@ async fn settle_private_document_payment( None, ) .await; - if never_delivered { - if let Some(commitments) = journaled { - if discharge_journal_records(store, persister, wallet_id, commitments).await { - payment_started.note_never_delivered(); - } - } - } reservation.cancel().await; Err(error) } } } -/// Discharge `wallet_id`'s journal records of `commitments`, whose payment's -/// transition never left the wallet, noting each one discharged on -/// `persister` so a restoration that read it before skips it. Returns whether -/// every one is gone; a failure leaves the records, which over-holds their -/// pairs. -async fn discharge_journal_records( - store: &Arc>, - persister: Option<&WalletPersister>, - wallet_id: WalletId, - commitments: &[[u8; 32]], -) -> bool { - let mut store = store.write().await; - let mut discharged = Vec::with_capacity(commitments.len()); - for commitment in commitments { - match store.discharge_private_payment(wallet_id, commitment) { - Ok(_) => discharged.push(*commitment), - Err(e) => warn!( - error = %e, - "a never-delivered private payment's journal record was not discharged; its \ - pair stays held" - ), - } - } - let every_one = discharged.len() == commitments.len(); - if let Some(persister) = persister { - persister.note_discharged_private_entries(discharged); - } - every_one -} - // ------------------------------------------------------------------------- // ShieldFromAssetLock: Core L1 asset lock -> shielded pool (Type 18) // (orchestrated entry point lives in `wallet/shielded/fund_from_asset_lock.rs`) @@ -3347,6 +3363,42 @@ impl NoteReservation { self.handed_over = false; } + /// For a spend whose transition never left the wallet: retire its saved + /// record `activity_id` with the journal records of `commitments` in one + /// durable write ([`ShieldedStore::retire_undelivered_spend`]), and + /// release the notes under the same store guard. Returns whether it was + /// retired. + /// + /// When it was not (a redrive pass claimed a re-broadcast of the record, + /// the record is gone, or the write failed), nothing changed and the + /// notes stay reserved, handed over to the spend's resolution. So do they + /// when the call is dropped while it waits for the store: the record may + /// still be saved, and a note released under a saved transition could be + /// spent twice. + pub(super) async fn retire_undelivered( + mut self, + activity_id: &[u8; 32], + commitments: &[[u8; 32]], + ) -> bool { + self.handed_over = true; + let mut store = self.store.write().await; + match store.retire_undelivered_spend(self.id, activity_id, commitments) { + Ok(true) => { + release_reserved_notes(&mut *store, self.id, &self.nullifiers, self.reservation); + self.nullifiers.clear(); + true + } + Ok(false) => false, + Err(e) => { + warn!( + error = %e, + "a never-delivered spend's record was not retired; it is kept with its notes" + ); + false + } + } + } + /// Release the notes now, for a spend that did not execute. /// The guard owns them until the release is done: a spend dropped while it waits for the /// store lock still releases them on drop. @@ -3827,6 +3879,45 @@ async fn bump_redrive_attempts_logged( } } +/// Claim one re-broadcast of `redrive`, durably, from the record as it is now, +/// under the store write lock a never-delivered spend's retirement takes +/// ([`ShieldedStore::retire_undelivered_spend`]): the attempt is counted +/// before the broadcast starts, so a retirement after the claim sees it and +/// refuses. A record retired, armed again with other bytes, or out of attempts +/// since `redrive` was read is not claimed, and not broadcast. Returns the +/// attempt count, or `None` when nothing was claimed: a counter that fails to +/// persist claims nothing. +async fn claim_redrive_attempt( + store: &Arc>, + id: SubwalletId, + redrive: &PendingRedrive, +) -> Option { + let mut store = store.write().await; + let live = store.pending_redrives(id).is_ok_and(|current| { + current.iter().any(|record| { + record.activity_id == redrive.activity_id + && record.st_bytes == redrive.st_bytes + && record.attempts < MAX_REDRIVE_ATTEMPTS + }) + }); + if !live { + debug!("redrive: the record was retired or changed since it was read; not re-broadcast"); + return None; + } + match store.bump_redrive_attempts(id, &redrive.activity_id) { + Ok(attempts) if attempts > 0 => Some(attempts), + Ok(_) => None, + Err(e) => { + warn!( + error = %e, + "redrive: the attempt could not be counted, so it is not re-broadcast; the next \ + pass retries" + ); + None + } + } +} + /// Re-broadcast every unresolved ShieldFromIdentity transition for `wallet_id`. /// These records have no nullifiers or spending anchor, so they must not depend /// on the note-spend stranded-release pass. Exact-byte retries remain safe even @@ -4012,6 +4103,9 @@ pub(super) async fn redrive_pending_spends( continue; } }; + let Some(attempts) = claim_redrive_attempt(store, id, &redrive).await else { + continue; + }; let broadcast_result = st.broadcast(sdk, None).await; let err_display = broadcast_result .as_ref() @@ -4020,7 +4114,6 @@ pub(super) async fn redrive_pending_spends( .unwrap_or_default(); match classify_redrive_broadcast(&broadcast_result) { RedriveBroadcastOutcome::Accepted => { - let attempts = bump_redrive_attempts_logged(store, id, &redrive.activity_id).await; info!( attempts, max = MAX_REDRIVE_ATTEMPTS, @@ -4028,12 +4121,11 @@ pub(super) async fn redrive_pending_spends( ); } RedriveBroadcastOutcome::AlreadyExecuted => { - // Success signal — but still consume an attempt: the scan + // Success signal, but the attempt still counts: the scan // normally confirms the landing and drops the record, and // if it lags, this arm must not re-broadcast unboundedly // on every pass. The cap parks the record for the scan / // prune passes to settle. - let attempts = bump_redrive_attempts_logged(store, id, &redrive.activity_id).await; info!( attempts, max = MAX_REDRIVE_ATTEMPTS, @@ -4045,7 +4137,6 @@ pub(super) async fn redrive_pending_spends( // `AlreadyExists` (still in a mempool after a lost-ACK // retry) or transport noise: inconclusive; counts toward // the cap. - let attempts = bump_redrive_attempts_logged(store, id, &redrive.activity_id).await; debug!( attempts, max = MAX_REDRIVE_ATTEMPTS, @@ -5105,9 +5196,11 @@ mod redrive_tests { /// broadcast may have been delivered and the result wait failed), the /// notes stay reserved and the record, association and journal record /// survive a reopen. Rejected after delivery, the notes and record are - /// released, the journal record stays. Never delivered, the journal - /// record is discharged too and the payment reads as not reaching the - /// network. + /// released, the journal record stays. Never delivered, the record and + /// the journal record are retired together and the payment reads as not + /// reaching the network; unless the redrive pass claimed a re-broadcast + /// of the record meanwhile, when it may leave the wallet after all and is + /// settled as unconfirmed, keeping everything. #[tokio::test] async fn should_keep_a_private_payment_s_journal_record_unless_it_never_left() { let sdk = Arc::new(dash_sdk::Sdk::new_mock()); @@ -5161,7 +5254,12 @@ mod redrive_tests { never_delivered: true, }, }; - for outcome in ["unconfirmed", "rejected after delivery", "never delivered"] { + for outcome in [ + "unconfirmed", + "rejected after delivery", + "never delivered", + "never delivered, re-broadcast claimed", + ] { let dir = tempfile::tempdir().expect("a directory"); let path = dir.path().join("tree.sqlite"); let store = Arc::new(RwLock::new( @@ -5207,12 +5305,20 @@ mod redrive_tests { "{outcome}: journaled before the broadcast" ); + if outcome == "never delivered, re-broadcast claimed" { + store + .write() + .await + .bump_redrive_attempts(id, &[0x77; 32]) + .expect("claimed"); + } let settled = settle_private_document_payment( &store, None, wallet_id, id, &None, + Some([0x77; 32]), reservation, &selected, Some(&[commitment]), @@ -5236,7 +5342,14 @@ mod redrive_tests { .private_contact_redrives_for_wallet(wallet_id) .expect("listed"); match outcome { - "unconfirmed" => { + "unconfirmed" | "never delivered, re-broadcast claimed" => { + assert!( + matches!( + settled, + Err(PlatformWalletError::ShieldedSpendUnconfirmed { .. }) + ), + "{outcome}: {settled:?}" + ); assert_eq!(unspent, 0, "the notes stay reserved"); assert_eq!(saved.len(), 1, "the record and its association stay"); assert_eq!(journal.len(), 1, "the journal record stays"); @@ -5765,6 +5878,92 @@ mod redrive_tests { "no attempt counters were bumped — nothing touched the network" ); } + + /// A record retired while the redrive pass holds a snapshot that still + /// has it is not re-broadcast: the pass claims each re-broadcast under + /// the store write lock, from the record as it is then. Here the pass is + /// held at its claim of an earlier record while the retirement queues for + /// the lock, so it reaches the retired record with its stale snapshot. + #[tokio::test] + async fn should_not_rebroadcast_a_record_retired_after_the_pass_read_it() { + use dash_sdk::platform::transition::broadcast_request::BroadcastRequestForStateTransition; + use dpp::serialization::PlatformSerializable; + use std::future::Future; + use std::task::{Context, Waker}; + + let mut sdk = dash_sdk::SdkBuilder::new_mock().build().expect("mock sdk"); + let store = Arc::new(RwLock::new(InMemoryShieldedStore::new())); + let id = SubwalletId::new([0x5D; 32], 0); + let anchor = [0x6D; 32]; + let earlier = identity_shield_transition([0xA1; 32], 3); + let retired = identity_shield_transition([0xA2; 32], 4); + let record = |activity: u8, transition: &StateTransition| PendingRedrive { + activity_id: [activity; 32], + anchor, + nullifiers: vec![[activity ^ 0xFF; 32]], + st_bytes: transition.serialize_to_bytes().expect("serializes"), + attempts: 0, + identity_nonce_finalized: false, + identity_user_abandoned: false, + }; + { + let mut guard = store.write().await; + guard.arm_redrive(id, record(1, &earlier)).expect("armed"); + guard.arm_redrive(id, record(2, &retired)).expect("armed"); + } + let recorded: std::collections::HashSet<[u8; 32]> = [anchor].into_iter().collect(); + let sdk_handle = Arc::new(sdk.clone()); + let cancel = CancellationToken::new(); + + let mut cx = Context::from_waker(Waker::noop()); + let held = store.read().await; + let mut pass = Box::pin(redrive_pending_spends( + &sdk_handle, + &store, + id, + &recorded, + &cancel, + )); + assert!( + pass.as_mut().poll(&mut cx).is_pending(), + "the pass read both records and waits to claim the first" + ); + let mut retire = Box::pin(async { + store + .write() + .await + .retire_undelivered_spend(id, &[2; 32], &[]) + }); + assert!( + retire.as_mut().poll(&mut cx).is_pending(), + "the retirement queues behind the claim" + ); + drop(held); + let ((), retirement) = tokio::join!(pass, retire); + assert!( + retirement.expect("retired"), + "no re-broadcast was claimed for it" + ); + + let request = |transition: &StateTransition| { + transition + .broadcast_request_for_state_transition() + .expect("serializes") + }; + assert!( + sdk.mock().request_count(&request(&earlier)).await > 0, + "the earlier record is re-broadcast" + ); + assert_eq!( + sdk.mock().request_count(&request(&retired)).await, + 0, + "the retired record is not" + ); + let left = store.read().await.pending_redrives(id).expect("records"); + assert_eq!(left.len(), 1); + assert_eq!(left[0].activity_id, [1; 32]); + assert_eq!(left[0].attempts, 1, "claimed once, before its broadcast"); + } } #[cfg(test)] diff --git a/packages/rs-platform-wallet/src/wallet/shielded/store.rs b/packages/rs-platform-wallet/src/wallet/shielded/store.rs index 14a935e8aba..54b6fa7fda5 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/store.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/store.rs @@ -537,17 +537,36 @@ pub trait ShieldedStore: Send + Sync + 'static { ) -> Result<(), Self::Error>; /// Remove `wallet_id`'s journal record of the entry `commitment`, once - /// its payment provably resolved: the entry was read back, the - /// transition never left the wallet, or the payment is proven expired - /// (its anchor pruned, an input note still unspent, its entry absent - /// from the store). Returns whether there was one. No other call removes - /// a journal record: not a note release, a redrive's removal or a purge. + /// its payment provably resolved: the entry was read back, or the payment + /// is proven expired (its anchor pruned and its entry absent from the + /// store). Returns whether there was one. Only this call and + /// [`Self::retire_undelivered_spend`] remove a journal record: not a note + /// release, a redrive's removal or a purge. fn discharge_private_payment( &mut self, wallet_id: WalletId, commitment: &[u8; 32], ) -> Result; + /// Retire the saved record of `id`'s spend `activity_id`, whose + /// transition never left the wallet, together with the journal records of + /// `commitments` in `id`'s wallet, the entries it would have appended. One + /// durable write removes them all (the file store commits it on its + /// recovery connection, at `synchronous=FULL`), so no restart finds the + /// journal discharged while the signed transition is still saved, or the + /// other way round. + /// + /// Returns false, changing nothing, when no such record is saved, or when + /// a re-broadcast of it was claimed ([`PendingRedrive::attempts`] above + /// 0): the transition may then have left the wallet. The notes the spend + /// reserved are the caller's to release. + fn retire_undelivered_spend( + &mut self, + id: SubwalletId, + activity_id: &[u8; 32], + commitments: &[[u8; 32]], + ) -> Result; + /// Every armed redrive record for `id`. fn pending_redrives(&self, id: SubwalletId) -> Result, Self::Error>; @@ -1375,6 +1394,28 @@ impl ShieldedStore for InMemoryShieldedStore { .is_some()) } + fn retire_undelivered_spend( + &mut self, + id: SubwalletId, + activity_id: &[u8; 32], + commitments: &[[u8; 32]], + ) -> Result { + let Some(subwallet) = self + .subwallets + .get_mut(&id) + .filter(|subwallet| subwallet.redrive_attempts(activity_id) == Some(0)) + else { + return Ok(false); + }; + subwallet.clear_redrive(activity_id); + self.private_contact_payments.remove(&(id, *activity_id)); + for commitment in commitments { + self.private_payment_journal + .remove(&(id.wallet_id, *commitment)); + } + Ok(true) + } + fn pending_redrives(&self, id: SubwalletId) -> Result, Self::Error> { Ok(self .subwallets From 0e240350033bb6a42d6bf875cee15197d37f2db6 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Tue, 10 Nov 2026 00:38:32 +0700 Subject: [PATCH 115/117] fix(platform-wallet): discharge an expired private payment whose inputs a competing spend consumed The expiry discharge required one of a payment's input notes to be unspent, so a payment whose inputs another transition consumed never reached the store check, and its journal record held the pair after every restart. A type 29 transition appends its entry in the block that spends its inputs. With its anchor proven pruned at H, so it cannot execute above H, and its entry absent from a store read whose every page is proved at or above H, it never executed, whatever spent its inputs since. The discharge no longer reads the notes; the snapshot equality, anchor height, scan coverage and page height guards stay. Co-Authored-By: Claude Opus 5.5 --- .../network/private_contact_requests.rs | 13 +++--- .../src/wallet/shielded/coordinator.rs | 44 +++++++------------ 2 files changed, 24 insertions(+), 33 deletions(-) diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/private_contact_requests.rs b/packages/rs-platform-wallet/src/wallet/identity/network/private_contact_requests.rs index 64360232142..cc54fe78f71 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/private_contact_requests.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/private_contact_requests.rs @@ -6945,11 +6945,12 @@ mod tests { } /// A private payment that never executes is proven expired once its - /// anchor is pruned, one of its input notes still unspent and its entry - /// absent from the store: the coordinator discharges its journal record, - /// the pair is freed, a retry to it pays, and a reopened store restores - /// no hold. A still-recorded anchor, a spent input or an entry in the - /// store each keep the record and the hold. + /// anchor is pruned and its entry is absent from the store: the + /// coordinator discharges its journal record, the pair is freed, a retry + /// to it pays, and a reopened store restores no hold. So it is when a + /// competing spend consumed its input first: the absent entry shows it + /// never executed. A still-recorded anchor or an entry in the store each + /// keep the record and the hold. #[cfg(feature = "shielded")] #[tokio::test] async fn should_free_a_pair_once_its_private_payment_provably_expired() { @@ -6989,7 +6990,7 @@ mod tests { }; let journal = fixture.journal_snapshot().await; fixture.discharge(&journal, &recorded, Some(height)).await; - if case == Case::Expired { + if matches!(case, Case::Expired | Case::InputSpent) { fixture.assert_discharged(&format!("{case:?}")).await; } else { fixture.assert_kept(&format!("{case:?}")).await; diff --git a/packages/rs-platform-wallet/src/wallet/shielded/coordinator.rs b/packages/rs-platform-wallet/src/wallet/shielded/coordinator.rs index f454abaf532..84c56e40d0a 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/coordinator.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/coordinator.rs @@ -1907,20 +1907,22 @@ impl NetworkShieldedCoordinator { /// the set: a pruned root never re-enters it, so the transition cannot /// execute above H (`shielded_anchor_retention_blocks` 1000, pruned /// every `shielded_anchor_pruning_interval` 100 blocks); - /// 3. one of its input notes is still unspent after this pass's note scan, - /// which covers H (`scan_covered_height`, - /// `MultiSyncNotesResult::covered_height`): had it executed at or below - /// H, the scan would have seen its nullifiers spent; + /// 3. this pass's note scan covers H (`scan_covered_height`, + /// `MultiSyncNotesResult::covered_height`); /// 4. its entry is absent from DashPay's private contact request store, /// read raw through its end with every page proved at or above H /// ([`Self::private_contact_requests_held`]): an entry starts with its - /// commitment, so none needs opening. + /// commitment, so none needs opening. The transition appends its entry + /// in the block that spends its inputs, so an entry absent at or above + /// H, the transition unable to execute above H, shows it never + /// executed. Whether its inputs are spent does not matter: a competing + /// spend that consumed them does not keep its pair held. /// /// Anything short of that discharges nothing this pass, and the next pass /// retries: a scan or a store read below H, a read that fails, a record /// changed since. A relay refusal or a missing redrive record is never - /// taken as proof. Points 1 and 3 are checked again under the store write - /// guard that discharges the record. + /// taken as proof. Point 1 is checked again under the store write guard + /// that discharges the record. pub(crate) async fn discharge_expired_private_payments( &self, journal: &[(WalletId, PrivatePaymentRecord)], @@ -1950,7 +1952,7 @@ impl NetworkShieldedCoordinator { let store = self.store.read().await; pruned .into_iter() - .filter(|(wallet_id, record)| expirable(&*store, *wallet_id, record)) + .filter(|(wallet_id, record)| unchanged(&*store, *wallet_id, record)) .cloned() .collect() }; @@ -1978,7 +1980,7 @@ impl NetworkShieldedCoordinator { let persisters = self.persisters.read().await; let mut store = self.store.write().await; for (wallet_id, record) in candidates { - if held.contains(&record.commitment) || !expirable(&*store, wallet_id, &record) { + if held.contains(&record.commitment) || !unchanged(&*store, wallet_id, &record) { continue; } match store.discharge_private_payment(wallet_id, &record.commitment) { @@ -1986,8 +1988,8 @@ impl NetworkShieldedCoordinator { tracing::info!( wallet_id = %hex::encode(wallet_id), commitment = %hex::encode(record.commitment), - "a private payment's anchor was pruned with an input unspent and its \ - entry absent from the store: it can never execute, its pair is freed" + "a private payment's anchor was pruned and its entry is absent from the \ + store: it never executed and never can, its pair is freed" ); if let Some(persister) = persisters.get(&wallet_id) { persister.note_discharged_private_entries([record.commitment]); @@ -2491,27 +2493,15 @@ pub(crate) fn private_payment_journal_snapshot( } /// Whether `wallet_id`'s journal still holds `record` exactly as a pass's -/// snapshot read it, with one of its input notes unspent. A store read that -/// fails says no. -fn expirable( +/// snapshot read it. A store read that fails says no. +fn unchanged( store: &S, wallet_id: WalletId, record: &PrivatePaymentRecord, ) -> bool { - let unchanged = store + store .private_payment_journal(wallet_id) - .is_ok_and(|records| records.contains(record)); - if !unchanged { - return false; - } - let Ok(notes) = store.get_all_notes(SubwalletId::new(wallet_id, record.account_index)) else { - return false; - }; - record.nullifiers.iter().any(|nullifier| { - notes - .iter() - .any(|note| note.nullifier == *nullifier && !note.is_spent) - }) + .is_ok_and(|records| records.contains(record)) } #[cfg(test)] From 92569e70e1026f60ccdb1648ed0baa29cdbf43d2 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Tue, 10 Nov 2026 01:04:06 +0700 Subject: [PATCH 116/117] feat(sdk): let the mock SDK prove each response type at its own height MockDashPlatformSdk::set_response_height_of::() sets the block height the metadata of every response of type O answered from an expectation reports, over set_response_height: for code under test that reads two kinds of responses proved at different heights, such as an anchor set and a note scan a block behind it. Unset types keep the shared height. Co-Authored-By: Claude Opus 5.5 --- packages/rs-sdk/src/mock/sdk.rs | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/packages/rs-sdk/src/mock/sdk.rs b/packages/rs-sdk/src/mock/sdk.rs index a74f922faea..9205c1055b9 100644 --- a/packages/rs-sdk/src/mock/sdk.rs +++ b/packages/rs-sdk/src/mock/sdk.rs @@ -51,6 +51,9 @@ pub struct MockDashPlatformSdk { /// The block height the metadata of a response answered from an expectation reports /// ([Self::set_response_height]); 0 unless set. response_height: u64, + /// The block height the metadata of a response of one type answered from an expectation + /// reports, by the type's name ([Self::set_response_height_of]), over `response_height`. + response_heights_of: BTreeMap<&'static str, u64>, } impl MockDashPlatformSdk { @@ -84,6 +87,7 @@ impl MockDashPlatformSdk { sdk: ArcSwapOption::new(None), response_time_ms: 0, response_height: 0, + response_heights_of: BTreeMap::new(), } } @@ -101,6 +105,15 @@ impl MockDashPlatformSdk { self } + /// Sets the block height the metadata of every response of type `O` answered from an + /// expectation reports, over [Self::set_response_height]: for code under test that reads + /// two kinds of responses proved at different heights. + pub fn set_response_height_of(&mut self, height: u64) -> &mut Self { + self.response_heights_of + .insert(std::any::type_name::(), height); + self + } + pub(crate) fn set_sdk(&mut self, sdk: Sdk) { self.sdk.store(Some(Arc::new(sdk))); } @@ -619,7 +632,11 @@ impl MockDashPlatformSdk { ResponseMetadata { protocol_version: dpp::version::LATEST_VERSION, time_ms: self.response_time_ms, - height: self.response_height, + height: self + .response_heights_of + .get(std::any::type_name::()) + .copied() + .unwrap_or(self.response_height), ..Default::default() }, Proof::default(), From e5f8ed9f2fd6f42fe49f7e9fb3c4871a826a40bb Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Tue, 10 Nov 2026 01:04:06 +0700 Subject: [PATCH 117/117] fix(platform-wallet): release a stranded note only on height-pinned proof its anchor was pruned The stranded note release cleared an anchored reservation on anchor absence alone. A spend can execute in block H before that block prunes its anchor while the note scan returns valid proofs from H-1, within the SDK's one-block tolerance: the release then freed the note, dropped its redrive record and marked its activity Failed though the note was spent in H, and an anchor newer than a lagging set read as pruned too. Each anchored reservation and saved record now keeps the proved height of the anchor set its anchor was read from (a new nullable anchor_height column on the recovery table). PrunedAnchorEvidence holds the pass's anchor set, its proved height H and the scan's covered height, and is the one rule both the note release and the journal expiry judge an anchor by: read at or below H, absent at H, and the scan covering H. Short of that nothing is released. A reservation restored from a record saved before heights were kept gets H once a set at H holds its anchor, durably, and is released only by a later set lacking it. Co-Authored-By: Claude Opus 5.5 --- .../src/shielded_send.rs | 1 + .../rs-platform-wallet/src/manager/load.rs | 1 + .../network/private_contact_requests.rs | 12 +- .../src/wallet/platform_wallet.rs | 1 + .../src/wallet/shielded/balance_tests.rs | 1 + .../src/wallet/shielded/coordinator.rs | 467 +++++++++++++++--- .../src/wallet/shielded/file_store.rs | 125 ++++- .../src/wallet/shielded/operations.rs | 128 +++-- .../src/wallet/shielded/store.rs | 114 ++++- .../wallet/shielded/viewing_key_bind_tests.rs | 2 + 10 files changed, 727 insertions(+), 125 deletions(-) diff --git a/packages/rs-platform-wallet-ffi/src/shielded_send.rs b/packages/rs-platform-wallet-ffi/src/shielded_send.rs index 052c409558e..dd280e0914b 100644 --- a/packages/rs-platform-wallet-ffi/src/shielded_send.rs +++ b/packages/rs-platform-wallet-ffi/src/shielded_send.rs @@ -2447,6 +2447,7 @@ mod tests { attempts: 0, identity_nonce_finalized: false, identity_user_abandoned: true, + anchor_height: None, }, ) .expect("seed malformed durable metadata"); diff --git a/packages/rs-platform-wallet/src/manager/load.rs b/packages/rs-platform-wallet/src/manager/load.rs index 784ed989b70..a58b86ce8dd 100644 --- a/packages/rs-platform-wallet/src/manager/load.rs +++ b/packages/rs-platform-wallet/src/manager/load.rs @@ -2098,6 +2098,7 @@ mod private_payment_hold_tests { attempts: 0, identity_nonce_finalized: false, identity_user_abandoned: false, + anchor_height: None, }, PrivateContactPayment { sender: SENDER, diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/private_contact_requests.rs b/packages/rs-platform-wallet/src/wallet/identity/network/private_contact_requests.rs index cc54fe78f71..ea32bde680c 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/private_contact_requests.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/private_contact_requests.rs @@ -2303,7 +2303,9 @@ mod tests { use crate::wallet::identity::network::SeedCryptoProvider; use crate::wallet::platform_wallet::{PlatformWallet, WalletId}; #[cfg(feature = "shielded")] - use crate::wallet::shielded::coordinator::private_payment_journal_snapshot; + use crate::wallet::shielded::coordinator::{ + private_payment_journal_snapshot, PrunedAnchorEvidence, + }; #[cfg(feature = "shielded")] use crate::wallet::shielded::operations::arm_redrive_before_broadcast; #[cfg(feature = "shielded")] @@ -6858,9 +6860,11 @@ mod tests { self.coordinator .discharge_expired_private_payments( journal, - recorded, - Self::ANCHOR_SET_HEIGHT, - scan_covered_height, + PrunedAnchorEvidence { + recorded, + height: Self::ANCHOR_SET_HEIGHT, + scan_covered_height, + }, ) .await; } diff --git a/packages/rs-platform-wallet/src/wallet/platform_wallet.rs b/packages/rs-platform-wallet/src/wallet/platform_wallet.rs index 00e9e5d8e65..a3ec4a49bbe 100644 --- a/packages/rs-platform-wallet/src/wallet/platform_wallet.rs +++ b/packages/rs-platform-wallet/src/wallet/platform_wallet.rs @@ -3234,6 +3234,7 @@ mod private_contact_request_redrive_tests { attempts: 0, identity_nonce_finalized: false, identity_user_abandoned: false, + anchor_height: None, } } diff --git a/packages/rs-platform-wallet/src/wallet/shielded/balance_tests.rs b/packages/rs-platform-wallet/src/wallet/shielded/balance_tests.rs index 03adbe56175..b9700306b2e 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/balance_tests.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/balance_tests.rs @@ -365,6 +365,7 @@ async fn local_balance_restores_funds_and_durable_pending_reservations_offline() attempts: 0, identity_nonce_finalized: false, identity_user_abandoned: false, + anchor_height: None, }, ) .unwrap(); diff --git a/packages/rs-platform-wallet/src/wallet/shielded/coordinator.rs b/packages/rs-platform-wallet/src/wallet/shielded/coordinator.rs index 84c56e40d0a..2a53ce7ddbe 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/coordinator.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/coordinator.rs @@ -1530,14 +1530,14 @@ impl NetworkShieldedCoordinator { .await; } if !cancel.is_cancelled() { - self.release_stranded_spends(stale, &recorded).await; - self.discharge_expired_private_payments( - &journal, - &recorded, - recorded_height, - notes.covered_height, - ) - .await; + let evidence = PrunedAnchorEvidence { + recorded: &recorded, + height: recorded_height, + scan_covered_height: notes.covered_height, + }; + self.release_stranded_spends(stale, evidence).await; + self.discharge_expired_private_payments(&journal, evidence) + .await; } } @@ -1765,27 +1765,30 @@ impl NetworkShieldedCoordinator { /// fact have landed), released only when the tx lands or the app /// restarts. A spend accepted but that never lands would otherwise /// strand its notes for the whole session. Here, after the pass's - /// spent-note reconcile, any reservation from the PRE-scan `snapshot` - /// whose anchor is absent from the PRE-scan `recorded` set is provably - /// dead: `validate_anchor_exists` accepts a spend only while its anchor - /// is retained (`shielded_anchor_retention_blocks = 1000`), so once the - /// anchor is pruned the transition can never execute. The notes are - /// freed and the linked activity row flipped to Failed so the UI shows - /// a clear, retryable failure instead of "Pending" forever. The - /// on-chain nullifier set stays the authoritative double-spend guard. + /// spent-note reconcile, a reservation from the PRE-scan `snapshot` is + /// released once `evidence` proves its anchor pruned + /// ([`PrunedAnchorEvidence::proves_pruned`]): `validate_anchor_exists` + /// accepts a spend only while its anchor is retained + /// (`shielded_anchor_retention_blocks = 1000`), so once the anchor is + /// pruned the transition can never execute. The notes are freed and the + /// linked activity row flipped to Failed so the UI shows a clear, + /// retryable failure instead of "Pending" forever. The on-chain + /// nullifier set stays the authoritative double-spend guard. /// - /// Fund-safe by construction, resting on two ordering facts: + /// Fund-safe by construction, resting on two facts: /// - /// 1. Both inputs predate the scan ([`Self::prefetch_stranded_release`]), - /// and the scan's spent-note reconcile ran to completion in between. - /// An anchor absent from the pre-scan set was pruned before the scan - /// began (a pruned root can never re-enter the set — the tree only - /// grows), so if that spend nevertheless executed, it executed at a - /// height the scan covered — the reconcile already cleared its - /// reservation, which fact 2 catches. + /// 1. The evidence is pinned to the anchor set's proved height H: the + /// reservation's anchor was in a set proved at or below H, so the set + /// at H lacks it only once it was pruned (an anchor absent but read + /// above H may be newer than the set), and the pass's note scan, + /// covering H, replayed every nullifier spent at or below H. So if + /// that spend executed, it did so at or below H, and the reconcile + /// already cleared its reservation, which fact 2 catches. Short of + /// that, as with a scan whose proofs are a block behind the set's, + /// nothing is released this pass. /// 2. Each release is a check-and-clear under a single store write /// guard: the reservation is cleared only while the nullifier is - /// still armed with the snapshot's exact anchor + activity. A + /// still armed with the snapshot's exact anchor, height + activity. A /// reservation resolved concurrently (the reconcile above, or the /// spend's own result-wait confirming mid-pass) — or cleared and /// re-armed by a retry with a fresh anchor — is skipped, and its @@ -1793,11 +1796,15 @@ impl NetworkShieldedCoordinator { /// /// A still-recorded (slow-but-landing) spend is never released, and an /// anchor-less reservation (reserved but not yet built) is never in the - /// snapshot (`stale_pending_spends` returns only anchored entries). + /// snapshot (`stale_pending_spends` returns only anchored entries). A + /// reservation whose anchor height is not known (one restored from a + /// record saved before heights were kept) gets H once the set at H + /// holds its anchor ([`ShieldedStore::note_anchor_recorded`]), and is + /// released only on a later set that lacks it. async fn release_stranded_spends( &self, snapshot: Vec<(SubwalletId, StalePendingSpend)>, - recorded: &HashSet<[u8; 32]>, + evidence: PrunedAnchorEvidence<'_>, ) { // Persister map is only written on wallet register/unregister — // one read acquisition covers the whole loop. @@ -1805,10 +1812,18 @@ impl NetworkShieldedCoordinator { // Every note of a multi-note spend carries the same activity id: // flip each linked row once, not once per nullifier. let mut flipped: HashSet<[u8; 32]> = HashSet::new(); - for (id, (nullifier, anchor, activity_id)) in snapshot { - // Fund-safety invariant: release ONLY when the anchor is absent - // from the recorded set. A still-recorded anchor may yet land. - if recorded.contains(&anchor) { + // Reservations whose anchor height is not known, and whose anchor the + // set at H holds: given H, a later set lacking it shows it pruned. + let mut seen_recorded: std::collections::BTreeSet<(SubwalletId, [u8; 32])> = + std::collections::BTreeSet::new(); + for (id, (nullifier, anchor, activity_id, anchor_height)) in snapshot { + if anchor_height.is_none() && evidence.recorded.contains(&anchor) { + seen_recorded.insert((id, anchor)); + } + // Fund-safety invariant: release ONLY on proof the anchor was + // pruned with the scan covering it. A still-recorded anchor may + // yet land; an absent one read above H may be newer than the set. + if !evidence.proves_pruned(&anchor, anchor_height) { continue; } // Check-and-clear under ONE write acquisition: the snapshot @@ -1820,9 +1835,12 @@ impl NetworkShieldedCoordinator { { let mut store = self.store.write().await; let still_same = match store.stale_pending_spends(id) { - Ok(current) => current - .iter() - .any(|(n, a, act)| *n == nullifier && *a == anchor && *act == activity_id), + Ok(current) => current.iter().any(|(n, a, act, height)| { + *n == nullifier + && *a == anchor + && *act == activity_id + && *height == anchor_height + }), Err(e) => { tracing::warn!( wallet_id = %hex::encode(id.wallet_id), @@ -1888,6 +1906,20 @@ impl NetworkShieldedCoordinator { .await; } } + if !seen_recorded.is_empty() { + let mut store = self.store.write().await; + for (id, anchor) in seen_recorded { + if let Err(e) = store.note_anchor_recorded(id, &anchor, evidence.height) { + tracing::warn!( + wallet_id = %hex::encode(id.wallet_id), + account = id.account_index, + error = %e, + "shielded reservation release: the anchor's recorded height was not \ + kept; its reservations stay until it is" + ); + } + } + } } /// Discharge the private payment journal records of payments proven @@ -1896,20 +1928,21 @@ impl NetworkShieldedCoordinator { /// (`WalletPersister::note_discharged_private_entries`). /// /// The candidates are `journal`, the records the pass read before it - /// fetched `recorded`, the anchor set proved at `recorded_height` (H). - /// A payment is proven never to execute when: + /// fetched the anchor set `evidence` holds, proved at H. A payment is + /// proven never to execute when: /// /// 1. its record still reads in the journal exactly as `journal` has it: /// a payment journaled since, or armed again, may name an anchor newer /// than the set; - /// 2. its anchor was read from a proof at or below H (`anchor_height`), so - /// the set at H holds it unless it was pruned, and it is absent from - /// the set: a pruned root never re-enters it, so the transition cannot - /// execute above H (`shielded_anchor_retention_blocks` 1000, pruned - /// every `shielded_anchor_pruning_interval` 100 blocks); - /// 3. this pass's note scan covers H (`scan_covered_height`, - /// `MultiSyncNotesResult::covered_height`); - /// 4. its entry is absent from DashPay's private contact request store, + /// 2. `evidence` proves its anchor pruned + /// ([`PrunedAnchorEvidence::proves_pruned`], the rule the note release + /// judges by): read from a set proved at or below H + /// (`anchor_height`), absent from the set at H, and the pass's note + /// scan covering H. A pruned root never re-enters the set, so the + /// transition cannot execute above H + /// (`shielded_anchor_retention_blocks` 1000, pruned every + /// `shielded_anchor_pruning_interval` 100 blocks); + /// 3. its entry is absent from DashPay's private contact request store, /// read raw through its end with every page proved at or above H /// ([`Self::private_contact_requests_held`]): an entry starts with its /// commitment, so none needs opening. The transition appends its entry @@ -1926,28 +1959,18 @@ impl NetworkShieldedCoordinator { pub(crate) async fn discharge_expired_private_payments( &self, journal: &[(WalletId, PrivatePaymentRecord)], - recorded: &HashSet<[u8; 32]>, - recorded_height: u64, - scan_covered_height: Option, + evidence: PrunedAnchorEvidence<'_>, ) { + let recorded_height = evidence.height; let pruned: Vec<&(WalletId, PrivatePaymentRecord)> = journal .iter() .filter(|(_, record)| { - record.anchor_height <= recorded_height && !recorded.contains(&record.anchor) + evidence.proves_pruned(&record.anchor, Some(record.anchor_height)) }) .collect(); if pruned.is_empty() { return; } - if !matches!(scan_covered_height, Some(covered) if covered >= recorded_height) { - tracing::debug!( - ?scan_covered_height, - recorded_height, - "private payment expiry: the note scan does not cover the anchor set's height; \ - retried next pass" - ); - return; - } let candidates: Vec<(WalletId, PrivatePaymentRecord)> = { let store = self.store.read().await; pruned @@ -2452,6 +2475,42 @@ fn is_own_orchard_recipient(views: &AccountViewingKeys, recipient: &[u8]) -> boo .is_some() } +/// The evidence a sync pass has that an anchor was pruned: Platform's recorded +/// anchor set `recorded`, proved at `height` (H) and read before the pass's +/// note scan, and the height that scan covers (`scan_covered_height`, +/// `MultiSyncNotesResult::covered_height`). The one rule the note release +/// ([`NetworkShieldedCoordinator::release_stranded_spends`]) and the private +/// payment expiry +/// ([`NetworkShieldedCoordinator::discharge_expired_private_payments`]) judge +/// an anchor by. +#[derive(Clone, Copy)] +pub(crate) struct PrunedAnchorEvidence<'a> { + pub(crate) recorded: &'a HashSet<[u8; 32]>, + pub(crate) height: u64, + pub(crate) scan_covered_height: Option, +} + +impl PrunedAnchorEvidence<'_> { + /// Whether a spend built on `anchor`, which was in Platform's recorded + /// set at `anchor_height`, is proven unable to execute above H, with all + /// it could have done at or below H seen by the note scan: + /// + /// 1. `anchor_height` is known and at or below H, so the set at H holds + /// the anchor unless it was pruned, and the set lacks it: a pruned + /// root never re-enters the set, so the spend cannot execute above H. + /// An anchor read above H, or at an unknown height, may simply be + /// newer than the set. + /// 2. the scan covers H: a spend that executed at or below H spent its + /// nullifiers where the scan replayed them. + pub(crate) fn proves_pruned(&self, anchor: &[u8; 32], anchor_height: Option) -> bool { + anchor_height.is_some_and(|read| read <= self.height) + && !self.recorded.contains(anchor) + && self + .scan_covered_height + .is_some_and(|covered| covered >= self.height) + } +} + /// What a sync pass reads before its note scan, for the steps after it that /// may act only on evidence from before the scan /// ([`NetworkShieldedCoordinator::prefetch_stranded_release`]). @@ -2615,6 +2674,7 @@ mod tests { attempts: 0, identity_nonce_finalized: true, identity_user_abandoned: false, + anchor_height: None, }, cmxs, ) @@ -3188,6 +3248,7 @@ mod tests { attempts: 0, identity_nonce_finalized: false, identity_user_abandoned: false, + anchor_height: None, }; coordinator .store() @@ -3564,6 +3625,7 @@ mod tests { attempts: 0, identity_nonce_finalized: false, identity_user_abandoned: false, + anchor_height: None, }; coordinator .store() @@ -3677,6 +3739,7 @@ mod tests { attempts: 0, identity_nonce_finalized: true, identity_user_abandoned: false, + anchor_height: None, }, ) .unwrap(); @@ -4023,24 +4086,31 @@ mod tests { let mut store = coordinator.store.write().await; store.mark_pending(id, &pruned_nf).expect("mark pruned"); store - .set_pending_spend(id, &pruned_nf, pruned_anchor, pruned_act) + .set_pending_spend(id, &pruned_nf, pruned_anchor, pruned_act, Some(10)) .expect("arm pruned"); store.mark_pending(id, &live_nf).expect("mark live"); store - .set_pending_spend(id, &live_nf, live_anchor, live_act) + .set_pending_spend(id, &live_nf, live_anchor, live_act, Some(10)) .expect("arm live"); } // Snapshot as the pre-scan prefetch would have produced it; the // recorded set holds only the live anchor (the other was "pruned"). let snapshot = vec![ - (id, (pruned_nf, pruned_anchor, Some(pruned_act))), - (id, (live_nf, live_anchor, Some(live_act))), + (id, (pruned_nf, pruned_anchor, Some(pruned_act), Some(10))), + (id, (live_nf, live_anchor, Some(live_act), Some(10))), ]; let recorded: HashSet<[u8; 32]> = [live_anchor].into_iter().collect(); coordinator - .release_stranded_spends(snapshot, &recorded) + .release_stranded_spends( + snapshot, + PrunedAnchorEvidence { + recorded: &recorded, + height: 10, + scan_covered_height: Some(10), + }, + ) .await; let remaining = coordinator @@ -4062,6 +4132,279 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } + /// The whole sync pass, its note scan proved a block below the anchor set + /// (within the SDK's one-block tolerance): a spend built on an anchor the + /// set lacks may have executed in that block, which the scan has not + /// shown, so its note stays reserved, its saved record stays and its + /// activity stays Pending. Once the scan covers the set's height, the + /// pass releases it. An anchor read above the set's height, absent from + /// it, may be newer than the set and is never released by it. + #[tokio::test] + async fn should_release_a_stranded_spend_only_once_the_scan_covers_the_anchor_set() { + use super::super::activity::{ + ShieldedActivityEntry, ShieldedActivityKind, ShieldedDirection, + }; + use crate::wallet::shielded::ShieldedNote; + use drive_proof_verifier::types::{ + NoParamQuery, ShieldedAnchors, ShieldedEncryptedNotes, ShieldedEncryptedNotesQuery, + }; + + const SET_HEIGHT: u64 = 50; + let mut sdk = dash_sdk::Sdk::new_mock(); + let count = 2048 + * u32::from( + sdk.version() + .drive_abci + .query + .shielded_queries + .max_query_chunks, + ); + // The scan's window of 16 chunk fetches, all of an empty pool. + for chunk in 0..16 { + sdk.mock() + .expect_fetch( + ShieldedEncryptedNotesQuery { + start_index: u64::from(count) * chunk, + count, + }, + Some(ShieldedEncryptedNotes { + notes: Vec::new(), + total_count: 0, + }), + ) + .await + .expect("an empty chunk"); + } + sdk.mock() + .expect_fetch(NoParamQuery {}, Some(ShieldedAnchors(vec![[0xEE; 32]]))) + .await + .expect("the anchor set"); + sdk.mock() + .set_response_height_of::(SET_HEIGHT) + .set_response_height_of::(SET_HEIGHT - 1); + + let dir = temp_dir("release_scan_below_set"); + std::fs::create_dir_all(&dir).expect("create temp dir"); + let db_path = dir.join("tree.sqlite"); + let coordinator = NetworkShieldedCoordinator::new( + Arc::new(sdk.clone()), + dashcore::Network::Testnet, + db_path.clone(), + FileBackedShieldedStore::open_path(&db_path, 100).expect("open file store"), + ); + let wallet_id: WalletId = [0x11; 32]; + let views = OrchardKeySet::from_seed(&[0x42u8; 64], dashcore::Network::Testnet, 0) + .expect("derive viewing keys") + .viewing_keys(); + coordinator + .register_wallet( + wallet_id, + BTreeMap::from([(0u32, views)]), + WalletPersister::new(wallet_id, Arc::new(NoPlatformPersistence)), + ) + .await + .expect("register wallet"); + let id = SubwalletId::new(wallet_id, 0); + // Two broadcast spends whose anchors the set lacks: one read below + // the set's height, one above it. + let spends = [ + ([0x21; 32], [0x22; 32], [0x23; 32], SET_HEIGHT - 5), + ([0x31; 32], [0x32; 32], [0x33; 32], SET_HEIGHT + 1), + ]; + { + let mut store = coordinator.store().write().await; + for (index, (nullifier, anchor, activity, anchor_height)) in spends.iter().enumerate() { + let note = ShieldedNote { + position: index as u64, + cmx: [0x40 + index as u8; 32], + nullifier: *nullifier, + block_height: 1, + is_spent: false, + value: 1_000, + note_data: vec![0u8; 115], + }; + store.save_note(id, ¬e).expect("note"); + store.mark_pending(id, nullifier).expect("reserved"); + store + .set_pending_spend(id, nullifier, *anchor, *activity, Some(*anchor_height)) + .expect("anchored"); + store + .arm_redrive( + id, + PendingRedrive { + activity_id: *activity, + anchor: *anchor, + nullifiers: vec![*nullifier], + st_bytes: vec![0xDE, 0xAD], + attempts: 0, + identity_nonce_finalized: false, + identity_user_abandoned: false, + anchor_height: Some(*anchor_height), + }, + ) + .expect("record"); + store + .save_activity( + id, + &ShieldedActivityEntry { + id: *activity, + kind: ShieldedActivityKind::Unshield, + direction: ShieldedDirection::Out, + amount: 900, + fee: None, + counterparty: None, + memo: None, + block_height: None, + status: ShieldedActivityStatus::Pending, + created_at_ms: 1, + min_note_position: None, + note_cmxs: vec![], + spent_nullifiers: vec![*nullifier], + }, + ) + .expect("activity"); + } + } + let state = |store: &FileBackedShieldedStore, nullifier: [u8; 32], activity: [u8; 32]| { + let reserved = !store + .get_unspent_notes(id) + .expect("notes") + .iter() + .any(|note| note.nullifier == nullifier); + let saved = store + .pending_redrives(id) + .expect("records") + .iter() + .any(|record| record.activity_id == activity); + let status = store + .get_activity_by_entry_id(id, &activity) + .expect("activity") + .map(|entry| entry.status); + (reserved, saved, status) + }; + let held = (true, true, Some(ShieldedActivityStatus::Pending)); + + coordinator.sync(true, &CancellationToken::new()).await; + { + let store = coordinator.store().read().await; + for (nullifier, _, activity, _) in &spends { + assert_eq!( + state(&store, *nullifier, *activity), + held, + "a scan a block below the set releases nothing" + ); + } + } + + sdk.mock() + .set_response_height_of::(SET_HEIGHT); + coordinator.sync(true, &CancellationToken::new()).await; + let store = coordinator.store().read().await; + assert_eq!( + state(&store, spends[0].0, spends[0].2), + (false, false, Some(ShieldedActivityStatus::Failed)), + "proven pruned once the scan covers the set" + ); + assert_eq!( + state(&store, spends[1].0, spends[1].2), + held, + "an anchor read above the set may be newer than it" + ); + drop(store); + let _ = std::fs::remove_dir_all(&dir); + } + + /// A reservation restored from a record saved before anchor heights were + /// kept has no known height: its anchor absent from a set may be newer + /// than it, so it is not released. A set holding the anchor gives it that + /// set's height, durably, and a later set lacking it, with the scan + /// covering that set, releases it. + #[tokio::test] + async fn should_release_a_heightless_reservation_only_after_a_set_held_its_anchor() { + let dir = temp_dir("release_heightless"); + let coordinator = coordinator_with_one_wallet(&dir).await; + let id = SubwalletId::new([0x11; 32], 0); + let nullifier = [0x51; 32]; + let anchor = [0x52; 32]; + let activity = [0x53; 32]; + { + let mut store = coordinator.store.write().await; + store.mark_pending(id, &nullifier).expect("reserved"); + store + .set_pending_spend(id, &nullifier, anchor, activity, None) + .expect("anchored, no height"); + store + .arm_redrive( + id, + PendingRedrive { + activity_id: activity, + anchor, + nullifiers: vec![nullifier], + st_bytes: vec![0xDE, 0xAD], + attempts: 0, + identity_nonce_finalized: false, + identity_user_abandoned: false, + anchor_height: None, + }, + ) + .expect("record"); + } + let stale = |coordinator: &NetworkShieldedCoordinator| { + let store = coordinator.store.clone(); + async move { store.read().await.stale_pending_spends(id).expect("stale") } + }; + let release = |recorded: HashSet<[u8; 32]>, height: u64| { + let snapshot_store = coordinator.store.clone(); + let coordinator = &coordinator; + async move { + let snapshot: Vec<(SubwalletId, StalePendingSpend)> = snapshot_store + .read() + .await + .stale_pending_spends(id) + .expect("stale") + .into_iter() + .map(|entry| (id, entry)) + .collect(); + coordinator + .release_stranded_spends( + snapshot, + PrunedAnchorEvidence { + recorded: &recorded, + height, + scan_covered_height: Some(height), + }, + ) + .await; + } + }; + + // Absent from the first set seen: perhaps newer than it. + release(HashSet::new(), 10).await; + assert_eq!( + stale(&coordinator).await, + vec![(nullifier, anchor, Some(activity), None)], + "not proven pruned" + ); + // A set holding it gives it the set's height. + release(HashSet::from([anchor]), 20).await; + assert_eq!( + stale(&coordinator).await, + vec![(nullifier, anchor, Some(activity), Some(20))], + ); + let reopened = + FileBackedShieldedStore::open_path(dir.join("tree.sqlite"), 100).expect("reopened"); + assert_eq!( + reopened.pending_redrives(id).expect("records")[0].anchor_height, + Some(20), + "kept durably" + ); + drop(reopened); + // A later set lacking it releases it. + release(HashSet::new(), 30).await; + assert!(stale(&coordinator).await.is_empty(), "proven pruned"); + let _ = std::fs::remove_dir_all(&dir); + } + /// A token cancelled before the pass starts: no network call (the /// mock SDK would otherwise fail every wallet), no store change and /// no caught-up cooldown stamp (dashpay/platform#5200). diff --git a/packages/rs-platform-wallet/src/wallet/shielded/file_store.rs b/packages/rs-platform-wallet/src/wallet/shielded/file_store.rs index 5758a2ca42d..799d152febc 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/file_store.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/file_store.rs @@ -188,8 +188,8 @@ impl FileBackedShieldedStore { let nullifier_blob: Vec = redrive.nullifiers.iter().flatten().copied().collect(); tx.execute( "INSERT OR REPLACE INTO shielded_pending_spends \ - (wallet_id, account_index, activity_id, anchor, nullifiers, st_bytes, attempts, identity_nonce_finalized, identity_user_abandoned, private_contact_payment) \ - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)", + (wallet_id, account_index, activity_id, anchor, nullifiers, st_bytes, attempts, identity_nonce_finalized, identity_user_abandoned, private_contact_payment, anchor_height) \ + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)", rusqlite::params![ id.wallet_id.as_slice(), id.account_index, @@ -201,6 +201,11 @@ impl FileBackedShieldedStore { redrive.identity_nonce_finalized, redrive.identity_user_abandoned, payment.map(|payment| payment.to_bytes().to_vec()), + // A height past SQLite's integer range is kept unknown, + // which only holds the notes longer. + redrive + .anchor_height + .and_then(|height| i64::try_from(height).ok()), ], ) .map_err(|e| FileShieldedStoreError::Storage(format!("persist redrive: {e}")))?; @@ -444,6 +449,19 @@ impl FileBackedShieldedStore { .map_err(|e| FileShieldedStoreError::Storage(format!("upgrade pending schema: {e}")))?; } } + // A proved height at which the record's anchor was recorded, when + // known (`PendingRedrive::anchor_height`): NULL for a record saved + // before it was kept. + if !columns.iter().any(|existing| existing == "anchor_height") { + pending_conn + .execute( + "ALTER TABLE shielded_pending_spends ADD COLUMN anchor_height INTEGER", + [], + ) + .map_err(|e| { + FileShieldedStoreError::Storage(format!("upgrade pending schema: {e}")) + })?; + } // The private contact request a record pays for, when it does. if !columns .iter() @@ -507,7 +525,7 @@ impl FileBackedShieldedStore { .prepare( "SELECT wallet_id, account_index, activity_id, anchor, nullifiers, st_bytes, \ attempts, identity_nonce_finalized, identity_user_abandoned, \ - private_contact_payment FROM shielded_pending_spends", + private_contact_payment, anchor_height FROM shielded_pending_spends", ) .map_err(|e| FileShieldedStoreError::Storage(format!("prepare rehydrate: {e}")))?; let rows = stmt @@ -536,6 +554,13 @@ impl FileBackedShieldedStore { ), Err(_) => Some(SavedPrivateContactPayment::Unreadable), }; + // A height that does not read is not known: the anchor is then + // not proven pruned until a set is seen holding it. + let anchor_height = row + .get::<_, Option>(10) + .ok() + .flatten() + .and_then(|height| u64::try_from(height).ok()); let decoded: rusqlite::Result<_> = (|| { Ok(( row.get::<_, Vec>(0)?, @@ -553,16 +578,22 @@ impl FileBackedShieldedStore { classifiable_st_bytes, readable_wallet_id, private_contact_payment, + anchor_height, decoded, )) }) .map_err(|e| FileShieldedStoreError::Storage(format!("query rehydrate: {e}")))?; for row in rows { - let (classifiable_st_bytes, readable_wallet_id, private_contact_payment, decoded) = row - .map_err(|e| FileShieldedStoreError::RecoveryCorrupted { - account_index: None, - reason: format!("cannot decode recovery row: {e}"), - })?; + let ( + classifiable_st_bytes, + readable_wallet_id, + private_contact_payment, + anchor_height, + decoded, + ) = row.map_err(|e| FileShieldedStoreError::RecoveryCorrupted { + account_index: None, + reason: format!("cannot decode recovery row: {e}"), + })?; let note_spend_kind = classifiable_st_bytes .as_deref() .and_then(Self::non_identity_note_spend_kind); @@ -648,6 +679,7 @@ impl FileBackedShieldedStore { nullifiers, st_bytes, attempts, + anchor_height, identity_nonce_finalized, identity_user_abandoned, }, @@ -680,7 +712,12 @@ impl FileBackedShieldedStore { let sw = self.subwallets.entry(id).or_default(); for n in &redrive.nullifiers { sw.mark_pending(n); - sw.set_pending_spend(n, redrive.anchor, redrive.activity_id); + sw.set_pending_spend( + n, + redrive.anchor, + redrive.activity_id, + redrive.anchor_height, + ); } sw.arm_redrive(redrive); } @@ -968,10 +1005,56 @@ impl ShieldedStore for FileBackedShieldedStore { nullifier: &[u8; 32], anchor: [u8; 32], activity_id: [u8; 32], + anchor_height: Option, ) -> Result<(), Self::Error> { if let Some(sw) = self.subwallets.get_mut(&id) { - sw.set_pending_spend(nullifier, anchor, activity_id); + sw.set_pending_spend(nullifier, anchor, activity_id, anchor_height); + } + Ok(()) + } + + fn note_anchor_recorded( + &mut self, + id: SubwalletId, + anchor: &[u8; 32], + height: u64, + ) -> Result<(), Self::Error> { + let Some(sw) = self.subwallets.get_mut(&id) else { + return Ok(()); + }; + // Durable first: a failure gives nothing a height, which holds. So + // does a height past SQLite's integer range. + let Ok(durable_height) = i64::try_from(height) else { + return Ok(()); + }; + let records = sw.redrives_without_anchor_height(anchor); + if !records.is_empty() { + let mut conn = self + .pending_conn + .lock() + .map_err(|e| FileShieldedStoreError::Storage(format!("pending_conn mutex: {e}")))?; + let tx = conn.transaction().map_err(|e| { + FileShieldedStoreError::Storage(format!("begin anchor height: {e}")) + })?; + for activity_id in &records { + tx.execute( + "UPDATE shielded_pending_spends SET anchor_height = ?4 \ + WHERE wallet_id = ?1 AND account_index = ?2 AND activity_id = ?3 \ + AND anchor_height IS NULL", + rusqlite::params![ + id.wallet_id.as_slice(), + id.account_index, + activity_id.as_slice(), + durable_height, + ], + ) + .map_err(|e| FileShieldedStoreError::Storage(format!("note anchor height: {e}")))?; + } + tx.commit().map_err(|e| { + FileShieldedStoreError::Storage(format!("commit anchor height: {e}")) + })?; } + sw.note_anchor_recorded(anchor, height); Ok(()) } @@ -1623,6 +1706,7 @@ mod tests { attempts: 0, identity_nonce_finalized: false, identity_user_abandoned: false, + anchor_height: None, }; { let mut store = FileBackedShieldedStore::open_path(&path, 100).expect("open"); @@ -1684,6 +1768,7 @@ mod tests { attempts: 0, identity_nonce_finalized: false, identity_user_abandoned: false, + anchor_height: None, }; let payment = PrivateContactPayment { sender: [0xB1; 32], @@ -1749,6 +1834,7 @@ mod tests { attempts: 0, identity_nonce_finalized: false, identity_user_abandoned: false, + anchor_height: None, }; let payment = PrivateContactPayment { sender: [0xB1; 32], @@ -1827,6 +1913,7 @@ mod tests { attempts: 0, identity_nonce_finalized: false, identity_user_abandoned: false, + anchor_height: None, }; { let mut store = FileBackedShieldedStore::open_path(&path, 100).expect("open"); @@ -1924,6 +2011,7 @@ mod tests { attempts: 0, identity_nonce_finalized: false, identity_user_abandoned: false, + anchor_height: None, }, ) .unwrap(); @@ -1980,6 +2068,7 @@ mod tests { attempts: 0, identity_nonce_finalized: false, identity_user_abandoned: false, + anchor_height: None, }; let payment = PrivateContactPayment { sender: [0xB1; 32], @@ -2085,6 +2174,7 @@ mod tests { attempts: 0, identity_nonce_finalized: false, identity_user_abandoned: false, + anchor_height: None, }; { let mut store = FileBackedShieldedStore::open_path(&path, 100).unwrap(); @@ -2099,6 +2189,7 @@ mod tests { attempts: 0, identity_nonce_finalized: false, identity_user_abandoned: false, + anchor_height: None, }, ) .unwrap(); @@ -2164,6 +2255,7 @@ mod tests { attempts: 0, identity_nonce_finalized: false, identity_user_abandoned: false, + anchor_height: None, }, ) .unwrap(); @@ -2217,6 +2309,7 @@ mod tests { attempts: 0, identity_nonce_finalized: false, identity_user_abandoned: false, + anchor_height: None, }, ) .unwrap(); @@ -2250,6 +2343,7 @@ mod tests { attempts: 0, identity_nonce_finalized: false, identity_user_abandoned: false, + anchor_height: None, }; { let mut store = FileBackedShieldedStore::open_path(&path, 100).unwrap(); @@ -2277,6 +2371,7 @@ mod tests { attempts: 0, identity_nonce_finalized: false, identity_user_abandoned: false, + anchor_height: None, }; { let mut store = FileBackedShieldedStore::open_path(&path, 100).expect("open"); @@ -2326,6 +2421,7 @@ mod tests { attempts: 5, identity_nonce_finalized: false, identity_user_abandoned: false, + anchor_height: None, }; let note_spend = PendingRedrive { activity_id: [0x48; 32], @@ -2335,6 +2431,7 @@ mod tests { attempts: 1, identity_nonce_finalized: false, identity_user_abandoned: false, + anchor_height: None, }; { let mut store = FileBackedShieldedStore::open_path(&path, 100).expect("open"); @@ -2380,6 +2477,7 @@ mod tests { attempts: 0, identity_nonce_finalized: false, identity_user_abandoned: false, + anchor_height: None, }; // purge_wallet is scoped: it drops A's rows, keeps B's. @@ -2457,6 +2555,7 @@ mod tests { attempts: 0, identity_nonce_finalized: false, identity_user_abandoned: false, + anchor_height: None, }, ) .expect("arm"); @@ -2762,7 +2861,7 @@ mod tests { store.checkpoint_tree(4).unwrap(); let wallet_depth0_mid_block = store.tree_anchor().unwrap(); - // The spend path uses exactly this anchor: `extract_spends_and_anchor` + // The spend path uses exactly this anchor: `extract_spends_and_recorded_anchor` // builds it as `witness(pos, 0).root(cmx)`. Pin that it equals the // mid-block `tree_anchor()`. let cmx0 = ExtractedNoteCommitment::from_bytes(&cmx(1)) @@ -2880,6 +2979,7 @@ mod tests { attempts: 0, identity_nonce_finalized: false, identity_user_abandoned: false, + anchor_height: None, }; let expected = PrivatePaymentRecord { account_index: 1, @@ -2964,6 +3064,7 @@ mod tests { attempts: 0, identity_nonce_finalized: false, identity_user_abandoned: false, + anchor_height: None, }; let mut store = FileBackedShieldedStore::open_path(&path, 100).expect("open"); store.mark_pending(id, &[0x33; 32]).expect("reserved"); @@ -3057,6 +3158,7 @@ mod tests { attempts: 0, identity_nonce_finalized: false, identity_user_abandoned: false, + anchor_height: None, }, payment, &[[0xD4; 32]], @@ -3120,6 +3222,7 @@ mod tests { attempts: 0, identity_nonce_finalized: false, identity_user_abandoned: false, + anchor_height: None, }, ) .expect("armed"); diff --git a/packages/rs-platform-wallet/src/wallet/shielded/operations.rs b/packages/rs-platform-wallet/src/wallet/shielded/operations.rs index 902d52b5796..bd8a0fb9eba 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/operations.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/operations.rs @@ -894,6 +894,7 @@ async fn arm_identity_shield_redrive( attempts: 0, identity_nonce_finalized: false, identity_user_abandoned: false, + anchor_height: None, }, ) .map_err(|e| PlatformWalletError::ShieldedStoreError(e.to_string()))?; @@ -1427,7 +1428,8 @@ pub(in crate::wallet) async fn identity_top_up_from_pool_with_metadata< let mut pending_entry = None; let result = async { - let (spends, anchor) = extract_spends_and_anchor(sdk, store, &selected_notes).await?; + let (spends, anchor, anchor_height) = + extract_spends_and_recorded_anchor(sdk, store, &selected_notes).await?; let anchor_bytes = anchor.to_bytes(); let (state_transition, fee_used) = build_identity_top_up_from_shielded_pool_transition( @@ -1468,7 +1470,16 @@ pub(in crate::wallet) async fn identity_top_up_from_pool_with_metadata< }, ) .await; - if arm_pending_release(store, id, anchor_bytes, &pending_entry, &selected_notes).await? { + if arm_pending_release( + store, + id, + anchor_bytes, + anchor_height, + &pending_entry, + &selected_notes, + ) + .await? + { // Each reservation carries its anchor: a pruned anchor releases it. reservation.hand_over(); } @@ -1486,6 +1497,7 @@ pub(in crate::wallet) async fn identity_top_up_from_pool_with_metadata< id, &pending_entry, anchor_bytes, + anchor_height, &selected_notes, &state_transition, "identity top up from shielded pool", @@ -1789,8 +1801,15 @@ where payment_started, } = submission; let result = async { - let anchored = - arm_pending_release(store, id, anchor, &pending_entry, selected_notes).await?; + let anchored = arm_pending_release( + store, + id, + anchor, + anchor_height, + &pending_entry, + selected_notes, + ) + .await?; arm_redrive_before_broadcast( store, id, @@ -2026,7 +2045,8 @@ pub async fn unshield( // it. A build failure leaves it `None` and records nothing. let mut pending_entry = None; let result = async { - let (spends, anchor) = extract_spends_and_anchor(sdk, store, &selected_notes).await?; + let (spends, anchor, anchor_height) = + extract_spends_and_recorded_anchor(sdk, store, &selected_notes).await?; // Capture the recorded anchor before the builder consumes it, so a // broadcast-accepted-but-unconfirmed spend can be auto-released once // this anchor is pruned from Platform's recorded set. @@ -2075,7 +2095,16 @@ pub async fn unshield( }, ) .await; - if arm_pending_release(store, id, anchor_bytes, &pending_entry, &selected_notes).await? { + if arm_pending_release( + store, + id, + anchor_bytes, + anchor_height, + &pending_entry, + &selected_notes, + ) + .await? + { // Each reservation carries its anchor: a pruned anchor releases it. reservation.hand_over(); } @@ -2087,6 +2116,7 @@ pub async fn unshield( id, &pending_entry, anchor_bytes, + anchor_height, &selected_notes, &state_transition, "unshield", @@ -2217,7 +2247,8 @@ pub async fn transfer( let mut pending_entry = None; let result = async { - let (spends, anchor) = extract_spends_and_anchor(sdk, store, &selected_notes).await?; + let (spends, anchor, anchor_height) = + extract_spends_and_recorded_anchor(sdk, store, &selected_notes).await?; // Capture the recorded anchor before the builder consumes it, so a // broadcast-accepted-but-unconfirmed spend can be auto-released once // this anchor is pruned from Platform's recorded set. @@ -2266,7 +2297,16 @@ pub async fn transfer( }, ) .await; - if arm_pending_release(store, id, anchor_bytes, &pending_entry, &selected_notes).await? { + if arm_pending_release( + store, + id, + anchor_bytes, + anchor_height, + &pending_entry, + &selected_notes, + ) + .await? + { // Each reservation carries its anchor: a pruned anchor releases it. reservation.hand_over(); } @@ -2278,6 +2318,7 @@ pub async fn transfer( id, &pending_entry, anchor_bytes, + anchor_height, &selected_notes, &state_transition, "transfer", @@ -2396,7 +2437,8 @@ pub async fn withdraw( let mut pending_entry = None; let result = async { - let (spends, anchor) = extract_spends_and_anchor(sdk, store, &selected_notes).await?; + let (spends, anchor, anchor_height) = + extract_spends_and_recorded_anchor(sdk, store, &selected_notes).await?; // Capture the recorded anchor before the builder consumes it, so a // broadcast-accepted-but-unconfirmed spend can be auto-released once // this anchor is pruned from Platform's recorded set. @@ -2447,7 +2489,16 @@ pub async fn withdraw( }, ) .await; - if arm_pending_release(store, id, anchor_bytes, &pending_entry, &selected_notes).await? { + if arm_pending_release( + store, + id, + anchor_bytes, + anchor_height, + &pending_entry, + &selected_notes, + ) + .await? + { // Each reservation carries its anchor: a pruned anchor releases it. reservation.hand_over(); } @@ -2459,6 +2510,7 @@ pub async fn withdraw( id, &pending_entry, anchor_bytes, + anchor_height, &selected_notes, &state_transition, "withdraw", @@ -2614,7 +2666,8 @@ where // outer match; it lives here so the flip can see it. let mut pending_entry = None; let result = async { - let (spends, anchor) = extract_spends_and_anchor(sdk, store, &selected_notes).await?; + let (spends, anchor, anchor_height) = + extract_spends_and_recorded_anchor(sdk, store, &selected_notes).await?; // Capture the recorded anchor before the builder consumes it, so a // broadcast-accepted-but-unconfirmed create can be auto-released once // this anchor is pruned from Platform's recorded set. @@ -2665,7 +2718,15 @@ where }, ) .await; - if arm_pending_release(store, id, anchor_bytes, &pending_entry, &selected_notes).await? { + if arm_pending_release( + store, + id, + anchor_bytes, + anchor_height, + &pending_entry, + &selected_notes, + ) + .await? { // Each reservation carries its anchor: a pruned anchor releases it. reservation.hand_over(); } @@ -2769,6 +2830,7 @@ where id, &pending_entry, anchor_bytes, + anchor_height, &selected_notes, &st, "identity_create", @@ -3015,19 +3077,10 @@ const MAX_ANCHOR_PROBE_DEPTH: usize = 100; /// recorded root it returns the retryable /// [`PlatformWalletError::ShieldedNoRecordedAnchor`] rather than broadcasting a /// spend Platform is guaranteed to reject. -async fn extract_spends_and_anchor( - sdk: &Arc, - store: &Arc>, - notes: &[ShieldedNote], -) -> Result<(Vec, Anchor), PlatformWalletError> { - extract_spends_and_recorded_anchor(sdk, store, notes) - .await - .map(|(spends, anchor, _)| (spends, anchor)) -} - -/// [`extract_spends_and_anchor`], with the proved height of the recorded -/// anchor set the anchor was chosen from: the anchor was recorded at that -/// height. +/// +/// Returns the proved height of the recorded anchor set the anchor was chosen +/// from too: the anchor was in the set at that height, so a set proved at or +/// above it that lacks the anchor shows it pruned rather than newer. async fn extract_spends_and_recorded_anchor( sdk: &Arc, store: &Arc>, @@ -3513,8 +3566,9 @@ async fn finalize_pending( /// (`NoteReservation::cancel`) removes the entry, so only an ambiguous /// unconfirmed outcome leaves it carrying the anchor. /// -/// Returns whether every reservation now carries the anchor, so a pruned -/// anchor releases each. A store write that fails stops the spend before it +/// Returns whether every reservation now carries the anchor, with +/// `anchor_height`, the proved height of the anchor set it was read from, so +/// an anchor proven pruned releases each. A store write that fails stops the spend before it /// is broadcast ([`PlatformWalletError::ShieldedStoreError`]): the caller's /// reservation guard still owns the notes, so they are released, rather /// than a spend that may execute leaving reservations no pruned anchor @@ -3523,6 +3577,7 @@ async fn arm_pending_release( store: &Arc>, id: SubwalletId, anchor: [u8; 32], + anchor_height: u64, pending_entry: &Option, notes: &[ShieldedNote], ) -> Result { @@ -3532,7 +3587,7 @@ async fn arm_pending_release( let mut store = store.write().await; for note in notes { store - .set_pending_spend(id, ¬e.nullifier, anchor, entry.id) + .set_pending_spend(id, ¬e.nullifier, anchor, entry.id, Some(anchor_height)) .map_err(|e| { PlatformWalletError::ShieldedStoreError(format!( "the spend's note reservation could not carry its anchor, so it is not \ @@ -3563,6 +3618,7 @@ async fn broadcast_shielded_spend_with_redrive( id: SubwalletId, pending_entry: &Option, anchor: [u8; 32], + anchor_height: u64, notes: &[ShieldedNote], state_transition: &StateTransition, operation: &'static str, @@ -3574,6 +3630,7 @@ async fn broadcast_shielded_spend_with_redrive( id, pending_entry, anchor, + anchor_height, notes, state_transition, operation, @@ -3590,6 +3647,7 @@ async fn broadcast_shielded_spend_with_redrive_with_metadata( id: SubwalletId, pending_entry: &Option, anchor: [u8; 32], + anchor_height: u64, notes: &[ShieldedNote], state_transition: &StateTransition, operation: &'static str, @@ -3605,6 +3663,7 @@ async fn broadcast_shielded_spend_with_redrive_with_metadata( id, pending_entry, anchor, + anchor_height, notes, state_transition, operation, @@ -3677,6 +3736,7 @@ pub(crate) async fn arm_redrive_before_broadcast( attempts: 0, identity_nonce_finalized: false, identity_user_abandoned: false, + anchor_height: Some(anchor_recorded_at), }; let mut store = store.write().await; match journal { @@ -3763,11 +3823,13 @@ pub(crate) fn private_document_commitments( /// a failure here only demotes the resolution path to the anchor-prune /// backstop (plus restart-loss of the reservation), never fails the /// spend call itself — the ambiguity already happened. +#[allow(clippy::too_many_arguments)] async fn arm_redrive_record( store: &Arc>, id: SubwalletId, pending_entry: &Option, anchor: [u8; 32], + anchor_height: u64, notes: &[ShieldedNote], state_transition: &StateTransition, operation: &'static str, @@ -3797,6 +3859,7 @@ async fn arm_redrive_record( attempts: 0, identity_nonce_finalized: false, identity_user_abandoned: false, + anchor_height: Some(anchor_height), }; if let Err(e) = store.write().await.arm_redrive(id, redrive) { warn!( @@ -4513,6 +4576,7 @@ mod redrive_tests { attempts: 0, identity_nonce_finalized: false, identity_user_abandoned: true, + anchor_height: None, }; store.arm_redrive(id, archived.clone()).unwrap(); assert!(!has_unresolved_identity_shield(&store, wallet_id, identity_id).unwrap()); @@ -4563,6 +4627,7 @@ mod redrive_tests { attempts: 0, identity_nonce_finalized: false, identity_user_abandoned: false, + anchor_height: None, }, ) .unwrap(); @@ -4635,6 +4700,7 @@ mod redrive_tests { attempts: 0, identity_nonce_finalized: false, identity_user_abandoned: false, + anchor_height: None, }, ) .expect("redrive should arm"); @@ -4663,6 +4729,7 @@ mod redrive_tests { attempts: 0, identity_nonce_finalized: false, identity_user_abandoned: false, + anchor_height: None, }, ) .expect("redrive should arm"); @@ -4774,6 +4841,7 @@ mod redrive_tests { attempts: MAX_REDRIVE_ATTEMPTS, identity_nonce_finalized: false, identity_user_abandoned: false, + anchor_height: None, }, ) .expect("arm identity guard"); @@ -5148,6 +5216,7 @@ mod redrive_tests { attempts: 0, identity_nonce_finalized: false, identity_user_abandoned: false, + anchor_height: None, }, payment, &[[0xC4; 32]], @@ -5810,6 +5879,7 @@ mod redrive_tests { attempts: 0, identity_nonce_finalized: false, identity_user_abandoned: false, + anchor_height: None, }, ) .expect("arm identity guard"); @@ -5846,6 +5916,7 @@ mod redrive_tests { attempts, identity_nonce_finalized: false, identity_user_abandoned: false, + anchor_height: None, }; { let mut guard = store.write().await; @@ -5905,6 +5976,7 @@ mod redrive_tests { attempts: 0, identity_nonce_finalized: false, identity_user_abandoned: false, + anchor_height: None, }; { let mut guard = store.write().await; diff --git a/packages/rs-platform-wallet/src/wallet/shielded/store.rs b/packages/rs-platform-wallet/src/wallet/shielded/store.rs index 54b6fa7fda5..fc2f906faf9 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/store.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/store.rs @@ -127,11 +127,12 @@ pub struct ShieldedOutgoingNote { } /// A pending reservation that carries a recorded anchor: -/// `(nullifier, anchor, activity_id)`. Yielded by -/// [`ShieldedStore::stale_pending_spends`] for the sync reconcile, -/// which releases the reservation once `anchor` is no longer in -/// Platform's recorded set (the spend can then never execute). -pub type StalePendingSpend = ([u8; 32], [u8; 32], Option<[u8; 32]>); +/// `(nullifier, anchor, activity_id, anchor_height)`, `anchor_height` being a +/// proved height at which `anchor` was in Platform's recorded set, when known. +/// Yielded by [`ShieldedStore::stale_pending_spends`] for the sync +/// reconcile, which releases the reservation once `anchor` is proven pruned +/// (the spend can then never execute). +pub type StalePendingSpend = ([u8; 32], [u8; 32], Option<[u8; 32]>, Option); /// An unresolved signed transition and its durable retry guard: the signed /// transition bytes plus everything the sync-time re-drive needs to @@ -162,6 +163,14 @@ pub struct PendingRedrive { pub st_bytes: Vec, /// Re-broadcast attempts made so far. pub attempts: u32, + /// A proved height at which [`Self::anchor`] was in Platform's recorded + /// anchor set: the height of the set the spend's anchor was read from, or + /// of a later set seen holding it + /// ([`ShieldedStore::note_anchor_recorded`]). A set proved at or above it + /// that lacks the anchor shows it pruned rather than newer. `None` for a + /// record saved without it, whose anchor is not proven pruned until a set + /// is seen holding it. + pub anchor_height: Option, /// A proven identity nonce has made this exact debit impossible to execute /// again. Stop broadcasting, but keep its unresolved-payment guard until /// the scan confirms the outputs. Always false for note-spend redrives. @@ -444,23 +453,37 @@ pub trait ShieldedStore: Send + Sync + 'static { reservation: u64, ) -> Result; - /// Attach the recorded `anchor` the spend was built against, and - /// the linked `activity_id`, to `id`'s pending reservation for - /// `nullifier` (taken earlier by [`Self::mark_pending`]). No-op - /// if the nullifier is no longer pending. The sync reconcile uses - /// the stored anchor to detect a stranded spend whose anchor - /// Platform has pruned (see [`Self::stale_pending_spends`]). + /// Attach the recorded `anchor` the spend was built against, the proved + /// height of the anchor set it was read from (`anchor_height`), and the + /// linked `activity_id`, to `id`'s pending reservation for `nullifier` + /// (taken earlier by [`Self::mark_pending`]). No-op if the nullifier is + /// no longer pending. The sync reconcile uses the stored anchor and its + /// height to detect a stranded spend whose anchor Platform has pruned + /// (see [`Self::stale_pending_spends`]). fn set_pending_spend( &mut self, id: SubwalletId, nullifier: &[u8; 32], anchor: [u8; 32], activity_id: [u8; 32], + anchor_height: Option, + ) -> Result<(), Self::Error>; + + /// Note that `anchor` was in Platform's recorded set proved at `height`: + /// each of `id`'s reservations built on it and each of its records that + /// carry no anchor height get `height`, a record's durably. A set proved + /// at or above it that lacks the anchor then shows it pruned. One that + /// already carries a height keeps it. + fn note_anchor_recorded( + &mut self, + id: SubwalletId, + anchor: &[u8; 32], + height: u64, ) -> Result<(), Self::Error>; /// Pending reservations for `id` that carry a recorded anchor — - /// `(nullifier, anchor, activity_id)` per built-but-unconfirmed - /// spend. Empty when `id` has no anchored reservations (the common + /// `(nullifier, anchor, activity_id, anchor_height)` per + /// built-but-unconfirmed spend. Empty when `id` has no anchored reservations (the common /// case, which lets the sync reconcile skip its network round-trip). /// The reconcile checks each anchor against Platform's recorded set /// and releases the reservation via [`Self::clear_pending`] when the @@ -818,6 +841,9 @@ pub(super) struct PendingSpend { /// while it still holds it. `None` for a reservation restored from a /// redrive record, which only a resolution of its spend clears. pub reservation: Option, + /// A proved height at which `anchor` was in Platform's recorded set + /// ([`PendingRedrive::anchor_height`]), when known. + pub anchor_height: Option, } /// Per-subwallet note + sync state used by both the in-memory and @@ -1007,11 +1033,43 @@ impl SubwalletState { nullifier: &[u8; 32], anchor: [u8; 32], activity_id: [u8; 32], + anchor_height: Option, ) { if let Some(pending) = self.pending_nullifiers.get_mut(nullifier) { pending.anchor = Some(anchor); pending.activity_id = Some(activity_id); + pending.anchor_height = anchor_height; + } + } + + /// Give `height` to each reservation built on `anchor`, and each record + /// on it, that carries no anchor height + /// ([`ShieldedStore::note_anchor_recorded`]). Returns the activity ids of + /// the records given it. + pub(super) fn note_anchor_recorded(&mut self, anchor: &[u8; 32], height: u64) -> Vec<[u8; 32]> { + for pending in self.pending_nullifiers.values_mut() { + if pending.anchor == Some(*anchor) && pending.anchor_height.is_none() { + pending.anchor_height = Some(height); + } + } + let mut noted = Vec::new(); + for redrive in self.redrives.values_mut() { + if redrive.anchor == *anchor && redrive.anchor_height.is_none() { + redrive.anchor_height = Some(height); + noted.push(redrive.activity_id); + } } + noted + } + + /// The activity ids of the records built on `anchor` that carry no anchor + /// height. + pub(super) fn redrives_without_anchor_height(&self, anchor: &[u8; 32]) -> Vec<[u8; 32]> { + self.redrives + .values() + .filter(|redrive| redrive.anchor == *anchor && redrive.anchor_height.is_none()) + .map(|redrive| redrive.activity_id) + .collect() } /// Reservations that carry a recorded anchor — i.e. built, @@ -1026,7 +1084,7 @@ impl SubwalletState { .filter_map(|(nullifier, spend)| { spend .anchor - .map(|anchor| (*nullifier, anchor, spend.activity_id)) + .map(|anchor| (*nullifier, anchor, spend.activity_id, spend.anchor_height)) }) .collect() } @@ -1270,9 +1328,22 @@ impl ShieldedStore for InMemoryShieldedStore { nullifier: &[u8; 32], anchor: [u8; 32], activity_id: [u8; 32], + anchor_height: Option, + ) -> Result<(), Self::Error> { + if let Some(sw) = self.subwallets.get_mut(&id) { + sw.set_pending_spend(nullifier, anchor, activity_id, anchor_height); + } + Ok(()) + } + + fn note_anchor_recorded( + &mut self, + id: SubwalletId, + anchor: &[u8; 32], + height: u64, ) -> Result<(), Self::Error> { if let Some(sw) = self.subwallets.get_mut(&id) { - sw.set_pending_spend(nullifier, anchor, activity_id); + sw.note_anchor_recorded(anchor, height); } Ok(()) } @@ -1840,6 +1911,7 @@ mod tests { attempts: 0, identity_nonce_finalized: false, identity_user_abandoned: false, + anchor_height: None, }; // Landing path: mark_spent on one nullifier drops the record. @@ -2021,18 +2093,18 @@ mod tests { "newly reserved" ); store - .set_pending_spend(id, &nullifier, anchor, activity_id) + .set_pending_spend(id, &nullifier, anchor, activity_id, Some(10)) .unwrap(); assert_eq!( store.stale_pending_spends(id).unwrap(), - vec![(nullifier, anchor, Some(activity_id))], + vec![(nullifier, anchor, Some(activity_id), Some(10))], "an armed reservation surfaces as an anchored (stale-checkable) spend" ); // Anchor IS still recorded → retain. let recorded: HashSet<[u8; 32]> = [anchor].into_iter().collect(); - for (n, a, _) in store.stale_pending_spends(id).unwrap() { + for (n, a, _, _) in store.stale_pending_spends(id).unwrap() { if !recorded.contains(&a) { store.clear_pending(id, &n).unwrap(); } @@ -2066,13 +2138,13 @@ mod tests { .unwrap(); store.mark_pending(id, &nullifier).unwrap(); store - .set_pending_spend(id, &nullifier, anchor, activity_id) + .set_pending_spend(id, &nullifier, anchor, activity_id, Some(10)) .unwrap(); assert!(store.get_unspent_notes(id).unwrap().is_empty()); // Anchor is ABSENT from the recorded set (pruned) → release. let recorded: HashSet<[u8; 32]> = [[0xEE; 32]].into_iter().collect(); - for (n, a, _) in store.stale_pending_spends(id).unwrap() { + for (n, a, _, _) in store.stale_pending_spends(id).unwrap() { if !recorded.contains(&a) { assert!(store.clear_pending(id, &n).unwrap()); } @@ -2145,6 +2217,7 @@ mod tests { attempts: 7, identity_nonce_finalized: false, identity_user_abandoned: false, + anchor_height: None, }; let note_spend = PendingRedrive { activity_id: [0x44; 32], @@ -2154,6 +2227,7 @@ mod tests { attempts: 1, identity_nonce_finalized: false, identity_user_abandoned: false, + anchor_height: None, }; store.arm_redrive(id, identity_guard.clone()).unwrap(); store.arm_redrive(id, note_spend).unwrap(); diff --git a/packages/rs-platform-wallet/src/wallet/shielded/viewing_key_bind_tests.rs b/packages/rs-platform-wallet/src/wallet/shielded/viewing_key_bind_tests.rs index fb1d107e6fa..dd0c190a281 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/viewing_key_bind_tests.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/viewing_key_bind_tests.rs @@ -528,6 +528,7 @@ async fn bind_persists_viewing_keys_and_restart_rebinds_seedlessly() { attempts: 0, identity_nonce_finalized: false, identity_user_abandoned: false, + anchor_height: None, }, ) .expect("persist pending reservation"); @@ -1080,6 +1081,7 @@ async fn should_keep_wallet_and_coordinator_keys_when_guarded_registration_is_re attempts: 0, identity_nonce_finalized: false, identity_user_abandoned: false, + anchor_height: None, }, ) .unwrap();