From 5fa471483ac9605bb04524a73d6f2a11c586ca88 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Sun, 8 Nov 2026 10:35:59 +0700 Subject: [PATCH 001/269] 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/269] 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/269] 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/269] 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 67355754ad90c05d509d95f42d49948ae38558c1 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Sun, 8 Nov 2026 12:07:00 +0700 Subject: [PATCH 005/269] feat(rs-sdk-ffi): scan, read and seal private contact requests in the resolver signer `MnemonicResolverCoreSigner` gains the three key operations DashPay private contact requests (protocol version 15) need, in the style of `contact_info_seal` and `ecdh_shared_secret`: each derives the keys it needs at Rust-built paths, wipes its copies, and returns only the result. - `scan_private_document_page` sorts a page of a private document store with the recipients' secret keys and the senders' outgoing viewing keys. - `read_private_contact_requests` reads a scan's requests, checking each sender with the recipient key that opened it. Only recipient keys are derived. - `seal_private_contact_request` seals a request with the sender's encryption key. A scan or a read derives every key from one resolve of the mnemonic, not one Keychain read per key: `resolve_and_derive` is now a one-path case of the new `resolve_master`. The SDK's refusals come back as the new `MnemonicResolverSignerError::PrivateContactRequest`, keeping the `dash_sdk::Error`. Tests pin the signer to a resident wallet of the same seed: the sealed entry is byte for byte the one the SDK seals with the seed-derived key, and a page scans and reads exactly as with seed-derived scan keys. Co-Authored-By: Claude Opus 5.5 --- .../src/mnemonic_resolver_core_signer.rs | 664 +++++++++++++++++- 1 file changed, 642 insertions(+), 22 deletions(-) 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..5780948b9fa 100644 --- a/packages/rs-sdk-ffi/src/mnemonic_resolver_core_signer.rs +++ b/packages/rs-sdk-ffi/src/mnemonic_resolver_core_signer.rs @@ -56,11 +56,24 @@ //! Combined, no private key bytes survive past the trait-method //! boundary. +use std::collections::BTreeMap; 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::{ + private_contact_request_outgoing_viewing_key, private_contact_requests_from_scan, + seal_private_contact_request, PrivateContactRequest, PrivateContactRequestScan, + 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 +83,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 +180,12 @@ pub enum MnemonicResolverSignerError { /// Decoding the generated recoverable signature failed. #[error("ECDSA signature conversion failed: {0}")] SignatureConversion(#[source] EcdsaError), + + /// The SDK refused to read or seal a private contact request with the + /// derived keys: a malformed request, a key the identity does not hold, + /// 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 +262,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 +351,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 +617,137 @@ 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 path of an encryption key whose outgoing + /// viewing key recovers what it sent. Every key comes from one resolve + /// and is dropped here; only the sorted entries return. + pub fn scan_private_document_page( + &self, + recipients: &[(Identifier, Vec<(KeyID, DerivationPath)>)], + senders: &[(Identifier, 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| { + let keys = private_document_scan_keys(master, recipients, senders)?; + Ok(keys.scan_page(&target, start_position, page)) + }) + } + + /// Read the private contact requests of `scan`, a scan of DashPay's + /// store made with the keys at `recipients` + /// ([`private_contact_requests_from_scan`]): each received request's + /// sender is checked with the recipient key that opened it, against the + /// key `sender_identities` holds under the id it names. Only the + /// recipient keys are derived, from one resolve, and dropped here. + pub fn read_private_contact_requests( + &self, + recipients: &[(Identifier, Vec<(KeyID, DerivationPath)>)], + dashpay_contract: &DataContract, + scan: PrivateDocumentScan, + sender_identities: &BTreeMap>, + platform_version: &PlatformVersion, + ) -> Result { + self.resolve_master(|master| { + let keys = private_document_scan_keys(master, recipients, &[])?; + private_contact_requests_from_scan( + dashpay_contract, + scan, + &keys, + sender_identities, + platform_version, + ) + .map_err(MnemonicResolverSignerError::PrivateContactRequest) + }) + } + + /// 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` + /// ([`seal_private_contact_request`]). `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, + recipient: &Identity, + recipient_key_id: KeyID, + request: &PrivateContactRequest, + esk_seed: &[u8; 32], + ) -> 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, + }, + recipient, + recipient_key_id, + request, + esk_seed, + ) + .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)) +} + +/// The scan keys of `recipients` and `senders`, derived below `master`: each +/// recipient's secret keys, and each sender's outgoing viewing key +/// ([`private_contact_request_outgoing_viewing_key`]). The copies made here +/// are wiped before this returns. +fn private_document_scan_keys( + master: &ExtendedPrivKey, + recipients: &[(Identifier, Vec<(KeyID, DerivationPath)>)], + senders: &[(Identifier, DerivationPath)], +) -> Result { + let mut keys = PrivateDocumentScanKeys::new(); + for (identity_id, paths) in recipients { + let mut secret_keys = WipingSecretKeys(Vec::with_capacity(paths.len())); + for (key_id, path) in paths { + secret_keys + .0 + .push((*key_id, secret_key_at(master, path)?.0)); + } + keys = keys.with_recipient(*identity_id, &secret_keys.0); + } + for (identity_id, path) in senders { + let secret = secret_key_at(master, path)?; + let outgoing_viewing_key = + Zeroizing::new(private_contact_request_outgoing_viewing_key(&secret.0)); + keys = keys.with_sender(*identity_id, *outgoing_viewing_key); + } + Ok(keys) } /// Result of [`MnemonicResolverCoreSigner::contact_info_seal`]. @@ -610,6 +775,17 @@ impl Drop for WipingSecretKey { } } +/// Scrubs the secret keys of one scan recipient on every exit path. +struct WipingSecretKeys(Vec<(KeyID, secp256k1::SecretKey)>); + +impl Drop for WipingSecretKeys { + fn drop(&mut self) { + for (_, secret_key) in &mut self.0 { + secret_key.non_secure_erase(); + } + } +} + #[async_trait] impl Signer for MnemonicResolverCoreSigner { type Error = MnemonicResolverSignerError; @@ -1226,4 +1402,448 @@ 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::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::platform::dashpay::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 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]; + 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 outsider_secret_key() -> secp256k1::SecretKey { + secp256k1::SecretKey::from_secret_bytes([0x41; 32]).expect("a valid scalar") + } + + fn key( + id: KeyID, + purpose: Purpose, + secret_key: &secp256k1::SecretKey, + ) -> IdentityPublicKey { + IdentityPublicKey::V0(IdentityPublicKeyV0 { + id, + purpose, + security_level: SecurityLevel::MEDIUM, + 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, + }) + } + + fn identity( + id: [u8; 32], + key_id: KeyID, + purpose: Purpose, + secret_key: &secp256k1::SecretKey, + ) -> Identity { + let key = key(key_id, purpose, secret_key); + Identity::V0(IdentityV0 { + id: Identifier::from(id), + public_keys: BTreeMap::from([(key_id, key)]), + balance: 0, + revision: 0, + }) + } + + fn sender() -> Identity { + identity( + SENDER_ID, + SENDER_KEY_ID, + Purpose::ENCRYPTION, + &seed_secret_key(&sender_key_path()), + ) + } + + fn recipient() -> Identity { + identity( + RECIPIENT_ID, + RECIPIENT_KEY_ID, + Purpose::DECRYPTION, + &seed_secret_key(&recipient_key_path()), + ) + } + + fn outsider() -> Identity { + identity( + OUTSIDER_ID, + SENDER_KEY_ID, + Purpose::ENCRYPTION, + &outsider_secret_key(), + ) + } + + fn carol() -> Identity { + identity( + CAROL_ID, + RECIPIENT_KEY_ID, + Purpose::DECRYPTION, + &secp256k1::SecretKey::from_secret_bytes([0x42; 32]).expect("a valid scalar"), + ) + } + + 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]), + } + } + + /// `request` sealed by `sender` with `secret_key`, the key a resident + /// wallet holds, to the decryption key of `recipient`. + fn seed_sealed( + contract: &DataContract, + sender: &Identity, + secret_key: &secp256k1::SecretKey, + 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, + }, + recipient, + RECIPIENT_KEY_ID, + &request(), + &[esk_seed; 32], + ) + .expect("the request seals") + } + + /// The test entry: the sender's request to the recipient. + fn sent_to_recipient(contract: &DataContract, esk_seed: u8) -> BinaryData { + seed_sealed( + contract, + &sender(), + &seed_secret_key(&sender_key_path()), + &recipient(), + 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, DerivationPath)> { + vec![(Identifier::from(SENDER_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), + 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 is byte for byte the one a resident + /// wallet seals with the key at the same path, so a request sent + /// through the Keychain opens and authenticates for every client. + #[test] + fn should_seal_the_entry_a_seed_derived_sender_key_seals() { + let contract = dashpay(); + 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, + &recipient(), + RECIPIENT_KEY_ID, + &request(), + &[7; 32], + ) + .expect("the signer seals the request"); + assert_eq!(sealed, sent_to_recipient(&contract, 7)); + + unsafe { dash_sdk_mnemonic_resolver_destroy(resolver) }; + } + + /// A page scanned and read through the signer sorts and reads as with + /// a resident wallet's scan keys: the request to the recipient, with + /// its sender checked, and the same request recovered as sent by the + /// sender's outgoing viewing key, while another wallet's entry is + /// passed over. + #[test] + fn should_scan_and_read_a_page_as_seed_derived_scan_keys_do() { + let contract = dashpay(); + let target = target(&contract); + let page = page(vec![ + seed_sealed(&contract, &outsider(), &outsider_secret_key(), &carol(), 1), + sent_to_recipient(&contract, 2), + ]); + 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"); + let seed_keys = seed_scan_keys(); + let expected_scan = seed_keys.scan_page(&target, 0, &page); + assert_eq!(scan, expected_scan); + 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 sender_identities = BTreeMap::from([(Identifier::from(SENDER_ID), Some(sender()))]); + let read = signer + .read_private_contact_requests( + &recipient_paths(), + &contract, + scan, + &sender_identities, + PlatformVersion::latest(), + ) + .expect("the signer reads the requests"); + let expected_read = private_contact_requests_from_scan( + &contract, + expected_scan, + &seed_keys, + &sender_identities, + PlatformVersion::latest(), + ) + .expect("the resident wallet reads the requests"); + assert_eq!(read, expected_read); + 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].recipient_id, recipient().id()); + assert!(read.rejected.is_empty()); + + unsafe { dash_sdk_mnemonic_resolver_destroy(resolver) }; + } + + /// Every key a scan or a read needs comes from one Keychain read, not + /// one per key. + #[test] + fn should_resolve_the_mnemonic_once_per_scan_and_once_per_read() { + let contract = dashpay(); + let page = page(vec![sent_to_recipient(&contract, 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) }; + // 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"), + ), + ], + )]; + + let scan = 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); + + let sender_identities = BTreeMap::from([(Identifier::from(SENDER_ID), Some(sender()))]); + signer + .read_private_contact_requests( + &recipients, + &contract, + scan, + &sender_identities, + PlatformVersion::latest(), + ) + .expect("the signer reads the requests"); + 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. + #[test] + fn should_refuse_to_seal_with_a_key_the_sender_does_not_hold() { + let contract = dashpay(); + 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, + &recipient(), + RECIPIENT_KEY_ID, + &request(), + &[7; 32], + ) + .expect_err("the key at that path is not the sender's"); + assert!( + matches!(err, MnemonicResolverSignerError::PrivateContactRequest(_)), + "expected the SDK's refusal, got: {err:?}" + ); + + 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 65ebad234511261da44bb215d6702ef0e55b94ee Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Sun, 8 Nov 2026 12:07:00 +0700 Subject: [PATCH 006/269] feat(platform-wallet): expose DashPay private contact requests over the FFI The resolver-backed `ContactCryptoProvider` implements the three private contact request methods by delegating to `MnemonicResolverCoreSigner`, so the keys stay in the signer. New C exports: - `platform_wallet_sync_private_contact_requests(wallet_handle, core_signer_handle, max_pages, out_requests, out_report)` runs `DashPayView::sync_private_contact_requests` and returns the requests it ingested with a `PrivateContactSyncReportFFI`. The wallet is looked up under the registry guard and the pass runs outside it. - `platform_wallet_manager_send_private_contact_request` and `platform_wallet_manager_accept_private_contact_request` (`shielded` feature) pay from an account's shielded notes, with the shielded spend contract: panics and results go through `catch_spend_panic` and `map_spend_result`, as the other note-spending exports do. - `managed_identity_get_private_contact` reads `PrivateContact`: whether requests came or went privately, and the contact's shielded address. `PrivateContactRequestNeedsPrivateReply` maps to the new result code `ErrorPrivateContactRequestNeedsPrivateReply` (68), so a host routes a privately received request to the private accept. 61 to 67 are already held by open branches; the registry records the claim and those holds. The auto-accept proof copy moved into `read_auto_accept_proof`, shared by the public and private sends. Co-Authored-By: Claude Opus 5.5 --- .../ERROR_CODE_REGISTRY.md | 9 +- .../rs-platform-wallet-ffi/src/dashpay.rs | 137 +++- packages/rs-platform-wallet-ffi/src/error.rs | 31 + packages/rs-platform-wallet-ffi/src/lib.rs | 2 + .../src/private_contact_request.rs | 587 ++++++++++++++++++ .../src/shielded_send.rs | 6 +- 6 files changed, 748 insertions(+), 24 deletions(-) create mode 100644 packages/rs-platform-wallet-ffi/src/private_contact_request.rs diff --git a/packages/rs-platform-wallet-ffi/ERROR_CODE_REGISTRY.md b/packages/rs-platform-wallet-ffi/ERROR_CODE_REGISTRY.md index 211a7d709ca..1909407eccf 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: 69**: 27–68 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 +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,7 @@ 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 public accept refused a contact request that came privately (protocol version 15): a `contactRequest` document in reply would publish the relationship. Nothing was sent; the host accepts it with `platform_wallet_manager_accept_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 | **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/dashpay.rs b/packages/rs-platform-wallet-ffi/src/dashpay.rs index 85dde7ca6ea..b7ed4f9fb4a 100644 --- a/packages/rs-platform-wallet-ffi/src/dashpay.rs +++ b/packages/rs-platform-wallet-ffi/src/dashpay.rs @@ -32,11 +32,22 @@ //! established contact via their `DashpayExternalAccount`. Runs //! the Core transaction path internally. +use std::collections::BTreeMap; 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, PrivateContactRequestScan}; +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 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; @@ -179,7 +190,7 @@ impl ContactRequestHandleArray { } /// Copy a slice of contact requests into the global handle storage. - fn from_requests(requests: Vec) -> Self { + pub(crate) fn from_requests(requests: Vec) -> Self { if requests.is_empty() { return Self::empty(); } @@ -314,22 +325,9 @@ pub unsafe extern "C" fn platform_wallet_send_contact_request_with_signer( } else { Some(unwrap_result_or_return!(CStr::from_ptr(account_label).to_str()).to_string()) }; - let proof: Option> = if auto_accept_proof.is_null() || auto_accept_proof_len == 0 { - None - } else { - // Defense-in-depth: bound the copy at the DIP-15 auto-accept proof - // ceiling (102 bytes) BEFORE allocating, so a malformed binding or a - // hostile (ptr, len) pair can't force an oversized allocation. The SDK - // re-validates the exact 38..=102 range after this. - if auto_accept_proof_len > 102 { - return crate::error::PlatformWalletFFIResult::err( - crate::error::PlatformWalletFFIResultCode::ErrorInvalidParameter, - format!( - "auto_accept_proof length {auto_accept_proof_len} exceeds the DIP-15 maximum of 102 bytes" - ), - ); - } - Some(std::slice::from_raw_parts(auto_accept_proof, auto_accept_proof_len).to_vec()) + let proof = match read_auto_accept_proof(auto_accept_proof, auto_accept_proof_len) { + Ok(proof) => proof, + Err(result) => return result, }; let signer_addr = signer_handle as usize; @@ -369,6 +367,31 @@ pub unsafe extern "C" fn platform_wallet_send_contact_request_with_signer( PlatformWalletFFIResult::ok() } +/// Copy an optional auto-accept proof from `(ptr, len)`; `(null, 0)` omits it. +/// +/// Defense-in-depth: the copy is bounded at the DIP-15 auto-accept proof +/// ceiling (102 bytes) BEFORE allocating, so a malformed binding or a hostile +/// (ptr, len) pair can't force an oversized allocation. The SDK re-validates +/// the exact 38..=102 range after this. +/// +/// # Safety +/// A non-null `ptr` must point to `len` readable bytes. +pub(crate) unsafe fn read_auto_accept_proof( + ptr: *const u8, + len: usize, +) -> Result>, PlatformWalletFFIResult> { + if ptr.is_null() || len == 0 { + return Ok(None); + } + if len > 102 { + return Err(PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorInvalidParameter, + format!("auto_accept_proof length {len} exceeds the DIP-15 maximum of 102 bytes"), + )); + } + Ok(Some(std::slice::from_raw_parts(ptr, len).to_vec())) +} + /// Send a contact request from a scanned DIP-15 auto-accept QR /// (`dash:?du=&dapk=`). Resolves the QR's username to the /// owner's identity, decodes the handed auto-accept key, signs the proof over @@ -1222,6 +1245,82 @@ 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 read_private_contact_requests( + &self, + keys: &PrivateDocumentKeyPaths, + dashpay_contract: &DataContract, + scan: PrivateDocumentScan, + sender_identities: &BTreeMap>, + platform_version: &PlatformVersion, + ) -> Result { + self.signer + .read_private_contact_requests( + &keys.recipients, + dashpay_contract, + scan, + sender_identities, + platform_version, + ) + .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, + recipient: &Identity, + recipient_key_id: KeyID, + request: &PrivateContactRequest, + esk_seed: &[u8; 32], + ) -> Result { + self.signer + .seal_private_contact_request( + sender_encryption_key_path, + dashpay_contract, + sender, + sender_encryption_key_id, + recipient, + recipient_key_id, + request, + esk_seed, + ) + .map_err(private_document_signer_error) + } +} + +/// A signer failure of a private document operation as a wallet error: the +/// SDK's refusal stays typed, 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 => PlatformWalletError::InvalidIdentityData(error.to_string()), + } } /// Drain the persisted deferred-crypto queue using the Keychain signer for the diff --git a/packages/rs-platform-wallet-ffi/src/error.rs b/packages/rs-platform-wallet-ffi/src/error.rs index 762a8e35668..75958a5441a 100644 --- a/packages/rs-platform-wallet-ffi/src/error.rs +++ b/packages/rs-platform-wallet-ffi/src/error.rs @@ -622,6 +622,14 @@ pub enum PlatformWalletFFIResultCode { /// coins lets either lock fit. ErrorAssetLockTooManyInputs = 60, + /// Maps `PlatformWalletError::PrivateContactRequestNeedsPrivateReply`: + /// the contact request came privately (protocol version 15), so a public + /// `contactRequest` reply would publish the relationship. Nothing was + /// sent. Accept it with + /// `platform_wallet_manager_accept_private_contact_request` instead. + /// 61 to 67 are claimed by open branches, so this takes 68. + ErrorPrivateContactRequestNeedsPrivateReply = 68, + /// The named thing does not exist. /// /// Originally (and still mostly) the code for every `Option` returned as an @@ -1059,6 +1067,11 @@ impl From for PlatformWalletFFIResult { PlatformWalletError::SpvProcessRestartRequired(..) => { PlatformWalletFFIResultCode::ErrorSpvProcessRestartRequired } + // The public accept refused a request that came privately; the + // host routes it to the private accept. + PlatformWalletError::PrivateContactRequestNeedsPrivateReply(..) => { + PlatformWalletFFIResultCode::ErrorPrivateContactRequestNeedsPrivateReply + } // 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 @@ -2692,3 +2705,21 @@ mod identity_balance_error_tests { assert!(!result.message.is_null()); } } + +#[cfg(test)] +mod private_contact_request_error_tests { + use super::*; + + #[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()); + } +} diff --git a/packages/rs-platform-wallet-ffi/src/lib.rs b/packages/rs-platform-wallet-ffi/src/lib.rs index 4734b414279..2e50b07c8e7 100644 --- a/packages/rs-platform-wallet-ffi/src/lib.rs +++ b/packages/rs-platform-wallet-ffi/src/lib.rs @@ -71,6 +71,7 @@ pub mod platform_address_sync; pub mod platform_address_types; pub mod platform_addresses; pub mod platform_wallet_info; +pub mod private_contact_request; pub mod provider_key_at_index; mod runtime; pub mod secp256k1_primitives; @@ -156,6 +157,7 @@ pub use platform_address_sync::*; pub use platform_address_types::*; pub use platform_addresses::*; pub use platform_wallet_info::*; +pub use private_contact_request::*; pub use provider_key_at_index::*; pub use secp256k1_primitives::*; #[cfg(feature = "shielded")] diff --git a/packages/rs-platform-wallet-ffi/src/private_contact_request.rs b/packages/rs-platform-wallet-ffi/src/private_contact_request.rs new file mode 100644 index 00000000000..3adb8a69ca0 --- /dev/null +++ b/packages/rs-platform-wallet-ffi/src/private_contact_request.rs @@ -0,0 +1,587 @@ +//! FFI bindings for DashPay private contact requests (protocol version 15). +//! +//! A private contact request is an entry of DashPay's `privateContactRequest` +//! store, sealed to one of the recipient's DashPay keys and paid for from the +//! shielded pool, so nothing on chain names either side. The wallet keeps a +//! private request as it keeps a public one, an ordinary +//! [`ContactRequest`](platform_wallet::ContactRequest), so the existing +//! contact-request rows and accessors read it. +//! +//! - [`platform_wallet_sync_private_contact_requests`]: read the store for the +//! wallet's identities and ingest what it holds for them. Every key stays in +//! the resolver-backed signer. +//! - [`platform_wallet_manager_send_private_contact_request`] / +//! [`platform_wallet_manager_accept_private_contact_request`]: send, or +//! accept with a private reply, paying from an account's shielded notes. +//! Built with the `shielded` feature only. +//! - [`managed_identity_get_private_contact`]: what a contact's private +//! requests carry beyond the `ContactRequest`: whether they came or went +//! privately, and the contact's shielded address. +//! +//! A request that came privately is refused by the public accept with +//! `ErrorPrivateContactRequestNeedsPrivateReply`; the host routes it to the +//! private accept. + +use platform_wallet::PrivateContact; +use rs_sdk_ffi::MnemonicResolverHandle; + +use crate::dashpay::{resolver_contact_crypto_provider, ContactRequestHandleArray}; +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}; + +#[cfg(feature = "shielded")] +use std::ffi::CStr; +#[cfg(feature = "shielded")] +use std::os::raw::c_char; + +#[cfg(feature = "shielded")] +use platform_wallet::wallet::shielded::CachedOrchardProver; +#[cfg(feature = "shielded")] +use platform_wallet::AutoAcceptProofSource; + +#[cfg(feature = "shielded")] +use crate::contact_request::CONTACT_REQUEST_STORAGE; +#[cfg(feature = "shielded")] +use crate::dashpay::read_auto_accept_proof; +#[cfg(feature = "shielded")] +use crate::established_contact::ESTABLISHED_CONTACT_STORAGE; +#[cfg(feature = "shielded")] +use crate::identity_keys_from_mnemonic::resolve_seed_from_resolver; +#[cfg(feature = "shielded")] +use crate::shielded_send::{ + catch_spend_panic, map_spend_result, poke_sync_on_unconfirmed, resolve_wallet_and_coordinator, +}; + +/// The bytes of an Orchard payment address: an 11-byte diversifier and a +/// 32-byte transmission key. +const SHIELDED_ADDRESS_LEN: usize = 43; + +/// What a pass over DashPay's private contact request store did +/// (`PrivateContactSyncReport`). Counts above `u32::MAX` saturate. +#[repr(C)] +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct PrivateContactSyncReportFFI { + /// The store position the pass stopped at. + pub next_position: u64, + /// The store pages read. + pub pages_read: u32, + /// Entries that opened for one of the wallet's identities but were + /// refused. + pub rejected_count: u32, + /// Identities whose ingest did not reach disk: their entries are read + /// again by the next pass. + pub unpersisted_identity_count: u32, + /// Whether the pass read to the store's last entry. When false, call + /// again to read on. + pub complete: bool, +} + +fn saturating_count(count: usize) -> u32 { + u32::try_from(count).unwrap_or(u32::MAX) +} + +/// Read DashPay's private contact request store for every identity of the +/// wallet, at most `max_pages` pages from the earliest of their cursors, and +/// ingest what it holds for them: requests to them, each sender checked, and +/// requests they sent, recovered from the store alone. Contacts establish and +/// their DashPay accounts are queued as the document sync does. +/// +/// `core_signer_handle` is the wallet-HD resolver signer: the store is opened +/// and senders are checked with keys derived inside it. The cursors live in +/// memory only, so the first pass of a process reads the store from its +/// start. +/// +/// Writes the requests the pass ingested, received and sent (including ones +/// already held), to `out_requests` (release with +/// `platform_wallet_contact_request_handle_array_free`, each handle with +/// `contact_request_destroy`) and the pass's counters to `out_report`. +/// +/// # Safety +/// - `core_signer_handle` must be a valid, non-destroyed +/// `*mut MnemonicResolverHandle`; the caller pins it for the call. +/// - `out_requests` and `out_report` must be valid, writable pointers. +#[no_mangle] +pub unsafe extern "C" fn platform_wallet_sync_private_contact_requests( + wallet_handle: Handle, + core_signer_handle: *mut MnemonicResolverHandle, + max_pages: u32, + out_requests: *mut ContactRequestHandleArray, + out_report: *mut PrivateContactSyncReportFFI, +) -> PlatformWalletFFIResult { + check_ptr!(core_signer_handle); + check_ptr!(out_requests); + check_ptr!(out_report); + // Publish FFI-safe sentinels before any fallible work, so every early + // return leaves both out-params well-defined. + unsafe { + *out_requests = ContactRequestHandleArray::empty(); + *out_report = PrivateContactSyncReportFFI::default(); + } + + // Look the wallet up under the registry guard, but run the pass outside + // it: a pass reads many pages, and a registry read guard held across + // that wait would stall `platform_wallet_destroy` and, through + // parking_lot's writer preference, every other registry reader. + let option = PLATFORM_WALLET_STORAGE.with_item(wallet_handle, |wallet| { + ( + wallet.identity().clone(), + wallet.wallet_id(), + wallet.network(), + ) + }); + let (identity, wallet_id, network) = unwrap_option_or_return!(option); + // SAFETY: the caller pins the resolver handle for the duration of this + // call. + let provider = + unsafe { resolver_contact_crypto_provider(core_signer_handle, wallet_id, network) }; + let result = block_on_worker(async move { + identity + .dashpay() + .sync_private_contact_requests(&provider, max_pages as usize) + .await + }); + let report = unwrap_result_or_return!(result); + unsafe { + *out_report = PrivateContactSyncReportFFI { + next_position: report.next_position, + pages_read: saturating_count(report.pages_read), + rejected_count: saturating_count(report.rejected.len()), + unpersisted_identity_count: saturating_count(report.unpersisted_identities.len()), + complete: report.complete, + }; + *out_requests = ContactRequestHandleArray::from_requests(report.requests); + } + PlatformWalletFFIResult::ok() +} + +/// What an identity's private contact requests with one contact carry beyond +/// the `ContactRequest` (`PrivateContact`). +#[repr(C)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct PrivateContactFFI { + /// The contact's request came privately: accepting it replies privately. + pub received_privately: bool, + /// This identity's request to the contact went privately. + pub sent_privately: bool, + /// Whether `shielded_address` holds the contact's address. + pub has_shielded_address: bool, + /// The Orchard address the contact asked to be paid at privately, when + /// `has_shielded_address`; zeros otherwise. + pub shielded_address: [u8; 43], +} + +impl PrivateContactFFI { + fn empty() -> Self { + Self { + received_privately: false, + sent_privately: false, + has_shielded_address: false, + shielded_address: [0; SHIELDED_ADDRESS_LEN], + } + } +} + +impl From<&PrivateContact> for PrivateContactFFI { + fn from(contact: &PrivateContact) -> Self { + Self { + received_privately: contact.received_privately, + sent_privately: contact.sent_privately, + has_shielded_address: contact.shielded_address.is_some(), + shielded_address: contact + .shielded_address + .unwrap_or([0; SHIELDED_ADDRESS_LEN]), + } + } +} + +/// Read what this identity's private contact requests with `contact_id` +/// carry into `out_contact`. Returns `NotFound` when no request between them +/// went privately, as far as this process has seen: the record is in memory +/// only and rebuilt by `platform_wallet_sync_private_contact_requests`. +/// +/// # Safety +/// - `contact_id` must point to 32 readable bytes. +/// - `out_contact` must be a valid, writable pointer. +#[no_mangle] +pub unsafe extern "C" fn managed_identity_get_private_contact( + identity_handle: Handle, + contact_id: *const u8, + out_contact: *mut PrivateContactFFI, +) -> PlatformWalletFFIResult { + check_ptr!(out_contact); + unsafe { *out_contact = PrivateContactFFI::empty() }; + let contact_id = unwrap_result_or_return!(unsafe { read_identifier(contact_id) }); + + let option = MANAGED_IDENTITY_STORAGE.with_item(identity_handle, |identity| { + identity + .dashpay() + .private_contact(&contact_id) + .map(PrivateContactFFI::from) + }); + let inner = unwrap_option_or_return!(option); + let contact = unwrap_option_or_return!(inner); + unsafe { *out_contact = contact }; + PlatformWalletFFIResult::ok() +} + +/// Copy an optional Orchard address: null means none. +/// +/// # Safety +/// A non-null `ptr` must point to [`SHIELDED_ADDRESS_LEN`] readable bytes. +#[cfg(feature = "shielded")] +unsafe fn read_shielded_address(ptr: *const u8) -> Option<[u8; SHIELDED_ADDRESS_LEN]> { + if ptr.is_null() { + return None; + } + let mut address = [0u8; SHIELDED_ADDRESS_LEN]; + std::ptr::copy_nonoverlapping(ptr, address.as_mut_ptr(), SHIELDED_ADDRESS_LEN); + Some(address) +} + +/// Send a DashPay private contact request from `sender_identity_id` to +/// `recipient_identity_id`, paying for it from `account`'s shielded notes, so +/// nothing on chain names either identity. The request carries our receiving +/// xpub for the contact, the masked account reference, `account_label`, +/// `auto_accept_proof`, and `shielded_address`, the Orchard address the +/// contact may pay us at privately. It is sealed to the recipient's DashPay +/// key inside the resolver-backed signer. +/// +/// `mnemonic_resolver_handle` supplies both the transient Orchard spend +/// authority and the DashPay keys. Writes a handle into +/// `CONTACT_REQUEST_STORAGE` to `out_request_handle` (release with +/// `contact_request_destroy`). +/// +/// Errors follow the shielded spend contract: `ErrorShieldedSpendUnconfirmed` +/// means the payment may already be on chain; do not send again. +/// +/// # Safety +/// - `wallet_id_bytes`, `sender_identity_id` and `recipient_identity_id` must +/// each point to 32 readable bytes. +/// - `mnemonic_resolver_handle` must be a valid, non-destroyed handle; the +/// caller retains ownership and pins it for the call. +/// - `account_label`, when non-null, must be a valid NUL-terminated UTF-8 C +/// string. +/// - `auto_accept_proof`, when non-null, must point to +/// `auto_accept_proof_len` readable bytes; `(null, 0)` omits it. +/// - `shielded_address`, when non-null, must point to 43 readable bytes; +/// null omits it. +/// - `out_request_handle` must be a valid, writable pointer. +#[cfg(feature = "shielded")] +#[no_mangle] +#[allow(clippy::too_many_arguments)] +pub unsafe extern "C" fn platform_wallet_manager_send_private_contact_request( + handle: Handle, + wallet_id_bytes: *const u8, + mnemonic_resolver_handle: *mut MnemonicResolverHandle, + account: u32, + sender_identity_id: *const u8, + recipient_identity_id: *const u8, + account_label: *const c_char, + auto_accept_proof: *const u8, + auto_accept_proof_len: usize, + shielded_address: *const u8, + out_request_handle: *mut Handle, +) -> PlatformWalletFFIResult { + // A panic in the proving task must not reach this `extern "C"` frame; the + // notes may already be spent when it strikes, so the ambiguous + // spend-unconfirmed contract applies (see `catch_spend_panic`). + catch_spend_panic("private contact request", || { + send_private_contact_request_inner( + handle, + wallet_id_bytes, + mnemonic_resolver_handle, + account, + sender_identity_id, + recipient_identity_id, + account_label, + auto_accept_proof, + auto_accept_proof_len, + shielded_address, + out_request_handle, + ) + }) +} + +/// Body of [`platform_wallet_manager_send_private_contact_request`], as an +/// ordinary Rust function so a panic unwinds into [`catch_spend_panic`]. +/// +/// # Safety +/// Identical contract to the export that calls it. +#[cfg(feature = "shielded")] +#[allow(clippy::too_many_arguments)] +unsafe fn send_private_contact_request_inner( + handle: Handle, + wallet_id_bytes: *const u8, + mnemonic_resolver_handle: *mut MnemonicResolverHandle, + account: u32, + sender_identity_id: *const u8, + recipient_identity_id: *const u8, + account_label: *const c_char, + auto_accept_proof: *const u8, + auto_accept_proof_len: usize, + shielded_address: *const u8, + out_request_handle: *mut Handle, +) -> PlatformWalletFFIResult { + check_ptr!(wallet_id_bytes); + check_ptr!(mnemonic_resolver_handle); + check_ptr!(out_request_handle); + unsafe { *out_request_handle = NULL_HANDLE }; + + let mut wallet_id = [0u8; 32]; + std::ptr::copy_nonoverlapping(wallet_id_bytes, wallet_id.as_mut_ptr(), 32); + let sender = unwrap_result_or_return!(read_identifier(sender_identity_id)); + let recipient = unwrap_result_or_return!(read_identifier(recipient_identity_id)); + let label = if account_label.is_null() { + None + } else { + Some(unwrap_result_or_return!(CStr::from_ptr(account_label).to_str()).to_string()) + }; + let proof = match read_auto_accept_proof(auto_accept_proof, auto_accept_proof_len) { + Ok(proof) => proof, + Err(result) => return result, + }; + let shielded_address = read_shielded_address(shielded_address); + + let (wallet, coordinator) = match resolve_wallet_and_coordinator(handle, &wallet_id) { + Ok(pair) => pair, + Err(result) => return result, + }; + let seed = match resolve_seed_from_resolver(mnemonic_resolver_handle, &wallet_id) { + Ok(seed) => seed, + Err(result) => return result, + }; + // SAFETY: the caller pins the resolver handle for the duration of this + // call. + let crypto = unsafe { + resolver_contact_crypto_provider(mnemonic_resolver_handle, wallet_id, wallet.network()) + }; + + // The proof runs on a worker thread: Halo 2 synthesis recurses past the + // iOS dispatch-thread stack. + let result = block_on_worker(async move { + let prover = CachedOrchardProver::new(); + let result = wallet + .send_private_contact_request( + &coordinator, + seed.as_ref(), + account, + &sender, + &recipient, + label, + AutoAcceptProofSource::from_option(proof), + shielded_address, + &crypto, + &prover, + ) + .await; + poke_sync_on_unconfirmed(&result, handle); + result + }); + match result { + Ok(request) => { + unsafe { *out_request_handle = CONTACT_REQUEST_STORAGE.insert(request) }; + PlatformWalletFFIResult::ok() + } + Err(error) => map_spend_result(Err(error), "private contact request"), + } +} + +/// Accept a DashPay contact request that came privately, replying privately +/// and paying for the reply from `account`'s shielded notes, as +/// [`platform_wallet_manager_send_private_contact_request`] pays. +/// `shielded_address` is the Orchard address the contact may pay us at +/// privately; null omits it. +/// +/// `request_handle` is a live `CONTACT_REQUEST_STORAGE` handle of the +/// incoming request. Writes a handle into `ESTABLISHED_CONTACT_STORAGE` to +/// `out_established_handle` (release with `established_contact_destroy`). +/// +/// # Safety +/// - `wallet_id_bytes` must point to 32 readable bytes. +/// - `mnemonic_resolver_handle` must be a valid, non-destroyed handle; the +/// caller retains ownership and pins it for the call. +/// - `shielded_address`, when non-null, must point to 43 readable bytes. +/// - `out_established_handle` must be a valid, writable pointer. +#[cfg(feature = "shielded")] +#[no_mangle] +pub unsafe extern "C" fn platform_wallet_manager_accept_private_contact_request( + handle: Handle, + wallet_id_bytes: *const u8, + mnemonic_resolver_handle: *mut MnemonicResolverHandle, + account: u32, + request_handle: Handle, + shielded_address: *const u8, + out_established_handle: *mut Handle, +) -> PlatformWalletFFIResult { + // Same panic contract as the send: the reply's notes may be spent. + catch_spend_panic("private contact request accept", || { + accept_private_contact_request_inner( + handle, + wallet_id_bytes, + mnemonic_resolver_handle, + account, + request_handle, + shielded_address, + out_established_handle, + ) + }) +} + +/// Body of [`platform_wallet_manager_accept_private_contact_request`], as an +/// ordinary Rust function so a panic unwinds into [`catch_spend_panic`]. +/// +/// # Safety +/// Identical contract to the export that calls it. +#[cfg(feature = "shielded")] +unsafe fn accept_private_contact_request_inner( + handle: Handle, + wallet_id_bytes: *const u8, + mnemonic_resolver_handle: *mut MnemonicResolverHandle, + account: u32, + request_handle: Handle, + shielded_address: *const u8, + out_established_handle: *mut Handle, +) -> PlatformWalletFFIResult { + check_ptr!(wallet_id_bytes); + check_ptr!(mnemonic_resolver_handle); + check_ptr!(out_established_handle); + unsafe { *out_established_handle = NULL_HANDLE }; + + let mut wallet_id = [0u8; 32]; + std::ptr::copy_nonoverlapping(wallet_id_bytes, wallet_id.as_mut_ptr(), 32); + let request = CONTACT_REQUEST_STORAGE.with_item(request_handle, |request| request.clone()); + let request = unwrap_option_or_return!(request); + let shielded_address = read_shielded_address(shielded_address); + + let (wallet, coordinator) = match resolve_wallet_and_coordinator(handle, &wallet_id) { + Ok(pair) => pair, + Err(result) => return result, + }; + let seed = match resolve_seed_from_resolver(mnemonic_resolver_handle, &wallet_id) { + Ok(seed) => seed, + Err(result) => return result, + }; + // SAFETY: the caller pins the resolver handle for the duration of this + // call. + let crypto = unsafe { + resolver_contact_crypto_provider(mnemonic_resolver_handle, wallet_id, wallet.network()) + }; + + let result = block_on_worker(async move { + let prover = CachedOrchardProver::new(); + let result = wallet + .accept_private_contact_request( + &coordinator, + seed.as_ref(), + account, + &request, + shielded_address, + &crypto, + &prover, + ) + .await; + poke_sync_on_unconfirmed(&result, handle); + result + }); + match result { + Ok(contact) => { + unsafe { *out_established_handle = ESTABLISHED_CONTACT_STORAGE.insert(contact) }; + PlatformWalletFFIResult::ok() + } + Err(error) => map_spend_result(Err(error), "private contact request accept"), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn should_clear_both_sync_outputs_on_an_unknown_wallet() { + let mut requests = ContactRequestHandleArray { + handles: std::ptr::NonNull::dangling().as_ptr(), + count: 3, + }; + let mut report = PrivateContactSyncReportFFI { + next_position: 9, + pages_read: 9, + rejected_count: 9, + unpersisted_identity_count: 9, + complete: true, + }; + // A resolver is never called for an unknown wallet; any non-null + // pointer passes the null check. + let resolver = std::ptr::NonNull::::dangling().as_ptr(); + let result = unsafe { + platform_wallet_sync_private_contact_requests( + 0xDEAD_BEEF, + resolver, + 1, + &mut requests, + &mut report, + ) + }; + assert_eq!(result.code, PlatformWalletFFIResultCode::NotFound); + assert!(requests.handles.is_null()); + assert_eq!(requests.count, 0); + assert_eq!(report, PrivateContactSyncReportFFI::default()); + } + + #[test] + fn should_reject_a_null_resolver_on_sync() { + let mut requests = ContactRequestHandleArray::empty(); + let mut report = PrivateContactSyncReportFFI::default(); + let result = unsafe { + platform_wallet_sync_private_contact_requests( + 0, + std::ptr::null_mut(), + 1, + &mut requests, + &mut report, + ) + }; + assert_eq!(result.code, PlatformWalletFFIResultCode::ErrorNullPointer); + } + + #[test] + fn should_carry_every_field_of_a_private_contact() { + let address = [0x55; SHIELDED_ADDRESS_LEN]; + let with_address = PrivateContactFFI::from(&PrivateContact { + received_privately: true, + sent_privately: false, + shielded_address: Some(address), + }); + assert!(with_address.received_privately); + assert!(!with_address.sent_privately); + assert!(with_address.has_shielded_address); + assert_eq!(with_address.shielded_address, address); + + let without_address = PrivateContactFFI::from(&PrivateContact { + received_privately: false, + sent_privately: true, + shielded_address: None, + }); + assert!(without_address.sent_privately); + assert!(!without_address.has_shielded_address); + assert_eq!(without_address.shielded_address, [0; SHIELDED_ADDRESS_LEN]); + } + + #[test] + fn should_report_no_private_contact_for_an_unknown_identity_handle() { + let mut contact = PrivateContactFFI { + received_privately: true, + sent_privately: true, + has_shielded_address: true, + shielded_address: [0x55; SHIELDED_ADDRESS_LEN], + }; + let contact_id = [7u8; 32]; + let result = unsafe { + managed_identity_get_private_contact(0xDEAD_BEEF, contact_id.as_ptr(), &mut contact) + }; + assert_eq!(result.code, PlatformWalletFFIResultCode::NotFound); + assert_eq!(contact, PrivateContactFFI::empty()); + } +} diff --git a/packages/rs-platform-wallet-ffi/src/shielded_send.rs b/packages/rs-platform-wallet-ffi/src/shielded_send.rs index 232d8a9a9c8..0564ad299fc 100644 --- a/packages/rs-platform-wallet-ffi/src/shielded_send.rs +++ b/packages/rs-platform-wallet-ffi/src/shielded_send.rs @@ -563,7 +563,7 @@ pub unsafe extern "C" fn platform_wallet_manager_shielded_withdraw( /// prune backstop); the pass outcome is logged, not surfaced. /// /// [`ShieldedSyncManager::sync_now`]: platform_wallet::manager::shielded_sync::ShieldedSyncManager::sync_now -fn poke_sync_on_unconfirmed(result: &Result, handle: Handle) { +pub(crate) fn poke_sync_on_unconfirmed(result: &Result, handle: Handle) { let ambiguous = matches!( result, Err(PlatformWalletError::ShieldedSpendUnconfirmed { .. }) @@ -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 { @@ -743,7 +743,7 @@ const SPEND_PANIC_GUIDANCE: &str = "The spend may or may not have been broadcast /// transition was broadcast, so the outcome is genuinely ambiguous. That code's contract is /// exactly the conservative one this needs — the host must not auto-retry, the reservation stays /// in place, and the next nullifier sync (or an app restart) reconciles whether the spend landed. -fn catch_spend_panic( +pub(crate) fn catch_spend_panic( operation: &str, body: impl FnOnce() -> PlatformWalletFFIResult, ) -> PlatformWalletFFIResult { From cc555f84088043835ba66e83fc64586609870121 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Sun, 8 Nov 2026 12:07:00 +0700 Subject: [PATCH 007/269] feat(swift-sdk): sync, send and accept DashPay private contact requests Thin wrappers over the new platform-wallet FFI: - `ManagedPlatformWallet.syncPrivateContactRequests(maxPages:)` returns a `PrivateContactSyncResult`. - `PlatformWalletManager.sendPrivateContactRequest(...)` and `acceptPrivateContactRequest(...)` pay from the wallet's shielded notes, taking the resolver as the other shielded calls do. - `ManagedIdentity.privateContact(contactId:)` returns `PrivateContactInfo` (came privately, went privately, shielded address), or nil. `PlatformWalletResultCode.errorPrivateContactRequestNeedsPrivateReply` (68) and `PlatformWalletError.privateContactRequestNeedsPrivateReply` carry the public accept's refusal, pinned in `ErrorHandlingTests`. Co-Authored-By: Claude Opus 5.5 --- .../PlatformWallet/PlatformWalletResult.swift | 12 + .../PrivateContactRequests.swift | 275 ++++++++++++++++++ .../ErrorHandlingTests.swift | 32 ++ 3 files changed, 319 insertions(+) create mode 100644 packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PrivateContactRequests.swift diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift index b8a2f107ad7..2c9abc26201 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift @@ -265,6 +265,9 @@ public enum PlatformWalletResultCode: Int32, Sendable { /// An asset lock (a drain, or a fixed amount in many small coins) exceeds /// the input cap; nothing was broadcast. case errorAssetLockTooManyInputs = 60 + /// The contact request came privately, so a public reply would publish + /// the relationship. Nothing was sent; accept it privately. + case errorPrivateContactRequestNeedsPrivateReply = 68 /// The named thing does not exist. Besides the handle/lookup failures this /// has always covered, BOTH deferred-send paths report the /// wallet-was-REMOVED case here. @@ -396,6 +399,8 @@ public enum PlatformWalletResultCode: Int32, Sendable { self = .errorSpvProcessRestartRequired case PLATFORM_WALLET_FFI_RESULT_CODE_ERROR_ASSET_LOCK_TOO_MANY_INPUTS: self = .errorAssetLockTooManyInputs + case PLATFORM_WALLET_FFI_RESULT_CODE_ERROR_PRIVATE_CONTACT_REQUEST_NEEDS_PRIVATE_REPLY: + self = .errorPrivateContactRequestNeedsPrivateReply case PLATFORM_WALLET_FFI_RESULT_CODE_NOT_FOUND: self = .notFound case PLATFORM_WALLET_FFI_RESULT_CODE_ERROR_UNKNOWN: @@ -587,6 +592,10 @@ public enum PlatformWalletError: LocalizedError { /// tracked lock over the cap: it is not re-sent (an earlier attempt may /// have reached the network), it stays tracked, and nothing is released. case assetLockTooManyInputs(String) + /// The contact request came privately (protocol version 15): a public + /// reply would publish the relationship, so nothing was sent. Accept it + /// with `PlatformWalletManager.acceptPrivateContactRequest`. + case privateContactRequestNeedsPrivateReply(String) case walletAlreadyExists(String) /// Definitive shielded-broadcast failure: the shielded transition /// (identity-create or a spend — unshield / transfer / withdrawal) was @@ -812,6 +821,7 @@ public enum PlatformWalletError: LocalizedError { .assetLockNotTracked(let m), .assetLockAlreadyConsumed(let m), .assetLockFundingMismatch(let m), .assetLockInsufficientFunds(let m), .assetLockTooManyInputs(let m), + .privateContactRequestNeedsPrivateReply(let m), .walletAlreadyExists(let m), .shieldedBroadcastFailed(let m), .shieldedBroadcastUnconfirmed(let m), .shieldedSpendUnconfirmed(let m), .shieldedNoRecordedAnchor(let m), .shieldedInsufficientBalance(let m), @@ -930,6 +940,8 @@ public enum PlatformWalletError: LocalizedError { case .errorAssetLockFundingMismatch: self = .assetLockFundingMismatch(detail) case .errorAssetLockInsufficientFunds: self = .assetLockInsufficientFunds(detail) case .errorAssetLockTooManyInputs: self = .assetLockTooManyInputs(detail) + case .errorPrivateContactRequestNeedsPrivateReply: + self = .privateContactRequestNeedsPrivateReply(detail) case .errorWalletAlreadyExists: self = .walletAlreadyExists(detail) case .errorShieldedBroadcastFailed: self = .shieldedBroadcastFailed(detail) case .errorShieldedBroadcastUnconfirmed: self = .shieldedBroadcastUnconfirmed(detail) diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PrivateContactRequests.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PrivateContactRequests.swift new file mode 100644 index 00000000000..d4337dc486f --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PrivateContactRequests.swift @@ -0,0 +1,275 @@ +import Foundation +import DashSDKFFI + +// MARK: - DashPay private contact requests (protocol version 15) +// +// A private contact request is sealed to one of the recipient's DashPay keys +// and paid for from the shielded pool, so nothing on chain names either side. +// The wallet records it as an ordinary `ContactRequest`; what that type has no +// field for (that it came privately, and the contact's shielded address) is +// read with `ManagedIdentity.privateContact(contactId:)`. +// +// Routing an accept: the public `acceptContactRequest` throws +// `PlatformWalletError.privateContactRequestNeedsPrivateReply` for a request +// that came privately; accept it with +// `PlatformWalletManager.acceptPrivateContactRequest` instead. + +/// What an identity's private contact requests with one contact carry beyond +/// the `ContactRequest`. Rust keeps it in memory only and rebuilds it with +/// `ManagedPlatformWallet.syncPrivateContactRequests`. +public struct PrivateContactInfo: Sendable, Equatable { + /// The contact's request came privately, so accepting it replies + /// privately. + public let receivedPrivately: Bool + /// This identity's request to the contact went privately. + public let sentPrivately: Bool + /// The 43-byte Orchard address the contact asked to be paid at privately. + public let shieldedAddress: Data? + + init(ffi: PrivateContactFFI) { + receivedPrivately = ffi.received_privately + sentPrivately = ffi.sent_privately + shieldedAddress = ffi.has_shielded_address + ? withUnsafeBytes(of: ffi.shielded_address) { Data($0) } + : nil + } +} + +/// What one pass over DashPay's private contact request store did. +public struct PrivateContactSyncResult: Sendable { + /// The requests the pass ingested, received and sent, including ones the + /// wallet already held. + public let requests: [ContactRequest] + /// The store pages read. + public let pagesRead: UInt32 + /// Entries that opened for one of the wallet's identities but were + /// refused. + public let rejectedCount: UInt32 + /// The store position the pass stopped at. + public let nextPosition: UInt64 + /// Whether the pass read to the store's last entry. When false, sync + /// again to read on. + public let complete: Bool + /// Identities whose ingest did not reach disk; the next pass reads their + /// entries again. + public let unpersistedIdentityCount: UInt32 +} + +extension ManagedIdentity { + /// What this identity's private contact requests with `contactId` carry, + /// or `nil` when none between them went privately as far as this process + /// has seen. + public func privateContact(contactId: Identifier) throws -> PrivateContactInfo? { + var ffi = PrivateContactFFI() + let result = contactId.withFFIBytes { idPtr in + PlatformWalletResult(managed_identity_get_private_contact(handle, idPtr, &ffi)) + } + if result.code == .notFound { + return nil + } + try result.throwIfError() + return PrivateContactInfo(ffi: ffi) + } +} + +extension ManagedPlatformWallet { + /// Read DashPay's private contact request store for every identity of + /// this wallet, at most `maxPages` pages (the default reads to the end), + /// and ingest what it holds for them: requests to them, each sender + /// checked, and requests they sent, recovered from the store alone. + /// + /// The keys stay in the Keychain mnemonic resolver Rust-side. The store + /// cursors live in memory only, so the first pass after launch reads the + /// store from its start, and until it has run the public accept cannot + /// tell that a request came privately. + public func syncPrivateContactRequests( + maxPages: UInt32 = .max + ) async throws -> PrivateContactSyncResult { + let handle = self.handle + let coreSigner = MnemonicResolver() + return try await Task.detached(priority: .userInitiated) { + () -> PrivateContactSyncResult in + var array = ContactRequestHandleArray() + var report = PrivateContactSyncReportFFI() + // Keep the resolver alive across the synchronous FFI call: its + // vtable callback fires during it. + let result = withExtendedLifetime(coreSigner) { + platform_wallet_sync_private_contact_requests( + handle, + coreSigner.handle, + maxPages, + &array, + &report + ) + } + defer { platform_wallet_contact_request_handle_array_free(&array) } + try result.check() + var requests: [ContactRequest] = [] + if let handles = array.handles, array.count > 0 { + requests.reserveCapacity(Int(array.count)) + for i in 0.. ContactRequest { + let handle = try privateContactRequestManagerHandle( + walletId: walletId, + resolver: resolver, + shieldedAddress: shieldedAddress + ) + let requestHandle: Handle = try await Task.detached(priority: .userInitiated) { + () -> Handle in + var outHandle: Handle = NULL_HANDLE + let resolverHandle = resolver.handle + // Guaranteed resolver keepalive across the FFI call: same + // rationale as `shieldedTransfer`. + try withExtendedLifetime(resolver) { + try walletId.withFFIBytes { widPtr in + try senderIdentityId.withFFIBytes { senderPtr in + try recipientIdentityId.withFFIBytes { recipientPtr in + try withOptionalCString(accountLabel) { labelPtr in + try withOptionalBytes(autoAcceptProof) { proofPtr, proofLen in + try withOptionalBytes(shieldedAddress) { addressPtr, _ in + try platform_wallet_manager_send_private_contact_request( + handle, + widPtr, + resolverHandle, + account, + senderPtr, + recipientPtr, + labelPtr, + proofPtr, + proofLen, + addressPtr, + &outHandle + ).check() + } + } + } + } + } + } + } + return outHandle + }.value + return ContactRequest(handle: requestHandle) + } + + /// Accept a DashPay contact request that came privately, replying + /// privately and paying for the reply from `account`'s shielded notes. + /// `shieldedAddress` is the 43-byte Orchard address the contact may pay + /// us at privately. + public func acceptPrivateContactRequest( + walletId: Data, + resolver: MnemonicResolver, + account: UInt32 = 0, + request: ContactRequest, + shieldedAddress: Data? = nil + ) async throws -> EstablishedContact { + let handle = try privateContactRequestManagerHandle( + walletId: walletId, + resolver: resolver, + shieldedAddress: shieldedAddress + ) + let requestHandle = request.handle + let establishedHandle: Handle = try await Task.detached(priority: .userInitiated) { + () -> Handle in + var outHandle: Handle = NULL_HANDLE + let resolverHandle = resolver.handle + try withExtendedLifetime((resolver, request)) { + try walletId.withFFIBytes { widPtr in + try withOptionalBytes(shieldedAddress) { addressPtr, _ in + try platform_wallet_manager_accept_private_contact_request( + handle, + widPtr, + resolverHandle, + account, + requestHandle, + addressPtr, + &outHandle + ).check() + } + } + } + return outHandle + }.value + return EstablishedContact(handle: establishedHandle) + } + + /// The manager handle of a private contact request call, refused before + /// any FFI call when the arguments cannot be passed: an unconfigured + /// manager, a wallet id that is not 32 bytes, a shielded address that is + /// not the 43 bytes Rust reads, or a resolver without a handle. + private func privateContactRequestManagerHandle( + walletId: Data, + resolver: MnemonicResolver, + shieldedAddress: Data? + ) throws -> Handle { + guard isConfigured, handle != NULL_HANDLE else { + throw PlatformWalletError.invalidHandle("PlatformWalletManager not configured") + } + guard walletId.count == 32 else { + throw PlatformWalletError.invalidParameter("walletId must be exactly 32 bytes") + } + if let shieldedAddress, shieldedAddress.count != 43 { + throw PlatformWalletError.invalidParameter( + "shieldedAddress must be exactly 43 raw Orchard bytes" + ) + } + guard resolver.handle != nil else { + throw PlatformWalletError.invalidParameter("MnemonicResolver has no handle") + } + return handle + } +} + +/// Run `body` with `text` as a C string, or with null when `text` is nil. +private func withOptionalCString( + _ text: String?, + _ body: (UnsafePointer?) throws -> R +) rethrows -> R { + guard let text else { return try body(nil) } + return try text.withCString { try body($0) } +} + +/// Run `body` with a pointer to `data` and its length, or with `(nil, 0)` +/// when `data` is nil or empty. +private func withOptionalBytes( + _ data: Data?, + _ body: (UnsafePointer?, UInt) throws -> R +) rethrows -> R { + guard let data, !data.isEmpty else { return try body(nil, 0) } + return try data.withUnsafeBytes { raw in + try body(raw.bindMemory(to: UInt8.self).baseAddress, UInt(data.count)) + } +} diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/ErrorHandlingTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/ErrorHandlingTests.swift index f18b44a9fce..c7f1f5909f4 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/ErrorHandlingTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/ErrorHandlingTests.swift @@ -459,6 +459,38 @@ final class ErrorHandlingTests: XCTestCase { ) } + func testShouldRouteAPrivatelyReceivedRequestByItsOwnCode() { + let code = PlatformWalletResultCode( + ffi: PLATFORM_WALLET_FFI_RESULT_CODE_ERROR_PRIVATE_CONTACT_REQUEST_NEEDS_PRIVATE_REPLY + ) + XCTAssertEqual(code, .errorPrivateContactRequestNeedsPrivateReply) + XCTAssertEqual(code.rawValue, 68) + let detail = "the contact request from 9fRYTJZW came privately; " + + "accept it with a private reply" + let error = PlatformWalletError(code: code, message: detail) + guard case .privateContactRequestNeedsPrivateReply(let message) = error else { + return XCTFail("lost typed private-reply error") + } + XCTAssertEqual(message, detail) + XCTAssertEqual(error.errorDescription, detail) + } + + func testShouldReadEveryFieldOfAPrivateContact() { + var ffi = PrivateContactFFI() + ffi.received_privately = true + ffi.has_shielded_address = true + withUnsafeMutableBytes(of: &ffi.shielded_address) { raw in + for i in raw.indices { raw[i] = UInt8(i) } + } + let info = PrivateContactInfo(ffi: ffi) + XCTAssertTrue(info.receivedPrivately) + XCTAssertFalse(info.sentPrivately) + XCTAssertEqual(info.shieldedAddress, Data((0..<43).map { UInt8($0) })) + + ffi.has_shielded_address = false + XCTAssertNil(PrivateContactInfo(ffi: ffi).shieldedAddress) + } + func testShouldPreserveShieldedIdentityDebitPendingFFIResult() { let code = PlatformWalletResultCode( ffi: PLATFORM_WALLET_FFI_RESULT_CODE_ERROR_SHIELDED_IDENTITY_DEBIT_PENDING From ef3a1a572808c0332c09788e6c04d31e32872b29 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Sun, 8 Nov 2026 12:07:00 +0700 Subject: [PATCH 008/269] feat(swift-example-app): send contact requests privately and accept them privately - Add Contact has a "Send privately" toggle that sends the request from the wallet's shielded balance. - Accepting a request the public accept refuses as private replies privately instead, in the request list and in Add Contact's collision alert. Rust decides which; the app only reacts to the refusal. - Pull-to-refresh on the request list also reads the private store, which is what records that a request came privately. Co-Authored-By: Claude Opus 5.5 --- .../Views/DashPay/AddContactView.swift | 45 +++++++++++++++---- .../Views/DashPay/ContactRequestsView.swift | 10 ++++- .../Views/DashPay/ContactsView.swift | 30 +++++++++++++ 3 files changed, 76 insertions(+), 9 deletions(-) diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/AddContactView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/AddContactView.swift index 7a0f020ff9b..285c0e00123 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/AddContactView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/AddContactView.swift @@ -50,6 +50,10 @@ struct AddContactView: View { /// as the contact's "Their account" hint. @State private var accountLabel = "" + /// Send a private contact request (protocol version 15): paid from the + /// wallet's shielded balance, so nothing on chain names either side. + @State private var sendPrivately = false + @State private var isSending = false @State private var errorMessage: String? @@ -114,6 +118,7 @@ struct AddContactView: View { if let recipient = resolvedRecipient { previewSection(recipient: recipient) accountLabelSection + sendPrivatelySection sendSection } @@ -357,6 +362,15 @@ struct AddContactView: View { } } + private var sendPrivatelySection: some View { + Section { + Toggle("Send privately", isOn: $sendPrivately) + .accessibilityIdentifier("dashpay.addContact.sendPrivately") + } footer: { + Text("Paid from this wallet's shielded balance, so nothing on chain names either of you.") + } + } + private var sendSection: some View { Section { Button { @@ -451,14 +465,24 @@ struct AddContactView: View { defer { isSending = false } do { let wallet = try requireWallet() - let signer = KeychainSigner(modelContainer: modelContext.container) let label = accountLabel.trimmingCharacters(in: .whitespacesAndNewlines) - _ = try await wallet.sendContactRequest( - senderIdentityId: identity.identityId, - recipientIdentityId: recipient, - accountLabel: label.isEmpty ? nil : label, - signer: signer - ) + if sendPrivately { + _ = try await walletManager.sendPrivateContactRequest( + walletId: wallet.walletId, + resolver: MnemonicResolver(), + senderIdentityId: identity.identityId, + recipientIdentityId: recipient, + accountLabel: label.isEmpty ? nil : label + ) + } else { + let signer = KeychainSigner(modelContainer: modelContext.container) + _ = try await wallet.sendContactRequest( + senderIdentityId: identity.identityId, + recipientIdentityId: recipient, + accountLabel: label.isEmpty ? nil : label, + signer: signer + ) + } onSent(recipient, mode == .dpns ? selectedResult?.fullName : nil) kickDashPaySync(walletManager) dismiss() @@ -486,7 +510,12 @@ struct AddContactView: View { return } let signer = KeychainSigner(modelContainer: modelContext.container) - _ = try await wallet.acceptContactRequest(request, signer: signer) + try await acceptIncomingContactRequest( + request, + wallet: wallet, + walletManager: walletManager, + signer: signer + ) kickDashPaySync(walletManager) dismiss() } catch { diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ContactRequestsView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ContactRequestsView.swift index 0ad941084cb..99ba9062db5 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ContactRequestsView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ContactRequestsView.swift @@ -165,6 +165,9 @@ struct ContactRequestsView: View { } .refreshable { await attachOrStartSync(walletManager) + if let wallet = try? requireWallet() { + await syncPrivateContactRequests(wallet) + } } .onChange(of: rowSignature) { _, _ in pruneOverlays() @@ -225,7 +228,12 @@ struct ContactRequestsView: View { return } let signer = KeychainSigner(modelContainer: modelContext.container) - _ = try await wallet.acceptContactRequest(request, signer: signer) + try await acceptIncomingContactRequest( + request, + wallet: wallet, + walletManager: walletManager, + signer: signer + ) // Optimistic removal — the persister will promote the // pair to established shortly. removedOverlayIds.insert(contactId) diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ContactsView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ContactsView.swift index edc73820e76..538b185138a 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ContactsView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ContactsView.swift @@ -226,6 +226,36 @@ func kickDashPaySync(_ walletManager: PlatformWalletManager) { Task { _ = try? await walletManager.dashPaySyncNow() } } +/// Read DashPay's private contact request store (protocol version 15) for +/// `wallet`. Best effort: a network before protocol version 15 has no such +/// store. Which requests came privately is kept in memory only, so this runs +/// with every pull-to-refresh of the request list. +@MainActor +func syncPrivateContactRequests(_ wallet: ManagedPlatformWallet) async { + _ = try? await wallet.syncPrivateContactRequests() +} + +/// Accept `request`. Rust refuses a public reply to a request that came +/// privately, as it would publish the relationship; that one is accepted with +/// a private reply instead, paid from the wallet's shielded balance. +@MainActor +func acceptIncomingContactRequest( + _ request: ContactRequest, + wallet: ManagedPlatformWallet, + walletManager: PlatformWalletManager, + signer: KeychainSigner +) async throws { + do { + _ = try await wallet.acceptContactRequest(request, signer: signer) + } catch PlatformWalletError.privateContactRequestNeedsPrivateReply { + _ = try await walletManager.acceptPrivateContactRequest( + walletId: wallet.walletId, + resolver: MnemonicResolver(), + request: request + ) + } +} + // MARK: - Row model + view /// UI model for one established contact row, resolved from the From c3f1b0d71b9339d2ab5c31fb65edea0e464714b1 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Sun, 8 Nov 2026 17:10:24 +0700 Subject: [PATCH 009/269] 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 010/269] 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 29adfcae9ca653089acfd46a97b10b82411b3d57 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Sun, 8 Nov 2026 17:58:47 +0700 Subject: [PATCH 011/269] fix(rs-sdk-ffi): seal private contact requests with the identity signer's signature Private contact requests are now signed by the sender's identity signer with one of its authentication keys, as public requests are. The resolver signer keeps only the DashPay encryption key: - `seal_private_contact_request` takes the signing key id and the 65-byte signature and passes them to the SDK seal. It signs nothing itself; the SDK refuses a signature that is not the signing key's. The encryption key, derived for the outgoing viewing key and the key check, is wiped on drop as before. - `read_private_contact_requests` is removed: checking an entry needs no secret key, so the wallet reads the scan itself. - `scan_private_document_page` takes each sender encryption key's id with its path, as `PrivateDocumentScanKeys::with_sender` now does. Tests sign with `SingleKeySigner` through `private_contact_request_signature`, as an identity signer signs data. The sealed entry is still byte for byte the one a resident wallet seals; a scanned page reads as the sender's signed request; a scan and a seal each resolve the mnemonic once; a signature by another key and a wrong encryption key path are refused, each for its own reason. Co-Authored-By: Claude Opus 5.5 --- .../src/mnemonic_resolver_core_signer.rs | 421 ++++++++++++------ 1 file changed, 294 insertions(+), 127 deletions(-) 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 5780948b9fa..f27eeb698ce 100644 --- a/packages/rs-sdk-ffi/src/mnemonic_resolver_core_signer.rs +++ b/packages/rs-sdk-ffi/src/mnemonic_resolver_core_signer.rs @@ -56,7 +56,6 @@ //! Combined, no private key bytes survive past the trait-method //! boundary. -use std::collections::BTreeMap; use std::ffi::c_void; use std::os::raw::c_char; @@ -66,11 +65,9 @@ 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::{ - private_contact_request_outgoing_viewing_key, private_contact_requests_from_scan, - seal_private_contact_request, PrivateContactRequest, PrivateContactRequestScan, - PrivateContactRequestSender, + private_contact_request_outgoing_viewing_key, seal_private_contact_request, + PrivateContactRequest, PrivateContactRequestSender, }; use dash_sdk::platform::private_document_entries::PrivateDocumentEntries; use dash_sdk::platform::private_document_scan::{PrivateDocumentScan, PrivateDocumentScanKeys}; @@ -181,9 +178,10 @@ pub enum MnemonicResolverSignerError { #[error("ECDSA signature conversion failed: {0}")] SignatureConversion(#[source] EcdsaError), - /// The SDK refused to read or seal a private contact request with the - /// derived keys: a malformed request, a key the identity does not hold, - /// or a contract without the private type. + /// 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), } @@ -623,13 +621,13 @@ impl MnemonicResolverCoreSigner { /// 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 path of an encryption key whose outgoing - /// viewing key recovers what it sent. Every key comes from one resolve - /// and is dropped here; only the sorted entries return. + /// entries, each with the id and path of an encryption key whose + /// outgoing viewing key recovers what it sent. Every key comes from one + /// resolve and is dropped here; only the sorted entries return. pub fn scan_private_document_page( &self, recipients: &[(Identifier, Vec<(KeyID, DerivationPath)>)], - senders: &[(Identifier, DerivationPath)], + senders: &[(Identifier, KeyID, DerivationPath)], data_contract_id: Identifier, document_type_name: &str, start_position: u64, @@ -645,39 +643,15 @@ impl MnemonicResolverCoreSigner { }) } - /// Read the private contact requests of `scan`, a scan of DashPay's - /// store made with the keys at `recipients` - /// ([`private_contact_requests_from_scan`]): each received request's - /// sender is checked with the recipient key that opened it, against the - /// key `sender_identities` holds under the id it names. Only the - /// recipient keys are derived, from one resolve, and dropped here. - pub fn read_private_contact_requests( - &self, - recipients: &[(Identifier, Vec<(KeyID, DerivationPath)>)], - dashpay_contract: &DataContract, - scan: PrivateDocumentScan, - sender_identities: &BTreeMap>, - platform_version: &PlatformVersion, - ) -> Result { - self.resolve_master(|master| { - let keys = private_document_scan_keys(master, recipients, &[])?; - private_contact_requests_from_scan( - dashpay_contract, - scan, - &keys, - sender_identities, - platform_version, - ) - .map_err(MnemonicResolverSignerError::PrivateContactRequest) - }) - } - /// 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` - /// ([`seal_private_contact_request`]). `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. + /// 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, @@ -685,6 +659,8 @@ impl MnemonicResolverCoreSigner { 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, @@ -698,6 +674,8 @@ impl MnemonicResolverCoreSigner { identity: sender, encryption_key_id: sender_encryption_key_id, encryption_secret_key: &secret.0, + signing_key_id, + signature, }, recipient, recipient_key_id, @@ -723,13 +701,13 @@ fn secret_key_at( } /// The scan keys of `recipients` and `senders`, derived below `master`: each -/// recipient's secret keys, and each sender's outgoing viewing key -/// ([`private_contact_request_outgoing_viewing_key`]). The copies made here -/// are wiped before this returns. +/// recipient's secret keys, and the outgoing viewing key of each sender +/// encryption key ([`private_contact_request_outgoing_viewing_key`]). The +/// copies made here are wiped before this returns. fn private_document_scan_keys( master: &ExtendedPrivKey, recipients: &[(Identifier, Vec<(KeyID, DerivationPath)>)], - senders: &[(Identifier, DerivationPath)], + senders: &[(Identifier, KeyID, DerivationPath)], ) -> Result { let mut keys = PrivateDocumentScanKeys::new(); for (identity_id, paths) in recipients { @@ -741,11 +719,11 @@ fn private_document_scan_keys( } keys = keys.with_recipient(*identity_id, &secret_keys.0); } - for (identity_id, path) in senders { + for (identity_id, key_id, path) in senders { let secret = secret_key_at(master, path)?; let outgoing_viewing_key = Zeroizing::new(private_contact_request_outgoing_viewing_key(&secret.0)); - keys = keys.with_sender(*identity_id, *outgoing_viewing_key); + keys = keys.with_sender(*identity_id, *key_id, *outgoing_viewing_key); } Ok(keys) } @@ -1407,16 +1385,24 @@ mod tests { 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::platform::dashpay::PRIVATE_CONTACT_REQUEST_DOCUMENT_TYPE_NAME; + use dash_sdk::dpp::version::PlatformVersion; + use dash_sdk::platform::dashpay::{ + private_contact_request_identities, private_contact_request_signature, + private_contact_request_signing_data, private_contact_requests_from_scan, + 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]; @@ -1424,6 +1410,8 @@ mod tests { /// 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; @@ -1456,19 +1444,34 @@ mod tests { .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 { - secp256k1::SecretKey::from_secret_bytes([0x41; 32]).expect("a valid scalar") + 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: SecurityLevel::MEDIUM, + security_level, contract_bounds: None, key_type: KeyType::ECDSA_SECP256K1, read_only: false, @@ -1481,16 +1484,20 @@ mod tests { }) } - fn identity( - id: [u8; 32], - key_id: KeyID, - purpose: Purpose, - secret_key: &secp256k1::SecretKey, - ) -> Identity { - let key = key(key_id, purpose, secret_key); + /// 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: BTreeMap::from([(key_id, key)]), + public_keys: keys.into_iter().map(|key| (key.id(), key)).collect(), balance: 0, revision: 0, }) @@ -1499,36 +1506,54 @@ mod tests { fn sender() -> Identity { identity( SENDER_ID, - SENDER_KEY_ID, - Purpose::ENCRYPTION, - &seed_secret_key(&sender_key_path()), + 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, - RECIPIENT_KEY_ID, - Purpose::DECRYPTION, - &seed_secret_key(&recipient_key_path()), + vec![key( + RECIPIENT_KEY_ID, + Purpose::DECRYPTION, + SecurityLevel::MEDIUM, + &seed_secret_key(&recipient_key_path()), + )], ) } fn outsider() -> Identity { identity( OUTSIDER_ID, - SENDER_KEY_ID, - Purpose::ENCRYPTION, - &outsider_secret_key(), + vec![ + signing_key(&outsider_signing_secret_key()), + key( + SENDER_KEY_ID, + Purpose::ENCRYPTION, + SecurityLevel::MEDIUM, + &outsider_secret_key(), + ), + ], ) } fn carol() -> Identity { identity( CAROL_ID, - RECIPIENT_KEY_ID, - Purpose::DECRYPTION, - &secp256k1::SecretKey::from_secret_bytes([0x42; 32]).expect("a valid scalar"), + vec![key( + RECIPIENT_KEY_ID, + Purpose::DECRYPTION, + SecurityLevel::MEDIUM, + &fixed_secret_key(0x42), + )], ) } @@ -1554,12 +1579,47 @@ mod tests { } } - /// `request` sealed by `sender` with `secret_key`, the key a resident - /// wallet holds, to the decryption key of `recipient`. + /// 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(), + ) + .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 { @@ -1569,6 +1629,8 @@ mod tests { identity: sender, encryption_key_id: SENDER_KEY_ID, encryption_secret_key: secret_key, + signing_key_id: SIGNING_KEY_ID, + signature, }, recipient, RECIPIENT_KEY_ID, @@ -1579,16 +1641,43 @@ mod tests { } /// The test entry: the sender's request to the recipient. - fn sent_to_recipient(contract: &DataContract, esk_seed: u8) -> BinaryData { + 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, @@ -1614,8 +1703,12 @@ mod tests { } /// The wallet's sender key paths: the sender's encryption key. - fn sender_paths() -> Vec<(Identifier, DerivationPath)> { - vec![(Identifier::from(SENDER_ID), sender_key_path())] + 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. @@ -1627,6 +1720,7 @@ mod tests { ) .with_sender( Identifier::from(SENDER_ID), + SENDER_KEY_ID, private_contact_request_outgoing_viewing_key(&seed_secret_key( &sender_key_path(), )), @@ -1644,12 +1738,20 @@ mod tests { english_resolve(ctx, wallet_id_bytes, out_buf, out_capacity, out_len) } - /// The entry the signer seals is byte for byte the one a resident - /// wallet seals with the key at the same path, so a request sent - /// through the Keychain opens and authenticates for every client. - #[test] - fn should_seal_the_entry_a_seed_derived_sender_key_seals() { + /// 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) }; @@ -1660,29 +1762,31 @@ mod tests { &contract, &sender(), SENDER_KEY_ID, + SIGNING_KEY_ID, + &signature, &recipient(), RECIPIENT_KEY_ID, &request(), &[7; 32], ) .expect("the signer seals the request"); - assert_eq!(sealed, sent_to_recipient(&contract, 7)); + assert_eq!(sealed, sent_to_recipient(&contract, 7).await); unsafe { dash_sdk_mnemonic_resolver_destroy(resolver) }; } - /// A page scanned and read through the signer sorts and reads as with - /// a resident wallet's scan keys: the request to the recipient, with - /// its sender checked, and the same request recovered as sent by the - /// sender's outgoing viewing key, while another wallet's entry is - /// passed over. - #[test] - fn should_scan_and_read_a_page_as_seed_derived_scan_keys_do() { + /// 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![ - seed_sealed(&contract, &outsider(), &outsider_secret_key(), &carol(), 1), - sent_to_recipient(&contract, 2), + sent_to_carol(&contract, 1).await, + sent_to_recipient(&contract, 2).await, ]); let resolver = make_resolver(english_resolve); let signer = @@ -1698,51 +1802,54 @@ mod tests { &page, ) .expect("the signer scans the page"); - let seed_keys = seed_scan_keys(); - let expected_scan = seed_keys.scan_page(&target, 0, &page); - assert_eq!(scan, expected_scan); + 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 sender_identities = BTreeMap::from([(Identifier::from(SENDER_ID), Some(sender()))]); - let read = signer - .read_private_contact_requests( - &recipient_paths(), - &contract, - scan, - &sender_identities, - PlatformVersion::latest(), - ) - .expect("the signer reads the requests"); - let expected_read = private_contact_requests_from_scan( + let identities: BTreeMap<_, _> = [sender(), recipient()] + .into_iter() + .map(|identity| (identity.id(), Some(identity))) + .collect(); + assert_eq!( + private_contact_request_identities(&scan), + identities.keys().copied().collect(), + "the read checks the sender and the recipient" + ); + let read = private_contact_requests_from_scan( &contract, - expected_scan, - &seed_keys, - &sender_identities, + scan, + &identities, PlatformVersion::latest(), ) - .expect("the resident wallet reads the requests"); - assert_eq!(read, expected_read); + .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()); - assert!(read.rejected.is_empty()); unsafe { dash_sdk_mnemonic_resolver_destroy(resolver) }; } - /// Every key a scan or a read needs comes from one Keychain read, not - /// one per key. - #[test] - fn should_resolve_the_mnemonic_once_per_scan_and_once_per_read() { + /// 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)]); + 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( @@ -1765,7 +1872,7 @@ mod tests { ], )]; - let scan = signer + signer .scan_private_document_page( &recipients, &sender_paths(), @@ -1777,16 +1884,20 @@ mod tests { .expect("the signer scans the page"); assert_eq!(resolves.load(Ordering::SeqCst), 1); - let sender_identities = BTreeMap::from([(Identifier::from(SENDER_ID), Some(sender()))]); signer - .read_private_contact_requests( - &recipients, + .seal_private_contact_request( + &sender_key_path(), &contract, - scan, - &sender_identities, - PlatformVersion::latest(), + &sender(), + SENDER_KEY_ID, + SIGNING_KEY_ID, + &signature, + &recipient(), + RECIPIENT_KEY_ID, + &request(), + &[4; 32], ) - .expect("the signer reads the requests"); + .expect("the signer seals the request"); assert_eq!(resolves.load(Ordering::SeqCst), 2); unsafe { dash_sdk_mnemonic_resolver_destroy(resolver) }; @@ -1794,9 +1905,16 @@ mod tests { /// 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. - #[test] - fn should_refuse_to_seal_with_a_key_the_sender_does_not_hold() { + #[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) }; @@ -1807,6 +1925,8 @@ mod tests { &contract, &sender(), SENDER_KEY_ID, + SIGNING_KEY_ID, + &signature, &recipient(), RECIPIENT_KEY_ID, &request(), @@ -1817,6 +1937,53 @@ mod tests { 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], + ) + .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) }; } From 05e2bdeb64290219a81e4c1a4df007e796ea0924 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Sun, 8 Nov 2026 17:58:47 +0700 Subject: [PATCH 012/269] fix(platform-wallet): take the identity signer in the private contact request FFI The private send and accept now take the identity signer the public send and accept take, and pass it to the wallet, which signs the request with it: platform_wallet_manager_send_private_contact_request(handle, wallet_id_bytes, mnemonic_resolver_handle, account, sender_identity_id, recipient_identity_id, account_label, auto_accept_proof, auto_accept_proof_len, shielded_address, signer_handle, out_request_handle) platform_wallet_manager_accept_private_contact_request(handle, wallet_id_bytes, mnemonic_resolver_handle, account, request_handle, shielded_address, signer_handle, out_established_handle) `signer_handle` is a `*mut SignerHandle` (a `VTableSigner`), crossed into the worker as an address and re-materialized there, as `platform_wallet_send_contact_request_with_signer` does. A null signer is `ErrorNullPointer` before the wallet is touched. `ResolverContactCryptoProvider` follows the new provider trait: no `read_private_contact_requests`, and the seal passes the signing key id and signature through. `managed_identity_get_private_contact` maps the owned `PrivateContact`; its docs say the flags now hold across restarts and only the shielded address waits for a sync. Code 68 (`ErrorPrivateContactRequestNeedsPrivateReply`) also covers a public send to a contact whose requests came or went privately; its docs and the registry row say so. Co-Authored-By: Claude Opus 5.5 --- .../ERROR_CODE_REGISTRY.md | 2 +- .../rs-platform-wallet-ffi/src/dashpay.rs | 27 +--- packages/rs-platform-wallet-ffi/src/error.rs | 17 ++- .../src/private_contact_request.rs | 130 +++++++++++++++--- 4 files changed, 126 insertions(+), 50 deletions(-) diff --git a/packages/rs-platform-wallet-ffi/ERROR_CODE_REGISTRY.md b/packages/rs-platform-wallet-ffi/ERROR_CODE_REGISTRY.md index 1909407eccf..1d9f095a17b 100644 --- a/packages/rs-platform-wallet-ffi/ERROR_CODE_REGISTRY.md +++ b/packages/rs-platform-wallet-ffi/ERROR_CODE_REGISTRY.md @@ -181,7 +181,7 @@ 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 public accept refused a contact request that came privately (protocol version 15): a `contactRequest` document in reply would publish the relationship. Nothing was sent; the host accepts it with `platform_wallet_manager_accept_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 | +| 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_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 | **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/dashpay.rs b/packages/rs-platform-wallet-ffi/src/dashpay.rs index b7ed4f9fb4a..0dbe5026682 100644 --- a/packages/rs-platform-wallet-ffi/src/dashpay.rs +++ b/packages/rs-platform-wallet-ffi/src/dashpay.rs @@ -32,17 +32,15 @@ //! established contact via their `DashpayExternalAccount`. Runs //! the Core transaction path internally. -use std::collections::BTreeMap; use std::ffi::CStr; use std::os::raw::c_char; -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::{Identity, KeyID}; use dpp::platform_value::BinaryData; use dpp::prelude::{DataContract, Identifier}; -use dpp::version::PlatformVersion; use platform_wallet::{ContactRequest, PlatformWalletError, PrivateDocumentKeyPaths}; use rs_sdk_ffi::{ MnemonicResolverCoreSigner, MnemonicResolverHandle, MnemonicResolverSignerError, SignerHandle, @@ -1266,31 +1264,14 @@ impl platform_wallet::ContactCryptoProvider for ResolverContactCryptoProvider { .map_err(private_document_signer_error) } - async fn read_private_contact_requests( - &self, - keys: &PrivateDocumentKeyPaths, - dashpay_contract: &DataContract, - scan: PrivateDocumentScan, - sender_identities: &BTreeMap>, - platform_version: &PlatformVersion, - ) -> Result { - self.signer - .read_private_contact_requests( - &keys.recipients, - dashpay_contract, - scan, - sender_identities, - platform_version, - ) - .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, @@ -1302,6 +1283,8 @@ impl platform_wallet::ContactCryptoProvider for ResolverContactCryptoProvider { dashpay_contract, sender, sender_encryption_key_id, + signing_key_id, + signature, recipient, recipient_key_id, request, diff --git a/packages/rs-platform-wallet-ffi/src/error.rs b/packages/rs-platform-wallet-ffi/src/error.rs index 75958a5441a..5085f8047c3 100644 --- a/packages/rs-platform-wallet-ffi/src/error.rs +++ b/packages/rs-platform-wallet-ffi/src/error.rs @@ -623,11 +623,13 @@ pub enum PlatformWalletFFIResultCode { ErrorAssetLockTooManyInputs = 60, /// Maps `PlatformWalletError::PrivateContactRequestNeedsPrivateReply`: - /// the contact request came privately (protocol version 15), so a public - /// `contactRequest` reply would publish the relationship. Nothing was - /// sent. Accept it with - /// `platform_wallet_manager_accept_private_contact_request` instead. - /// 61 to 67 are claimed by open branches, so this takes 68. + /// 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_private_contact_request`, + /// instead. 61 to 67 are claimed by open branches, so this takes 68. ErrorPrivateContactRequestNeedsPrivateReply = 68, /// The named thing does not exist. @@ -1067,8 +1069,9 @@ impl From for PlatformWalletFFIResult { PlatformWalletError::SpvProcessRestartRequired(..) => { PlatformWalletFFIResultCode::ErrorSpvProcessRestartRequired } - // The public accept refused a request that came privately; the - // host routes it to the private accept. + // 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 } diff --git a/packages/rs-platform-wallet-ffi/src/private_contact_request.rs b/packages/rs-platform-wallet-ffi/src/private_contact_request.rs index 3adb8a69ca0..a8eea803720 100644 --- a/packages/rs-platform-wallet-ffi/src/private_contact_request.rs +++ b/packages/rs-platform-wallet-ffi/src/private_contact_request.rs @@ -8,22 +8,27 @@ //! contact-request rows and accessors read it. //! //! - [`platform_wallet_sync_private_contact_requests`]: read the store for the -//! wallet's identities and ingest what it holds for them. Every key stays in -//! the resolver-backed signer. +//! wallet's identities and ingest what it holds for them. The store is +//! opened with keys that stay in the resolver-backed signer; each request's +//! signature is checked against its sender's keys, which needs no secret. //! - [`platform_wallet_manager_send_private_contact_request`] / //! [`platform_wallet_manager_accept_private_contact_request`]: send, or //! accept with a private reply, paying from an account's shielded notes. -//! Built with the `shielded` feature only. +//! The request is signed by the identity signer the public send and accept +//! take, and sealed in the resolver-backed signer. Built with the +//! `shielded` feature only. //! - [`managed_identity_get_private_contact`]: what a contact's private //! requests carry beyond the `ContactRequest`: whether they came or went //! privately, and the contact's shielded address. //! -//! A request that came privately is refused by the public accept with -//! `ErrorPrivateContactRequestNeedsPrivateReply`; the host routes it to the -//! private accept. +//! The public send and accept refuse a contact whose requests came or went +//! privately with `ErrorPrivateContactRequestNeedsPrivateReply`; the host +//! routes the request to the private send or accept. use platform_wallet::PrivateContact; use rs_sdk_ffi::MnemonicResolverHandle; +#[cfg(feature = "shielded")] +use rs_sdk_ffi::{SignerHandle, VTableSigner}; use crate::dashpay::{resolver_contact_crypto_provider, ContactRequestHandleArray}; use crate::error::*; @@ -90,9 +95,9 @@ fn saturating_count(count: usize) -> u32 { /// their DashPay accounts are queued as the document sync does. /// /// `core_signer_handle` is the wallet-HD resolver signer: the store is opened -/// and senders are checked with keys derived inside it. The cursors live in -/// memory only, so the first pass of a process reads the store from its -/// start. +/// with keys derived inside it. Each request's signature is checked against +/// its sender's keys, which needs no secret. The cursors live in memory only, +/// so the first pass of a process reads the store from its start. /// /// Writes the requests the pass ingested, received and sent (including ones /// already held), to `out_requests` (release with @@ -184,8 +189,8 @@ impl PrivateContactFFI { } } -impl From<&PrivateContact> for PrivateContactFFI { - fn from(contact: &PrivateContact) -> Self { +impl From for PrivateContactFFI { + fn from(contact: PrivateContact) -> Self { Self { received_privately: contact.received_privately, sent_privately: contact.sent_privately, @@ -198,9 +203,12 @@ impl From<&PrivateContact> for PrivateContactFFI { } /// Read what this identity's private contact requests with `contact_id` -/// carry into `out_contact`. Returns `NotFound` when no request between them -/// went privately, as far as this process has seen: the record is in memory -/// only and rebuilt by `platform_wallet_sync_private_contact_requests`. +/// carry into `out_contact`. Returns `NotFound` when neither tracked request +/// between them came or went privately. Whether one did is the stored +/// request's own, so it holds across restarts; the contact's shielded address +/// is kept in memory only and rebuilt by +/// `platform_wallet_sync_private_contact_requests`, so until a pass has run +/// it is absent. /// /// # Safety /// - `contact_id` must point to 32 readable bytes. @@ -250,9 +258,11 @@ unsafe fn read_shielded_address(ptr: *const u8) -> Option<[u8; SHIELDED_ADDRESS_ /// key inside the resolver-backed signer. /// /// `mnemonic_resolver_handle` supplies both the transient Orchard spend -/// authority and the DashPay keys. Writes a handle into -/// `CONTACT_REQUEST_STORAGE` to `out_request_handle` (release with -/// `contact_request_destroy`). +/// authority and the DashPay keys. `signer_handle` is the identity signer +/// the public send takes (`platform_wallet_send_contact_request_with_signer`): +/// it signs the request with one of the sender's authentication keys, as it +/// signs a transition. Writes a handle into `CONTACT_REQUEST_STORAGE` to +/// `out_request_handle` (release with `contact_request_destroy`). /// /// Errors follow the shielded spend contract: `ErrorShieldedSpendUnconfirmed` /// means the payment may already be on chain; do not send again. @@ -268,6 +278,9 @@ unsafe fn read_shielded_address(ptr: *const u8) -> Option<[u8; SHIELDED_ADDRESS_ /// `auto_accept_proof_len` readable bytes; `(null, 0)` omits it. /// - `shielded_address`, when non-null, must point to 43 readable bytes; /// null omits it. +/// - `signer_handle` must be a valid, non-destroyed `*mut SignerHandle` +/// produced by `dash_sdk_signer_create_with_ctx`; the caller retains +/// ownership and pins it for the call. /// - `out_request_handle` must be a valid, writable pointer. #[cfg(feature = "shielded")] #[no_mangle] @@ -283,6 +296,7 @@ pub unsafe extern "C" fn platform_wallet_manager_send_private_contact_request( auto_accept_proof: *const u8, auto_accept_proof_len: usize, shielded_address: *const u8, + signer_handle: *mut SignerHandle, out_request_handle: *mut Handle, ) -> PlatformWalletFFIResult { // A panic in the proving task must not reach this `extern "C"` frame; the @@ -300,6 +314,7 @@ pub unsafe extern "C" fn platform_wallet_manager_send_private_contact_request( auto_accept_proof, auto_accept_proof_len, shielded_address, + signer_handle, out_request_handle, ) }) @@ -323,10 +338,12 @@ unsafe fn send_private_contact_request_inner( auto_accept_proof: *const u8, auto_accept_proof_len: usize, shielded_address: *const u8, + signer_handle: *mut SignerHandle, out_request_handle: *mut Handle, ) -> PlatformWalletFFIResult { check_ptr!(wallet_id_bytes); check_ptr!(mnemonic_resolver_handle); + check_ptr!(signer_handle); check_ptr!(out_request_handle); unsafe { *out_request_handle = NULL_HANDLE }; @@ -358,10 +375,16 @@ unsafe fn send_private_contact_request_inner( let crypto = unsafe { resolver_contact_crypto_provider(mnemonic_resolver_handle, wallet_id, wallet.network()) }; + // The signer pointer crosses into the worker as a plain address and is + // re-materialized there, as the public send does. + let signer_addr = signer_handle as usize; // The proof runs on a worker thread: Halo 2 synthesis recurses past the // iOS dispatch-thread stack. let result = block_on_worker(async move { + // SAFETY: the caller pins the signer for the duration of this call, + // and `block_on_worker` blocks this frame until the task completes. + let signer: &VTableSigner = &*(signer_addr as *const VTableSigner); let prover = CachedOrchardProver::new(); let result = wallet .send_private_contact_request( @@ -373,6 +396,7 @@ unsafe fn send_private_contact_request_inner( label, AutoAcceptProofSource::from_option(proof), shielded_address, + signer, &crypto, &prover, ) @@ -396,7 +420,9 @@ unsafe fn send_private_contact_request_inner( /// privately; null omits it. /// /// `request_handle` is a live `CONTACT_REQUEST_STORAGE` handle of the -/// incoming request. Writes a handle into `ESTABLISHED_CONTACT_STORAGE` to +/// incoming request. `signer_handle` is the identity signer the public accept +/// takes (`platform_wallet_accept_contact_request_with_signer`); it signs the +/// reply. Writes a handle into `ESTABLISHED_CONTACT_STORAGE` to /// `out_established_handle` (release with `established_contact_destroy`). /// /// # Safety @@ -404,9 +430,13 @@ unsafe fn send_private_contact_request_inner( /// - `mnemonic_resolver_handle` must be a valid, non-destroyed handle; the /// caller retains ownership and pins it for the call. /// - `shielded_address`, when non-null, must point to 43 readable bytes. +/// - `signer_handle` must be a valid, non-destroyed `*mut SignerHandle` +/// produced by `dash_sdk_signer_create_with_ctx`; the caller retains +/// ownership and pins it for the call. /// - `out_established_handle` must be a valid, writable pointer. #[cfg(feature = "shielded")] #[no_mangle] +#[allow(clippy::too_many_arguments)] pub unsafe extern "C" fn platform_wallet_manager_accept_private_contact_request( handle: Handle, wallet_id_bytes: *const u8, @@ -414,6 +444,7 @@ pub unsafe extern "C" fn platform_wallet_manager_accept_private_contact_request( account: u32, request_handle: Handle, shielded_address: *const u8, + signer_handle: *mut SignerHandle, out_established_handle: *mut Handle, ) -> PlatformWalletFFIResult { // Same panic contract as the send: the reply's notes may be spent. @@ -425,6 +456,7 @@ pub unsafe extern "C" fn platform_wallet_manager_accept_private_contact_request( account, request_handle, shielded_address, + signer_handle, out_established_handle, ) }) @@ -436,6 +468,7 @@ pub unsafe extern "C" fn platform_wallet_manager_accept_private_contact_request( /// # Safety /// Identical contract to the export that calls it. #[cfg(feature = "shielded")] +#[allow(clippy::too_many_arguments)] unsafe fn accept_private_contact_request_inner( handle: Handle, wallet_id_bytes: *const u8, @@ -443,10 +476,12 @@ unsafe fn accept_private_contact_request_inner( account: u32, request_handle: Handle, shielded_address: *const u8, + signer_handle: *mut SignerHandle, out_established_handle: *mut Handle, ) -> PlatformWalletFFIResult { check_ptr!(wallet_id_bytes); check_ptr!(mnemonic_resolver_handle); + check_ptr!(signer_handle); check_ptr!(out_established_handle); unsafe { *out_established_handle = NULL_HANDLE }; @@ -469,8 +504,11 @@ unsafe fn accept_private_contact_request_inner( let crypto = unsafe { resolver_contact_crypto_provider(mnemonic_resolver_handle, wallet_id, wallet.network()) }; + let signer_addr = signer_handle as usize; let result = block_on_worker(async move { + // SAFETY: as in the send, the caller pins the signer for the call. + let signer: &VTableSigner = &*(signer_addr as *const VTableSigner); let prover = CachedOrchardProver::new(); let result = wallet .accept_private_contact_request( @@ -479,6 +517,7 @@ unsafe fn accept_private_contact_request_inner( account, &request, shielded_address, + signer, &crypto, &prover, ) @@ -546,10 +585,61 @@ mod tests { assert_eq!(result.code, PlatformWalletFFIResultCode::ErrorNullPointer); } + /// The request is signed by the identity signer, so the private send + /// refuses to run without one, before it touches the wallet. + #[cfg(feature = "shielded")] + #[test] + fn should_reject_a_null_identity_signer_on_the_private_send() { + let wallet_id = [1u8; 32]; + let sender = [2u8; 32]; + let recipient = [3u8; 32]; + let resolver = std::ptr::NonNull::::dangling().as_ptr(); + let mut out = 0xDEAD_BEEF; + let result = unsafe { + platform_wallet_manager_send_private_contact_request( + 0xDEAD_BEEF, + wallet_id.as_ptr(), + resolver, + 0, + sender.as_ptr(), + recipient.as_ptr(), + std::ptr::null(), + std::ptr::null(), + 0, + std::ptr::null(), + std::ptr::null_mut(), + &mut out, + ) + }; + assert_eq!(result.code, PlatformWalletFFIResultCode::ErrorNullPointer); + } + + /// As the private send: the reply is signed by the identity signer. + #[cfg(feature = "shielded")] + #[test] + fn should_reject_a_null_identity_signer_on_the_private_accept() { + let wallet_id = [1u8; 32]; + let resolver = std::ptr::NonNull::::dangling().as_ptr(); + let mut out = 0xDEAD_BEEF; + let result = unsafe { + platform_wallet_manager_accept_private_contact_request( + 0xDEAD_BEEF, + wallet_id.as_ptr(), + resolver, + 0, + 0xDEAD_BEEF, + std::ptr::null(), + std::ptr::null_mut(), + &mut out, + ) + }; + assert_eq!(result.code, PlatformWalletFFIResultCode::ErrorNullPointer); + } + #[test] fn should_carry_every_field_of_a_private_contact() { let address = [0x55; SHIELDED_ADDRESS_LEN]; - let with_address = PrivateContactFFI::from(&PrivateContact { + let with_address = PrivateContactFFI::from(PrivateContact { received_privately: true, sent_privately: false, shielded_address: Some(address), @@ -559,7 +649,7 @@ mod tests { assert!(with_address.has_shielded_address); assert_eq!(with_address.shielded_address, address); - let without_address = PrivateContactFFI::from(&PrivateContact { + let without_address = PrivateContactFFI::from(PrivateContact { received_privately: false, sent_privately: true, shielded_address: None, From c83c19fb7a9d90461613b1740f92ccad45b2431b Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Sun, 8 Nov 2026 18:00:16 +0700 Subject: [PATCH 013/269] fix(swift-sdk): sign private contact requests with the identity signer `PlatformWalletManager.sendPrivateContactRequest` and `acceptPrivateContactRequest` take `signer: KeychainSigner`, last as in `sendContactRequest` and `acceptContactRequest`, and keep it alive across the FFI call with the resolver. The docs say the private request is signed by the same identity signer, that `privateContact(contactId:)` holds across launches (only the shielded address waits for a sync), and that the public send also throws `privateContactRequestNeedsPrivateReply` for a contact whose requests came or went privately. The `ErrorHandlingTests` pin of code 68 carries the wallet's new message. Co-Authored-By: Claude Opus 5.5 --- .../PlatformWallet/PlatformWalletResult.swift | 14 ++-- .../PrivateContactRequests.swift | 70 ++++++++++++------- .../ErrorHandlingTests.swift | 4 +- 3 files changed, 55 insertions(+), 33 deletions(-) diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift index 2c9abc26201..2ea5fa7e59c 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift @@ -265,8 +265,9 @@ public enum PlatformWalletResultCode: Int32, Sendable { /// An asset lock (a drain, or a fixed amount in many small coins) exceeds /// the input cap; nothing was broadcast. case errorAssetLockTooManyInputs = 60 - /// The contact request came privately, so a public reply would publish - /// the relationship. Nothing was sent; accept it privately. + /// The contact requests with that identity came or went privately, so a + /// public request to it, in reply or as a rotation, would publish the + /// relationship. Nothing was sent; accept or send it privately. case errorPrivateContactRequestNeedsPrivateReply = 68 /// The named thing does not exist. Besides the handle/lookup failures this /// has always covered, BOTH deferred-send paths report the @@ -592,9 +593,12 @@ public enum PlatformWalletError: LocalizedError { /// tracked lock over the cap: it is not re-sent (an earlier attempt may /// have reached the network), it stays tracked, and nothing is released. case assetLockTooManyInputs(String) - /// The contact request came privately (protocol version 15): a public - /// reply would publish the relationship, so nothing was sent. Accept it - /// with `PlatformWalletManager.acceptPrivateContactRequest`. + /// The contact requests with that identity came or went privately + /// (protocol version 15): a public request to it, in reply or as a + /// rotation, would publish the relationship, so the public accept and + /// send refuse it and nothing was sent. Accept it with + /// `PlatformWalletManager.acceptPrivateContactRequest`, or send with + /// `PlatformWalletManager.sendPrivateContactRequest`. case privateContactRequestNeedsPrivateReply(String) case walletAlreadyExists(String) /// Definitive shielded-broadcast failure: the shielded transition diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PrivateContactRequests.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PrivateContactRequests.swift index d4337dc486f..13debee5447 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PrivateContactRequests.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PrivateContactRequests.swift @@ -5,17 +5,20 @@ import DashSDKFFI // // A private contact request is sealed to one of the recipient's DashPay keys // and paid for from the shielded pool, so nothing on chain names either side. -// The wallet records it as an ordinary `ContactRequest`; what that type has no -// field for (that it came privately, and the contact's shielded address) is -// read with `ManagedIdentity.privateContact(contactId:)`. +// It is signed by the same identity signer as a public request. The wallet +// records it as an ordinary `ContactRequest`; whether it came or went +// privately, and the contact's shielded address, are read with +// `ManagedIdentity.privateContact(contactId:)`. // -// Routing an accept: the public `acceptContactRequest` throws -// `PlatformWalletError.privateContactRequestNeedsPrivateReply` for a request -// that came privately; accept it with -// `PlatformWalletManager.acceptPrivateContactRequest` instead. +// Routing: the public `acceptContactRequest` and `sendContactRequest` throw +// `PlatformWalletError.privateContactRequestNeedsPrivateReply` for a contact +// whose requests came or went privately; accept it with +// `PlatformWalletManager.acceptPrivateContactRequest`, or send with +// `PlatformWalletManager.sendPrivateContactRequest`, instead. -/// What an identity's private contact requests with one contact carry beyond -/// the `ContactRequest`. Rust keeps it in memory only and rebuilds it with +/// How an identity and one contact exchanged private contact requests. Rust +/// reads it from the stored requests, so it holds across launches; only the +/// contact's shielded address is kept in memory, rebuilt by /// `ManagedPlatformWallet.syncPrivateContactRequests`. public struct PrivateContactInfo: Sendable, Equatable { /// The contact's request came privately, so accepting it replies @@ -23,7 +26,9 @@ public struct PrivateContactInfo: Sendable, Equatable { public let receivedPrivately: Bool /// This identity's request to the contact went privately. public let sentPrivately: Bool - /// The 43-byte Orchard address the contact asked to be paid at privately. + /// The 43-byte Orchard address the contact asked to be paid at + /// privately; only when `receivedPrivately`, and only once a sync this + /// launch has read it. public let shieldedAddress: Data? init(ffi: PrivateContactFFI) { @@ -56,9 +61,9 @@ public struct PrivateContactSyncResult: Sendable { } extension ManagedIdentity { - /// What this identity's private contact requests with `contactId` carry, - /// or `nil` when none between them went privately as far as this process - /// has seen. + /// How this identity and `contactId` exchanged private contact requests, + /// or `nil` when neither tracked request between them came or went + /// privately. public func privateContact(contactId: Identifier) throws -> PrivateContactInfo? { var ffi = PrivateContactFFI() let result = contactId.withFFIBytes { idPtr in @@ -78,10 +83,12 @@ extension ManagedPlatformWallet { /// and ingest what it holds for them: requests to them, each sender /// checked, and requests they sent, recovered from the store alone. /// - /// The keys stay in the Keychain mnemonic resolver Rust-side. The store - /// cursors live in memory only, so the first pass after launch reads the - /// store from its start, and until it has run the public accept cannot - /// tell that a request came privately. + /// The store is opened with keys that stay in the Keychain mnemonic + /// resolver Rust-side; each request's signature is checked against its + /// sender's keys. The store cursors live in memory only, so the first + /// pass after launch reads the store from its start. A request it + /// ingests is stored as having come privately, which holds across + /// launches. public func syncPrivateContactRequests( maxPages: UInt32 = .max ) async throws -> PrivateContactSyncResult { @@ -130,8 +137,11 @@ extension PlatformWalletManager { /// 43-byte Orchard address the contact may pay us at privately. /// /// `resolver` supplies both the Orchard spend authority and the DashPay - /// keys. Throws `PlatformWalletError.shieldedSpendUnconfirmed` when the - /// payment may already be on chain: do not send again. + /// keys. `signer` is the identity signer `sendContactRequest` takes: it + /// signs the request with one of the sender's authentication keys, as it + /// signs a public request's transition. Throws + /// `PlatformWalletError.shieldedSpendUnconfirmed` when the payment may + /// already be on chain: do not send again. public func sendPrivateContactRequest( walletId: Data, resolver: MnemonicResolver, @@ -140,20 +150,23 @@ extension PlatformWalletManager { recipientIdentityId: Identifier, accountLabel: String? = nil, autoAcceptProof: Data? = nil, - shieldedAddress: Data? = nil + shieldedAddress: Data? = nil, + signer: KeychainSigner ) async throws -> ContactRequest { let handle = try privateContactRequestManagerHandle( walletId: walletId, resolver: resolver, shieldedAddress: shieldedAddress ) + let signerHandle = signer.handle let requestHandle: Handle = try await Task.detached(priority: .userInitiated) { () -> Handle in var outHandle: Handle = NULL_HANDLE let resolverHandle = resolver.handle - // Guaranteed resolver keepalive across the FFI call: same - // rationale as `shieldedTransfer`. - try withExtendedLifetime(resolver) { + // Guaranteed keepalive of the resolver and the signer across the + // FFI call, whose vtable callbacks fire during it: same rationale + // as `shieldedTransfer`. + try withExtendedLifetime((resolver, signer)) { try walletId.withFFIBytes { widPtr in try senderIdentityId.withFFIBytes { senderPtr in try recipientIdentityId.withFFIBytes { recipientPtr in @@ -171,6 +184,7 @@ extension PlatformWalletManager { proofPtr, proofLen, addressPtr, + signerHandle, &outHandle ).check() } @@ -188,13 +202,15 @@ extension PlatformWalletManager { /// Accept a DashPay contact request that came privately, replying /// privately and paying for the reply from `account`'s shielded notes. /// `shieldedAddress` is the 43-byte Orchard address the contact may pay - /// us at privately. + /// us at privately. `signer` is the identity signer + /// `acceptContactRequest` takes; it signs the reply. public func acceptPrivateContactRequest( walletId: Data, resolver: MnemonicResolver, account: UInt32 = 0, request: ContactRequest, - shieldedAddress: Data? = nil + shieldedAddress: Data? = nil, + signer: KeychainSigner ) async throws -> EstablishedContact { let handle = try privateContactRequestManagerHandle( walletId: walletId, @@ -202,11 +218,12 @@ extension PlatformWalletManager { shieldedAddress: shieldedAddress ) let requestHandle = request.handle + let signerHandle = signer.handle let establishedHandle: Handle = try await Task.detached(priority: .userInitiated) { () -> Handle in var outHandle: Handle = NULL_HANDLE let resolverHandle = resolver.handle - try withExtendedLifetime((resolver, request)) { + try withExtendedLifetime((resolver, signer, request)) { try walletId.withFFIBytes { widPtr in try withOptionalBytes(shieldedAddress) { addressPtr, _ in try platform_wallet_manager_accept_private_contact_request( @@ -216,6 +233,7 @@ extension PlatformWalletManager { account, requestHandle, addressPtr, + signerHandle, &outHandle ).check() } diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/ErrorHandlingTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/ErrorHandlingTests.swift index c7f1f5909f4..0cedd0ba809 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/ErrorHandlingTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/ErrorHandlingTests.swift @@ -465,8 +465,8 @@ final class ErrorHandlingTests: XCTestCase { ) XCTAssertEqual(code, .errorPrivateContactRequestNeedsPrivateReply) XCTAssertEqual(code.rawValue, 68) - let detail = "the contact request from 9fRYTJZW came privately; " - + "accept it with a private reply" + let detail = "the contact requests with 9fRYTJZW came or went privately; " + + "reply to or rotate with it privately" let error = PlatformWalletError(code: code, message: detail) guard case .privateContactRequestNeedsPrivateReply(let message) = error else { return XCTFail("lost typed private-reply error") From ff3791cf79c5fd1e6ebd1b8cf93eec84fdd1e2a5 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Sun, 8 Nov 2026 18:00:16 +0700 Subject: [PATCH 014/269] fix(swift-example-app): sign private contact requests with the Keychain signer Add Contact's private send and the private-accept fallback pass the `KeychainSigner` the public send and accept already use. Co-Authored-By: Claude Opus 5.5 --- .../Views/DashPay/AddContactView.swift | 6 ++++-- .../Views/DashPay/ContactsView.swift | 14 ++++++++------ 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/AddContactView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/AddContactView.swift index 285c0e00123..6576a9006e1 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/AddContactView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/AddContactView.swift @@ -466,16 +466,18 @@ struct AddContactView: View { do { let wallet = try requireWallet() let label = accountLabel.trimmingCharacters(in: .whitespacesAndNewlines) + // Both sends sign with the same identity signer. + let signer = KeychainSigner(modelContainer: modelContext.container) if sendPrivately { _ = try await walletManager.sendPrivateContactRequest( walletId: wallet.walletId, resolver: MnemonicResolver(), senderIdentityId: identity.identityId, recipientIdentityId: recipient, - accountLabel: label.isEmpty ? nil : label + accountLabel: label.isEmpty ? nil : label, + signer: signer ) } else { - let signer = KeychainSigner(modelContainer: modelContext.container) _ = try await wallet.sendContactRequest( senderIdentityId: identity.identityId, recipientIdentityId: recipient, diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ContactsView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ContactsView.swift index 538b185138a..4083f1e03a9 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ContactsView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ContactsView.swift @@ -228,16 +228,17 @@ func kickDashPaySync(_ walletManager: PlatformWalletManager) { /// Read DashPay's private contact request store (protocol version 15) for /// `wallet`. Best effort: a network before protocol version 15 has no such -/// store. Which requests came privately is kept in memory only, so this runs -/// with every pull-to-refresh of the request list. +/// store. Private requests reach the wallet only through this read, so it +/// runs with every pull-to-refresh of the request list. @MainActor func syncPrivateContactRequests(_ wallet: ManagedPlatformWallet) async { _ = try? await wallet.syncPrivateContactRequests() } -/// Accept `request`. Rust refuses a public reply to a request that came -/// privately, as it would publish the relationship; that one is accepted with -/// a private reply instead, paid from the wallet's shielded balance. +/// Accept `request`. Rust refuses a public reply to a contact whose requests +/// came privately, as it would publish the relationship; that one is accepted +/// with a private reply instead, paid from the wallet's shielded balance and +/// signed by the same `signer`. @MainActor func acceptIncomingContactRequest( _ request: ContactRequest, @@ -251,7 +252,8 @@ func acceptIncomingContactRequest( _ = try await walletManager.acceptPrivateContactRequest( walletId: wallet.walletId, resolver: MnemonicResolver(), - request: request + request: request, + signer: signer ) } } From 4ad0b1d8ea5720dc02a4cfc54991f8373f356a00 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Sun, 8 Nov 2026 18:17:32 +0700 Subject: [PATCH 015/269] feat(kotlin-sdk): bridge DashPay private contact requests and type code 68 Ports what Swift's `PrivateContactRequests.swift` exposes, over the same platform-wallet-ffi functions: - `Dashpay.syncPrivateContactRequests(coreSignerHandle, maxPages)` reads the private store for every identity of the wallet and returns a `PrivateContactSyncResult` (requests ingested, pages read, entries refused, store position, complete, identities not persisted). The requests reach Room through the persister, so the JNI bridge releases their handles instead of leaking them. - `PlatformWalletManager.sendPrivateContactRequest(...)` and `acceptPrivateContactRequest(walletId, request, ...)` are shielded spends, so they sit with the other manager-handle spends and use the manager's resolver and identity signer, the signer the public send and accept are given. The JNI passes the signer as `*mut SignerHandle`, as the public `sendContactRequest` does. Built with the `shielded` feature only. - `Dashpay.incomingContactRequest(ourIdentityId, senderId)` returns the request the private accept takes (Swift `ManagedIdentity.getIncomingContactRequest(senderId:)`). - Code 68 maps to `DashSdkError.PlatformWallet.PrivateContactRequestNeedsPrivateReply`, so a host routes a refused public accept to the private accept. The JNI refuses a shielded address that is not 43 bytes before the pointer crosses, since the FFI reads exactly 43. `managed_identity_get_private_contact` is not bridged: the Swift example app has no caller for it. Tests: `DashSdkErrorTest` pins code 68; the JNI crate pins the counter layout Kotlin decodes; `FfiSmokeTest` checks the three symbols bind and reject bad input. Co-Authored-By: Claude Opus 5.5 --- .../dashfoundation/dashsdk/FfiSmokeTest.kt | 30 ++ .../dashsdk/errors/DashSdkError.kt | 18 + .../dashsdk/ffi/DashpayNative.kt | 64 ++++ .../dashfoundation/dashsdk/tokens/Dashpay.kt | 82 +++++ .../dashsdk/wallet/PlatformWalletManager.kt | 86 +++++ .../dashsdk/errors/DashSdkErrorTest.kt | 16 + packages/rs-unified-sdk-jni/src/dashpay.rs | 320 +++++++++++++++++- 7 files changed, 613 insertions(+), 3 deletions(-) diff --git a/packages/kotlin-sdk/sdk/src/androidTest/kotlin/org/dashfoundation/dashsdk/FfiSmokeTest.kt b/packages/kotlin-sdk/sdk/src/androidTest/kotlin/org/dashfoundation/dashsdk/FfiSmokeTest.kt index d9bfe5ee81b..a1767679e48 100644 --- a/packages/kotlin-sdk/sdk/src/androidTest/kotlin/org/dashfoundation/dashsdk/FfiSmokeTest.kt +++ b/packages/kotlin-sdk/sdk/src/androidTest/kotlin/org/dashfoundation/dashsdk/FfiSmokeTest.kt @@ -4,6 +4,7 @@ import androidx.test.core.app.ApplicationProvider import androidx.test.ext.junit.runners.AndroidJUnit4 import kotlinx.coroutines.Dispatchers import org.dashfoundation.dashsdk.ffi.DashSDKException +import org.dashfoundation.dashsdk.ffi.DashpayNative import org.dashfoundation.dashsdk.ffi.NativeLoader import org.dashfoundation.dashsdk.ffi.SdkNative import org.dashfoundation.dashsdk.ffi.SignerNative @@ -94,6 +95,35 @@ class FfiSmokeTest { * covered by the shared Rust tests in * `packages/rs-platform-wallet/src/changeset/core_bridge.rs`. */ + /** + * DashPay private contact requests (protocol version 15): the three + * `DashpayNative` symbols bind with the descriptors Kotlin declares, and + * bad input surfaces as a mapped [DashSDKException] rather than a crash. + * A null resolver is refused before any wallet lookup, and a shielded + * address that is not 43 bytes never reaches the FFI (which reads + * exactly 43). The flows themselves need a protocol version 15 network + * with shielded funds; their Rust side is covered by the + * platform-wallet and platform-wallet-ffi tests. + */ + @Test + fun privateContactRequestSymbolsBindAndRejectInvalidInput() { + NativeLoader.ensureLoaded() + assertThrows(DashSDKException::class.java) { + DashpayNative.syncPrivateContactRequests(0L, 0L, 1) + } + assertThrows(DashSDKException::class.java) { + DashpayNative.sendPrivateContactRequest( + 0L, ByteArray(32), 0L, 0, ByteArray(32), ByteArray(32), + null, null, null, 0L, + ) + } + assertThrows(DashSDKException::class.java) { + DashpayNative.acceptPrivateContactRequest( + 0L, ByteArray(32), 0L, 0, 0L, ByteArray(42), 0L, + ) + } + } + @Test fun syncFaultDetectedSymbolBindsAndRejectsInvalidHandle() { NativeLoader.ensureLoaded() diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/errors/DashSdkError.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/errors/DashSdkError.kt index 9f19d1653a0..e5bf9a1d4ef 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/errors/DashSdkError.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/errors/DashSdkError.kt @@ -646,6 +646,21 @@ sealed class DashSdkError( override val userMessage: String get() = SPV_PROCESS_RESTART_REQUIRED_USER_MESSAGE } + /** + * `ErrorPrivateContactRequestNeedsPrivateReply` (native code 68). The + * contact requests with that identity came or went privately + * (protocol version 15), so a public request to it, in reply or as a + * rotation, would publish the relationship. The public accept and + * send refuse it and nothing was sent. Accept it with + * `PlatformWalletManager.acceptPrivateContactRequest`, or send with + * `PlatformWalletManager.sendPrivateContactRequest`. Not retryable + * as-is: the same public call is refused again. + * ← Swift `PlatformWalletError.privateContactRequestNeedsPrivateReply` + * (`PlatformWalletResult.swift`). + */ + class PrivateContactRequestNeedsPrivateReply(message: String, cause: Throwable? = null) : + PlatformWallet(message, cause) + /** * Any other `PlatformWalletFFIResultCode` without a dedicated type. * Carries the platform-wallet [nativeCode] (already de-offset) and @@ -842,6 +857,9 @@ sealed class DashSdkError( 56 -> PlatformWallet.ShieldedRecoveryCorrupted(message, cause) 57 -> PlatformWallet.ShieldedRecoveryKeysRequired(message, cause) 59 -> PlatformWallet.SpvProcessRestartRequired(message, cause) // ErrorSpvProcessRestartRequired + // ErrorPrivateContactRequestNeedsPrivateReply: the host routes the + // refused public accept (or send) to the private one. + 68 -> PlatformWallet.PrivateContactRequestNeedsPrivateReply(message, cause) else -> // @Deprecated fallback — see the code-6 arm; code 31 is the // real discriminator. diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/DashpayNative.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/DashpayNative.kt index 1144dad7add..3fb6fcc752f 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/DashpayNative.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/DashpayNative.kt @@ -263,4 +263,68 @@ internal object DashpayNative { signerHandle: Long, nowUnix: Long, ): IdentityRegistrationNativeResult + + // ── DashPay private contact requests (protocol version 15) ──────── + // + // ← Swift `PrivateContactRequests.swift` + // (packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PrivateContactRequests.swift). + + /** + * Read DashPay's private contact request store for every identity of + * the wallet, at most [maxPages] pages, and ingest what it holds for + * them. The store is opened with keys derived inside + * [coreSignerHandle] (the manager's `MnemonicResolverHandle`). Ingested + * requests reach Room through the persister; no handles cross. Returns + * six longs: requests ingested, pages read, entries refused, identities + * whose ingest did not reach disk, the store position the pass stopped + * at, and 1 when it read to the store's last entry (else 0). Blocking. + * ← Swift `ManagedPlatformWallet.syncPrivateContactRequests(maxPages:)`. + */ + external fun syncPrivateContactRequests( + walletHandle: Long, + coreSignerHandle: Long, + maxPages: Int, + ): LongArray? + + /** + * Send a DashPay private contact request, paid from [account]'s + * shielded notes on [walletId]. [resolverHandle] supplies the Orchard + * spend authority and the DashPay keys; [signerHandle] is the identity + * signer [TokensNative.sendContactRequest] takes. [shieldedAddress] + * must be 43 raw Orchard bytes when set. Needs the `shielded` native + * feature. Returns the created `ContactRequest` handle (destroy via + * [TokensNative.contactRequestDestroy]). + * ← Swift `PlatformWalletManager.sendPrivateContactRequest`. + */ + @Suppress("LongParameterList") + external fun sendPrivateContactRequest( + managerHandle: Long, + walletId: ByteArray, + resolverHandle: Long, + account: Int, + senderIdentityId: ByteArray, + recipientIdentityId: ByteArray, + accountLabel: String?, + autoAcceptProof: ByteArray?, + shieldedAddress: ByteArray?, + signerHandle: Long, + ): Long + + /** + * Accept the incoming contact request [requestHandle] with a private + * reply, paid from [account]'s shielded notes. Handles as for + * [sendPrivateContactRequest]. Returns the `EstablishedContact` handle + * (destroy via [TokensNative.establishedContactDestroy]). + * ← Swift `PlatformWalletManager.acceptPrivateContactRequest`. + */ + @Suppress("LongParameterList") + external fun acceptPrivateContactRequest( + managerHandle: Long, + walletId: ByteArray, + resolverHandle: Long, + account: Int, + requestHandle: Long, + shieldedAddress: ByteArray?, + signerHandle: Long, + ): Long } diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/tokens/Dashpay.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/tokens/Dashpay.kt index 62eb7d33457..6787b34bd56 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/tokens/Dashpay.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/tokens/Dashpay.kt @@ -276,6 +276,88 @@ class Dashpay internal constructor(private val walletHandle: Long, } } + // ── DashPay private contact requests (protocol version 15) ──────── + // + // ← Swift `PrivateContactRequests.swift` + // (packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PrivateContactRequests.swift). + // A private request is sealed to one of the recipient's DashPay keys and + // paid for from the shielded pool, so nothing on chain names either + // side; it is signed by the same identity signer as a public request. + // The wallet records it as an ordinary contact request, so the Room rows + // and [contacts] read it. The send and the private-reply accept are + // shielded spends and live on `PlatformWalletManager`. + + /** What one pass over DashPay's private contact request store did. */ + data class PrivateContactSyncResult( + /** The requests the pass ingested, received and sent, including ones already held. */ + val requestCount: Long, + /** The store pages read. */ + val pagesRead: Long, + /** Entries that opened for one of the wallet's identities but were refused. */ + val rejectedCount: Long, + /** The store position the pass stopped at. */ + val nextPosition: Long, + /** Whether the pass read to the store's last entry. When false, sync again to read on. */ + val complete: Boolean, + /** Identities whose ingest did not reach disk; the next pass reads their entries again. */ + val unpersistedIdentityCount: Long, + ) + + /** + * Read DashPay's private contact request store for every identity of + * this wallet, at most [maxPages] pages (the default reads to the end), + * and ingest what it holds for them: requests to them, each sender's + * signature checked, and requests they sent, recovered from the store + * alone. The ingested requests reach Room through the persister. + * + * The store is opened with keys derived inside [coreSignerHandle], the + * manager's `MnemonicResolverHandle`. The store cursors live in memory + * only, so the first pass after launch reads the store from its start. + * Private requests reach the wallet only through this read. + * ← Swift `ManagedPlatformWallet.syncPrivateContactRequests(maxPages:)`. + */ + suspend fun syncPrivateContactRequests( + coreSignerHandle: Long, + maxPages: Int = Int.MAX_VALUE, + ): PrivateContactSyncResult = gate.op { + val counters = mapNativeErrors { + DashpayNative.syncPrivateContactRequests(walletHandle, coreSignerHandle, maxPages) + } + checkNotNull(counters) { "native syncPrivateContactRequests returned no report" } + check(counters.size == 6) { "expected 6 sync counters, got ${counters.size}" } + PrivateContactSyncResult( + requestCount = counters[0], + pagesRead = counters[1], + rejectedCount = counters[2], + nextPosition = counters[4], + complete = counters[5] != 0L, + unpersistedIdentityCount = counters[3], + ) + } + + /** + * The incoming contact request from [senderId] to [ourIdentityId] in + * local state, or null when there is none (or the identity isn't + * managed by this wallet). Close the returned ref when done. This is + * the request `PlatformWalletManager.acceptPrivateContactRequest` + * takes when the public accept refuses a contact whose requests came + * privately ([DashSdkError.PlatformWallet.PrivateContactRequestNeedsPrivateReply]). + * Local read; no network I/O. + * ← Swift `ManagedIdentity.getIncomingContactRequest(senderId:)`. + */ + suspend fun incomingContactRequest( + ourIdentityId: ByteArray, + senderId: ByteArray, + ): ContactRequestRef? = withContext(Dispatchers.IO) { + mapNativeErrors { + withManagedIdentity(ourIdentityId) { identityHandle -> + val requestHandle = + TokensNative.getIncomingContactRequest(identityHandle, senderId) + if (requestHandle == 0L) null else ContactRequestRef(requestHandle) + } + } + } + // ── DashPay read surface (upstream #3841 parity) ─────────────────── // // JSON-string reads over the managed-identity snapshot, following the diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/PlatformWalletManager.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/PlatformWalletManager.kt index 5f074a73d5b..e1aae239b36 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/PlatformWalletManager.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/PlatformWalletManager.kt @@ -41,6 +41,8 @@ import org.dashfoundation.dashsdk.security.IdentityKeyPrivateKeyDeriver import org.dashfoundation.dashsdk.security.KeystoreSigner import org.dashfoundation.dashsdk.security.MnemonicResolverAndPersister import org.dashfoundation.dashsdk.security.WalletStorage +import org.dashfoundation.dashsdk.tokens.ContactRequestRef +import org.dashfoundation.dashsdk.tokens.EstablishedContactRef import java.util.concurrent.atomic.AtomicLong /** Effective native persistence contract exposed for initialization diagnostics. */ @@ -2129,6 +2131,90 @@ class PlatformWalletManager( } } + /** + * Send a DashPay private contact request (protocol version 15) from + * [senderIdentityId] to [recipientIdentityId], paying for it from + * [account]'s shielded notes on [walletId], so nothing on chain names + * either identity. Port of Swift's + * `PlatformWalletManager.sendPrivateContactRequest` + * (`packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PrivateContactRequests.swift`). + * + * The request is signed by this manager's identity signer + * ([signerHandle], the handle the public `Dashpay.sendContactRequest` + * is given) and sealed with DashPay keys derived through this manager's + * resolver, which also supplies the Orchard spend authority. + * [shieldedAddress] is the 43-byte raw Orchard address the contact may + * pay us at privately. Blocks for the Halo 2 proof. + * + * Throws [DashSdkError.PlatformWallet.ShieldedSpendUnconfirmed] when + * the payment may already be on chain: do not send again. + * + * @return the created request; close it (or `use {}`) when done. + */ + suspend fun sendPrivateContactRequest( + walletId: ByteArray, + senderIdentityId: ByteArray, + recipientIdentityId: ByteArray, + accountLabel: String? = null, + autoAcceptProof: ByteArray? = null, + shieldedAddress: ByteArray? = null, + account: Int = 0, + ): ContactRequestRef = teardownGate.op { + require(account >= 0) { "account must be non-negative, got $account" } + val handle = mapNativeErrors { + DashpayNative.sendPrivateContactRequest( + managerHandle, + walletId, + mnemonicResolver.nativeHandle, + account, + senderIdentityId, + recipientIdentityId, + accountLabel, + autoAcceptProof, + shieldedAddress, + signerHandle, + ) + } + ContactRequestRef(handle) + } + + /** + * Accept a DashPay contact request that came privately, replying + * privately and paying for the reply from [account]'s shielded notes on + * [walletId]. Port of Swift's + * `PlatformWalletManager.acceptPrivateContactRequest` + * (`packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PrivateContactRequests.swift`). + * + * The public `Dashpay.acceptContactRequest` refuses such a request with + * [DashSdkError.PlatformWallet.PrivateContactRequestNeedsPrivateReply]; + * read it with `Dashpay.incomingContactRequest` and accept it here. The + * reply is signed by this manager's identity signer ([signerHandle]). + * [shieldedAddress] is the 43-byte raw Orchard address the contact may + * pay us at privately. Blocks for the Halo 2 proof. + * + * @return the established contact; close it (or `use {}`) when done. + */ + suspend fun acceptPrivateContactRequest( + walletId: ByteArray, + request: ContactRequestRef, + shieldedAddress: ByteArray? = null, + account: Int = 0, + ): EstablishedContactRef = teardownGate.op { + require(account >= 0) { "account must be non-negative, got $account" } + val handle = mapNativeErrors { + DashpayNative.acceptPrivateContactRequest( + managerHandle, + walletId, + mnemonicResolver.nativeHandle, + account, + request.value, + shieldedAddress, + signerHandle, + ) + } + EstablishedContactRef(handle) + } + /** * Shielded → Core L1 withdrawal (Type 19) — port of Swift's * `PlatformWalletManager.shieldedWithdraw(walletId:account:toCoreAddress:amount:coreFeePerByte:)` diff --git a/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/errors/DashSdkErrorTest.kt b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/errors/DashSdkErrorTest.kt index fd09ae4051a..f244bea164e 100644 --- a/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/errors/DashSdkErrorTest.kt +++ b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/errors/DashSdkErrorTest.kt @@ -259,6 +259,22 @@ class DashSdkErrorTest { ) } + @Test + fun shouldRouteARequestThatNeedsAPrivateReplyByItsOwnCode() { + // PlatformWalletFFIResultCode::ErrorPrivateContactRequestNeedsPrivateReply = 68 + // (rs-platform-wallet-ffi/src/error.rs); a host catches this type to + // accept privately, as the Swift app does on its typed error. + val detail = "the contact requests with 9fRYTJZW came or went privately; " + + "reply to or rotate with it privately" + val native = DashSDKException(DashSdkError.PLATFORM_WALLET_CODE_OFFSET + 68, detail) + val mapped = DashSdkError.fromNative(native) + assertTrue(mapped is DashSdkError.PlatformWallet.PrivateContactRequestNeedsPrivateReply) + assertEquals(detail, mapped.message) + assertEquals(detail, mapped.userMessage) + assertEquals(native, mapped.cause) + assertFalse("the same public call is refused again", mapped.isRetryable) + } + @Test fun platformShieldCapacityCode41MapsTyped() { val message = diff --git a/packages/rs-unified-sdk-jni/src/dashpay.rs b/packages/rs-unified-sdk-jni/src/dashpay.rs index b6b36194a90..15ac9a42322 100644 --- a/packages/rs-unified-sdk-jni/src/dashpay.rs +++ b/packages/rs-unified-sdk-jni/src/dashpay.rs @@ -1,7 +1,8 @@ //! JNI bridge for the DashPay read surface added by the iOS DashPay //! completion (upstream #3841): payment history, cached contact profiles, //! per-identity sync state, wallet-scoped DPNS search and per-account -//! wallet balances. +//! wallet balances; and DashPay private contact requests (protocol +//! version 15): the store sync, and the shielded send and accept. //! //! Kotlin counterpart: `org.dashfoundation.dashsdk.ffi.DashpayNative`, //! driven by `org.dashfoundation.dashsdk.tokens.Dashpay` and @@ -20,15 +21,17 @@ use crate::identity::ManagedIdentityHandleGuard; use crate::pubkey_rows::decode_registration_pubkeys_blob; -use crate::support::{guard, take_pwffi_error}; +use crate::support::{guard, take_pwffi_error, throw_sdk_exception}; use jni::objects::{JByteArray, JClass, JString, JValue}; -use jni::sys::{jboolean, jint, jlong, jobject, jstring}; +use jni::sys::{jboolean, jint, jlong, jlongArray, jobject, jstring}; use jni::JNIEnv; use platform_wallet_ffi::core_wallet_types::OutPointFFI; use platform_wallet_ffi::dashpay_profile::DashPayProfileFFI; +use platform_wallet_ffi::error::platform_wallet_ffi_result_free; use platform_wallet_ffi::handle::Handle; use platform_wallet_ffi::identity_registration_with_signer::IdentityPubkeyFFI; use platform_wallet_ffi::invitation::InvitationPreviewFFI; +use platform_wallet_ffi::{ContactRequestHandleArray, PrivateContactSyncReportFFI}; use rs_sdk_ffi::{MnemonicResolverHandle, SignerHandle}; use std::ffi::CStr; use std::os::raw::c_char; @@ -1210,3 +1213,314 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_DashpayNative_claimIn } }) } + +// ── DashPay private contact requests (protocol version 15) ──────────── +// +// Mirrors Swift's `PrivateContactRequests.swift`: the store sync is a +// wallet-handle call (`ManagedPlatformWallet.syncPrivateContactRequests`); +// the send and the private-reply accept are manager-handle shielded spends +// (`PlatformWalletManager.sendPrivateContactRequest` / +// `acceptPrivateContactRequest`), gated like the other shielded spends. + +/// The bytes of an Orchard payment address the FFI reads from a +/// `shielded_address` pointer: an 11-byte diversifier and a 32-byte +/// transmission key. +#[cfg(feature = "shielded")] +const SHIELDED_ADDRESS_LEN: usize = 43; + +/// The longs [`Java_org_dashfoundation_dashsdk_ffi_DashpayNative_syncPrivateContactRequests`] +/// returns, in the order `Dashpay.syncPrivateContactRequests` reads them: +/// the requests the pass ingested, the store pages read, the entries +/// refused, the identities whose ingest did not reach disk, the store +/// position the pass stopped at, and whether it read to the store's last +/// entry (1) or not (0). +fn private_contact_sync_longs(requests: usize, report: &PrivateContactSyncReportFFI) -> [i64; 6] { + [ + i64::try_from(requests).unwrap_or(i64::MAX), + i64::from(report.pages_read), + i64::from(report.rejected_count), + i64::from(report.unpersisted_identity_count), + i64::try_from(report.next_position).unwrap_or(i64::MAX), + i64::from(report.complete), + ] +} + +/// Destroy every `ContactRequest` handle of `array`, free its buffer, and +/// return how many handles it held. Kotlin reads ingested requests from Room +/// (the persister writes them), so the handles are not handed across. +/// +/// # Safety +/// `array` must come from a platform-wallet-ffi enumerator and not yet be +/// freed. +unsafe fn release_contact_request_handles(array: &mut ContactRequestHandleArray) -> usize { + let count = array.count; + if !array.handles.is_null() && count > 0 { + for handle in std::slice::from_raw_parts(array.handles, count) { + let mut result = platform_wallet_ffi::contact_request_destroy(*handle); + platform_wallet_ffi_result_free(&mut result); + } + } + platform_wallet_ffi::platform_wallet_contact_request_handle_array_free(array); + count +} + +/// Read DashPay's private contact request store for every identity of the +/// wallet, at most `maxPages` pages, and ingest what it holds for them +/// (bridges `platform_wallet_sync_private_contact_requests`). The store is +/// opened with keys derived inside `coreSignerHandle`, the manager's +/// `MnemonicResolverHandle`. Ingested requests reach Room through the +/// persister; their handles are released here. Returns the six counters of +/// [`private_contact_sync_longs`]. Blocking (network). +#[no_mangle] +pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_DashpayNative_syncPrivateContactRequests( + mut env: JNIEnv, + _class: JClass, + wallet_handle: jlong, + core_signer_handle: jlong, + max_pages: jint, +) -> jlongArray { + guard(&mut env, ptr::null_mut(), |env| { + if max_pages < 0 { + throw_sdk_exception(env, 1, "maxPages must be non-negative"); + return ptr::null_mut(); + } + let mut array = ContactRequestHandleArray::empty(); + let mut report = PrivateContactSyncReportFFI::default(); + let result = unsafe { + platform_wallet_ffi::platform_wallet_sync_private_contact_requests( + wallet_handle as Handle, + core_signer_handle as *mut MnemonicResolverHandle, + max_pages as u32, + &mut array, + &mut report, + ) + }; + // The FFI leaves an empty array on every failure, so releasing it + // first is safe on both paths. + let requests = unsafe { release_contact_request_handles(&mut array) }; + if take_pwffi_error(env, result) { + return ptr::null_mut(); + } + let longs = private_contact_sync_longs(requests, &report); + let Ok(out) = env.new_long_array(longs.len() as i32) else { + return ptr::null_mut(); + }; + if env.set_long_array_region(&out, 0, &longs).is_err() { + return ptr::null_mut(); + } + out.into_raw() + }) +} + +/// Copy an optional Java `byte[]`; JVM null is `None`. Throws and returns +/// `Err` on a JNI error. `field` names the argument in the message. +#[cfg(feature = "shielded")] +fn read_opt_bytes(env: &mut JNIEnv, arr: &JByteArray, field: &str) -> Result>, ()> { + if arr.is_null() { + return Ok(None); + } + match env.convert_byte_array(arr) { + Ok(bytes) => Ok(Some(bytes)), + Err(_) => { + let _ = env.exception_clear(); + throw_sdk_exception(env, 1, &format!("{field} byte[] was invalid")); + Err(()) + } + } +} + +/// Copy an optional Orchard address. The FFI reads exactly +/// [`SHIELDED_ADDRESS_LEN`] bytes from the pointer it is given and has no +/// length to check against, so any other length is refused here before the +/// pointer crosses. +#[cfg(feature = "shielded")] +fn read_opt_shielded_address(env: &mut JNIEnv, arr: &JByteArray) -> Result>, ()> { + let address = read_opt_bytes(env, arr, "shieldedAddress")?; + if let Some(address) = &address { + if address.len() != SHIELDED_ADDRESS_LEN { + throw_sdk_exception( + env, + 1, + &format!( + "shieldedAddress must be {SHIELDED_ADDRESS_LEN} raw Orchard bytes, got {}", + address.len() + ), + ); + return Err(()); + } + } + Ok(address) +} + +/// Send a DashPay private contact request from `senderIdentityId` to +/// `recipientIdentityId`, paying for it from `account`'s shielded notes +/// (bridges `platform_wallet_manager_send_private_contact_request`). +/// `resolverHandle` supplies the Orchard spend authority and the DashPay +/// keys; `signerHandle` is the identity signer (`SignerHandle`) the public +/// [`crate::tokens`] `sendContactRequest` takes, and signs the request. +/// `accountLabel`, `autoAcceptProof` and `shieldedAddress` (43 bytes) may be +/// null. Blocks for the Halo 2 proof; returns the created `ContactRequest` +/// handle (destroy via `TokensNative.contactRequestDestroy`). +#[cfg(feature = "shielded")] +#[no_mangle] +#[allow(clippy::too_many_arguments)] +pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_DashpayNative_sendPrivateContactRequest( + mut env: JNIEnv, + _class: JClass, + manager_handle: jlong, + wallet_id: JByteArray, + resolver_handle: jlong, + account: jint, + sender_identity_id: JByteArray, + recipient_identity_id: JByteArray, + account_label: JString, + auto_accept_proof: JByteArray, + shielded_address: JByteArray, + signer_handle: jlong, +) -> jlong { + guard(&mut env, 0, |env| { + if account < 0 { + throw_sdk_exception(env, 1, "account must be non-negative"); + return 0; + } + let Some(wid) = read_id32(env, &wallet_id, "walletId") else { + return 0; + }; + let Some(sender) = read_id32(env, &sender_identity_id, "senderIdentityId") else { + return 0; + }; + let Some(recipient) = read_id32(env, &recipient_identity_id, "recipientIdentityId") else { + return 0; + }; + let Ok(label) = opt_jstring_to_cstring(env, "accountLabel", &account_label) else { + return 0; + }; + let Ok(proof) = read_opt_bytes(env, &auto_accept_proof, "autoAcceptProof") else { + return 0; + }; + let Ok(address) = read_opt_shielded_address(env, &shielded_address) else { + return 0; + }; + let (proof_ptr, proof_len) = proof + .as_ref() + .map_or((ptr::null(), 0), |proof| (proof.as_ptr(), proof.len())); + let mut out_handle: Handle = 0; + let result = unsafe { + platform_wallet_ffi::platform_wallet_manager_send_private_contact_request( + manager_handle as Handle, + wid.as_ptr(), + resolver_handle as *mut MnemonicResolverHandle, + account as u32, + sender.as_ptr(), + recipient.as_ptr(), + label.as_ref().map_or(ptr::null(), |label| label.as_ptr()), + proof_ptr, + proof_len, + address + .as_ref() + .map_or(ptr::null(), |address| address.as_ptr()), + signer_handle as *mut SignerHandle, + &mut out_handle, + ) + }; + if take_pwffi_error(env, result) { + return 0; + } + out_handle as jlong + }) +} + +/// Accept the incoming contact request `requestHandle` with a private reply, +/// paying for the reply from `account`'s shielded notes (bridges +/// `platform_wallet_manager_accept_private_contact_request`). The handles +/// are as for [`Java_org_dashfoundation_dashsdk_ffi_DashpayNative_sendPrivateContactRequest`]; +/// `shieldedAddress` (43 bytes) may be null. Blocks for the Halo 2 proof; +/// returns the `EstablishedContact` handle (destroy via +/// `TokensNative.establishedContactDestroy`). +#[cfg(feature = "shielded")] +#[no_mangle] +#[allow(clippy::too_many_arguments)] +pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_DashpayNative_acceptPrivateContactRequest( + mut env: JNIEnv, + _class: JClass, + manager_handle: jlong, + wallet_id: JByteArray, + resolver_handle: jlong, + account: jint, + request_handle: jlong, + shielded_address: JByteArray, + signer_handle: jlong, +) -> jlong { + guard(&mut env, 0, |env| { + if account < 0 { + throw_sdk_exception(env, 1, "account must be non-negative"); + return 0; + } + let Some(wid) = read_id32(env, &wallet_id, "walletId") else { + return 0; + }; + let Ok(address) = read_opt_shielded_address(env, &shielded_address) else { + return 0; + }; + let mut out_handle: Handle = 0; + let result = unsafe { + platform_wallet_ffi::platform_wallet_manager_accept_private_contact_request( + manager_handle as Handle, + wid.as_ptr(), + resolver_handle as *mut MnemonicResolverHandle, + account as u32, + request_handle as Handle, + address + .as_ref() + .map_or(ptr::null(), |address| address.as_ptr()), + signer_handle as *mut SignerHandle, + &mut out_handle, + ) + }; + if take_pwffi_error(env, result) { + return 0; + } + out_handle as jlong + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + // The natives above need a JVM; the counter layout Kotlin decodes is + // pure and is pinned here. + + #[test] + fn should_lay_out_the_private_sync_counters_in_the_order_kotlin_reads() { + let report = PrivateContactSyncReportFFI { + next_position: 41, + pages_read: 3, + rejected_count: 2, + unpersisted_identity_count: 1, + complete: true, + }; + assert_eq!(private_contact_sync_longs(5, &report), [5, 3, 2, 1, 41, 1]); + let incomplete = PrivateContactSyncReportFFI { + complete: false, + ..report + }; + assert_eq!(private_contact_sync_longs(0, &incomplete)[5], 0); + } + + #[test] + fn should_saturate_a_store_position_past_the_long_range() { + let report = PrivateContactSyncReportFFI { + next_position: u64::MAX, + ..Default::default() + }; + assert_eq!(private_contact_sync_longs(0, &report)[4], i64::MAX); + } + + #[test] + fn should_release_an_empty_handle_array_as_zero_requests() { + let mut array = ContactRequestHandleArray::empty(); + assert_eq!(unsafe { release_contact_request_handles(&mut array) }, 0); + assert!(array.handles.is_null()); + } +} From d3b4f89d1d5df71166546a01bac8aaf5b5026d28 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Sun, 8 Nov 2026 18:17:32 +0700 Subject: [PATCH 016/269] feat(kotlin-example-app): accept privately when the public accept refuses As the Swift app does (`ContactsView.swift`): - Accepting a request in Requests, or from Add Contact's collision path, goes through `acceptIncomingContactRequest`, which falls back to the private accept (paid from the wallet's shielded balance, signed by the same identity signer) when Rust refuses the public one with `PrivateContactRequestNeedsPrivateReply`. Rust decides which; the app only reacts to the refusal. - Pull-to-refresh on Requests also reads the private store, the only way private requests reach the wallet. Add Contact has no send-privately toggle yet. Co-Authored-By: Claude Opus 5.5 --- .../example/ui/dashpay/AddContactScreen.kt | 6 +-- .../ui/dashpay/ContactRequestsScreen.kt | 11 +++-- .../example/ui/dashpay/DashPaySyncHelpers.kt | 43 +++++++++++++++++++ 3 files changed, 53 insertions(+), 7 deletions(-) diff --git a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/AddContactScreen.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/AddContactScreen.kt index 7a92748c67e..e787616ba7d 100644 --- a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/AddContactScreen.kt +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/AddContactScreen.kt @@ -218,11 +218,11 @@ fun AddContactScreen(identityIdHex: String, navController: NavHostController) { errorMessage = null scope.launch { try { - val ok = w.dashpay.acceptIncomingRequest( + val ok = acceptIncomingContactRequest( + wallet = w, + manager = m, ourIdentityId = idBytes, senderId = recipient, - signerHandle = m.signerHandle, - coreSignerHandle = m.mnemonicResolverHandle, ) if (ok) { kickDashPaySync(scope, m) diff --git a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/ContactRequestsScreen.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/ContactRequestsScreen.kt index b6057c802ed..a51d282cf09 100644 --- a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/ContactRequestsScreen.kt +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/ContactRequestsScreen.kt @@ -155,11 +155,11 @@ fun ContactRequestsScreen(identityIdHex: String, navController: NavHostControlle inFlightIds = inFlightIds + hex scope.launch { try { - val ok = w.dashpay.acceptIncomingRequest( + val ok = acceptIncomingContactRequest( + wallet = w, + manager = m, ourIdentityId = idBytes, senderId = contactId, - signerHandle = m.signerHandle, - coreSignerHandle = m.mnemonicResolverHandle, ) if (ok) { removedOverlayIds = removedOverlayIds + hex @@ -209,7 +209,10 @@ fun ContactRequestsScreen(identityIdHex: String, navController: NavHostControlle onRefresh = { scope.launch { isRefreshing = true - manager?.let { attachOrStartSync(it) } + manager?.let { m -> + attachOrStartSync(m) + wallet?.let { syncPrivateContactRequests(it, m) } + } isRefreshing = false } }, diff --git a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/DashPaySyncHelpers.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/DashPaySyncHelpers.kt index 0080bbdb3a7..6294b4fc2d1 100644 --- a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/DashPaySyncHelpers.kt +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/DashPaySyncHelpers.kt @@ -3,6 +3,8 @@ package org.dashfoundation.example.ui.dashpay import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch +import org.dashfoundation.dashsdk.errors.DashSdkError +import org.dashfoundation.dashsdk.wallet.ManagedPlatformWallet import org.dashfoundation.dashsdk.wallet.PlatformWalletManager /** @@ -32,3 +34,44 @@ suspend fun attachOrStartSync(manager: PlatformWalletManager) { fun kickDashPaySync(scope: CoroutineScope, manager: PlatformWalletManager) { scope.launch { runCatching { manager.dashPaySyncNow() } } } + +/** + * Read DashPay's private contact request store (protocol version 15) for + * [wallet]. Best effort: a network before protocol version 15 has no such + * store. Private requests reach the wallet only through this read, so it + * runs with every pull-to-refresh of the request list. + * ← Swift `syncPrivateContactRequests` (`ContactsView.swift`). + */ +suspend fun syncPrivateContactRequests( + wallet: ManagedPlatformWallet, + manager: PlatformWalletManager, +) { + runCatching { wallet.dashpay.syncPrivateContactRequests(manager.mnemonicResolverHandle) } +} + +/** + * Accept the incoming request from [senderId] to [ourIdentityId]. Rust refuses + * a public reply to a contact whose requests came privately, as it would + * publish the relationship; that one is accepted with a private reply + * instead, paid from the wallet's shielded balance and signed by the same + * identity signer. Rust decides which; the app only reacts to the refusal. + * Returns false when the request is not in local state. + * ← Swift `acceptIncomingContactRequest` (`ContactsView.swift`). + */ +suspend fun acceptIncomingContactRequest( + wallet: ManagedPlatformWallet, + manager: PlatformWalletManager, + ourIdentityId: ByteArray, + senderId: ByteArray, +): Boolean = try { + wallet.dashpay.acceptIncomingRequest( + ourIdentityId = ourIdentityId, + senderId = senderId, + signerHandle = manager.signerHandle, + coreSignerHandle = manager.mnemonicResolverHandle, + ) +} catch (refused: DashSdkError.PlatformWallet.PrivateContactRequestNeedsPrivateReply) { + val request = wallet.dashpay.incomingContactRequest(ourIdentityId, senderId) + request?.use { manager.acceptPrivateContactRequest(wallet.walletId, it).close() } + request != null +} From 95c0dd2a85b359712000b512d1f7d13062ef0aab Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Sun, 8 Nov 2026 18:17:32 +0700 Subject: [PATCH 017/269] docs(kotlin-sdk): track DashPay private contact request parity Adds `dashpay.private_contact_requests` (store sync, shielded send, private-reply accept, private contact read, code 68 routing) with its four FFI symbols. Both hosts are partial with restart required: the sync, send and accept need a protocol version 15 network with shielded funds and have no recorded gate, no test covers a restart, Kotlin does not bridge the private contact read, and the Kotlin app has no send-privately toggle. `PARITY_SUMMARY.md` is regenerated by the checker. Co-Authored-By: Claude Opus 5.5 --- docs/sdk/sdk-parity-manifest.json | 83 +++++++++++++++++++++++++++ packages/kotlin-sdk/PARITY_SUMMARY.md | 15 ++--- 2 files changed, 91 insertions(+), 7 deletions(-) diff --git a/docs/sdk/sdk-parity-manifest.json b/docs/sdk/sdk-parity-manifest.json index aa26292d4b4..109342d77a4 100644 --- a/docs/sdk/sdk-parity-manifest.json +++ b/docs/sdk/sdk-parity-manifest.json @@ -30,6 +30,7 @@ "dpns_name_array_free": "packages/rs-platform-wallet-ffi/src/dpns.rs", "managed_identity_get_contested_dpns_names": "packages/rs-platform-wallet-ffi/src/dpns.rs", "managed_identity_get_dashpay_payments": "packages/rs-platform-wallet-ffi/src/dashpay_payment.rs", + "managed_identity_get_private_contact": "packages/rs-platform-wallet-ffi/src/private_contact_request.rs", "on_load_shielded_viewing_keys_fn": "packages/rs-platform-wallet-ffi/src/persistence.rs", "on_load_shielded_viewing_keys_free_fn": "packages/rs-platform-wallet-ffi/src/persistence.rs", "on_persist_address_balances_fn": "packages/rs-platform-wallet-ffi/src/persistence.rs", @@ -40,8 +41,10 @@ "platform_wallet_create_document_with_signer": "packages/rs-platform-wallet-ffi/src/document.rs", "platform_wallet_create_invitation": "packages/rs-platform-wallet-ffi/src/invitation.rs", "platform_wallet_drain_pending_contact_crypto": "packages/rs-platform-wallet-ffi/src/dashpay.rs", + "platform_wallet_manager_accept_private_contact_request": "packages/rs-platform-wallet-ffi/src/private_contact_request.rs", "platform_wallet_manager_create_with_persistence_capabilities": "packages/rs-platform-wallet-ffi/src/manager.rs", "platform_wallet_manager_persistence_capabilities": "packages/rs-platform-wallet-ffi/src/manager.rs", + "platform_wallet_manager_send_private_contact_request": "packages/rs-platform-wallet-ffi/src/private_contact_request.rs", "platform_wallet_manager_shielded_fund_from_wallet_drain": "packages/rs-platform-wallet-ffi/src/shielded_wallet_drain.rs", "platform_wallet_manager_shielded_wallet_drain_preview": "packages/rs-platform-wallet-ffi/src/shielded_wallet_drain.rs", "platform_wallet_manager_shielded_wallet_drain_status": "packages/rs-platform-wallet-ffi/src/shielded_wallet_drain.rs", @@ -49,6 +52,7 @@ "platform_wallet_manager_sync_fault_detected": "packages/rs-platform-wallet-ffi/src/manager.rs", "platform_wallet_resume_identity_with_existing_asset_lock_signer": "packages/rs-platform-wallet-ffi/src/identity_registration_funded_with_signer.rs", "platform_wallet_sync_contested_dpns_names": "packages/rs-platform-wallet-ffi/src/dpns.rs", + "platform_wallet_sync_private_contact_requests": "packages/rs-platform-wallet-ffi/src/private_contact_request.rs", "platform_wallet_token_purchase": "packages/rs-platform-wallet-ffi/src/tokens/purchase.rs", "platform_wallet_token_transfer": "packages/rs-platform-wallet-ffi/src/tokens/transfer.rs", "platform_wallet_token_update_config": "packages/rs-platform-wallet-ffi/src/tokens/update_config.rs", @@ -770,6 +774,85 @@ } ] }, + { + "id": "dashpay.private_contact_requests", + "title": "DashPay private contact requests: store sync, shielded send, private-reply accept, private contact read, code 68 routing", + "area": "correctness", + "shared_apis": [ + "platform_wallet_sync_private_contact_requests", + "platform_wallet_manager_send_private_contact_request", + "platform_wallet_manager_accept_private_contact_request", + "managed_identity_get_private_contact" + ], + "required_persistence_capabilities": [ + "atomic_changesets", + "wallet_restore" + ], + "hosts": { + "swift": { + "sdk": "partial", + "example_app": "partial", + "restart": "required", + "reason": "PrivateContactRequests.swift wraps the store sync, the shielded send and private-reply accept (signed with the identity signer the public calls take) and PrivateContactInfo; code 68 is typed and pinned in ErrorHandlingTests. The sync, send and accept have no automated or recorded gate: they need a protocol version 15 network with shielded funds. The private marker (core height 0) round-trips through the persisted coreHeightCreatedAt, but no test covers a restart. The example app has the send-privately toggle, the private-accept fallback on code 68 and the private sync on pull-to-refresh, with no recorded gate." + }, + "kotlin": { + "sdk": "partial", + "example_app": "partial", + "restart": "required", + "reason": "Dashpay.syncPrivateContactRequests and PlatformWalletManager.sendPrivateContactRequest / acceptPrivateContactRequest bridge the same FFI (signed with the manager's identity signer), and code 68 maps to PlatformWallet.PrivateContactRequestNeedsPrivateReply (DashSdkErrorTest). managed_identity_get_private_contact is not bridged: the Swift example app has no caller for it. JNI descriptors are covered only by the FfiSmokeTest symbol test; the flows need a protocol version 15 network with shielded funds and have no recorded gate, and no test covers a restart. The example app falls back to the private accept on code 68 and syncs the private store on pull-to-refresh; it has no send-privately toggle." + } + }, + "verification": [ + { + "host": "shared", + "kind": "unit", + "file": "packages/rs-platform-wallet/src/wallet/identity/network/private_contact_requests.rs", + "id": "should_send_read_and_accept_a_private_contact_request", + "command": "cargo test -p platform-wallet --lib should_send_read_and_accept_a_private_contact_request", + "covers_restart": false + }, + { + "host": "shared", + "kind": "unit", + "file": "packages/rs-platform-wallet-ffi/src/error.rs", + "id": "should_tell_a_private_request_from_other_accept_failures_by_code", + "command": "cargo test -p platform-wallet-ffi --lib should_tell_a_private_request_from_other_accept_failures_by_code", + "covers_restart": false + }, + { + "host": "swift", + "kind": "unit", + "file": "packages/swift-sdk/SwiftTests/SwiftDashSDKTests/ErrorHandlingTests.swift", + "id": "testShouldRouteAPrivatelyReceivedRequestByItsOwnCode", + "command": "cd packages/swift-sdk && xcodebuild test -scheme SwiftDashSDK -destination 'platform=iOS Simulator,name=iPhone 17 Pro' -only-testing:SwiftDashSDKTests/ErrorHandlingTests", + "covers_restart": false + }, + { + "host": "kotlin", + "kind": "unit", + "file": "packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/errors/DashSdkErrorTest.kt", + "id": "shouldRouteARequestThatNeedsAPrivateReplyByItsOwnCode", + "command": "cd packages/kotlin-sdk && ./gradlew :sdk:testDebugUnitTest --tests '*DashSdkErrorTest*'", + "covers_restart": false + }, + { + "host": "kotlin", + "kind": "unit", + "file": "packages/rs-unified-sdk-jni/src/dashpay.rs", + "id": "should_lay_out_the_private_sync_counters_in_the_order_kotlin_reads", + "command": "cargo test -p rs-unified-sdk-jni --lib should_lay_out_the_private_sync_counters_in_the_order_kotlin_reads", + "covers_restart": false + }, + { + "host": "kotlin", + "kind": "device", + "file": "packages/kotlin-sdk/sdk/src/androidTest/kotlin/org/dashfoundation/dashsdk/FfiSmokeTest.kt", + "id": "privateContactRequestSymbolsBindAndRejectInvalidInput", + "command": "cd packages/kotlin-sdk && ./gradlew :sdk:connectedDebugAndroidTest", + "covers_restart": false + } + ] + }, { "id": "identity.asset_lock_resume", "title": "Identity registration and top-up resume from tracked locks", diff --git a/packages/kotlin-sdk/PARITY_SUMMARY.md b/packages/kotlin-sdk/PARITY_SUMMARY.md index ff6bef9ca6e..5b6a2af0178 100644 --- a/packages/kotlin-sdk/PARITY_SUMMARY.md +++ b/packages/kotlin-sdk/PARITY_SUMMARY.md @@ -2,23 +2,23 @@ # Kotlin/Swift executable parity summary Audit baseline: `PR #3999 @ 6dbc72a54df72d26eb9c4a014b425d2b95134e4e` -Capabilities tracked: **27** +Capabilities tracked: **28** ## Status counts | Host | Surface | Supported | Partial | Unsupported | Not applicable | | --- | --- | ---: | ---: | ---: | ---: | -| Swift | SDK | 16 | 9 | 1 | 1 | -| Swift | Example app | 4 | 12 | 2 | 9 | -| Kotlin | SDK | 13 | 12 | 1 | 1 | -| Kotlin | Example app | 5 | 12 | 1 | 9 | +| Swift | SDK | 16 | 10 | 1 | 1 | +| Swift | Example app | 4 | 13 | 2 | 9 | +| Kotlin | SDK | 13 | 13 | 1 | 1 | +| Kotlin | Example app | 5 | 13 | 1 | 9 | ## Restart coverage | Host | Tested | Required | Not applicable | | --- | ---: | ---: | ---: | -| Swift | 1 | 8 | 18 | -| Kotlin | 4 | 6 | 17 | +| Swift | 1 | 9 | 18 | +| Kotlin | 4 | 7 | 17 | ## Capability status @@ -33,6 +33,7 @@ Capabilities tracked: **27** | `dashpay.invitations` | supported / partial / required | supported / partial / required | | `core.provider_restore` | supported / partial / required | partial / partial / required | | `dashpay.deferred_contact_crypto` | partial / partial / required | partial / partial / required | +| `dashpay.private_contact_requests` | partial / partial / required | partial / partial / required | | `identity.asset_lock_resume` | supported / partial / required | partial / partial / required | | `core.compact_filter_rescan` | supported / partial / not_applicable | partial / partial / not_applicable | | `dpns.contested_names_by_identity` | supported / partial / not_applicable | partial / partial / required | From 6db333ec6a479fa4352146555aca5c08df3a758d Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Sun, 8 Nov 2026 19:08:17 +0700 Subject: [PATCH 018/269] 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 019/269] 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 020/269] 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 021/269] 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 bd66fb9cf52d75eab8e366ab43402d4cdbff4839 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Sun, 8 Nov 2026 20:32:30 +0700 Subject: [PATCH 022/269] feat(platform-wallet): accept a contact request replying in kind over the FFI `platform_wallet_manager_accept_contact_request_replying_in_kind(handle, wallet_id_bytes, mnemonic_resolver_handle, account, request_handle, shielded_address, signer_handle, out_established_handle)` (`shielded` feature) exposes `PlatformWallet::accept_contact_request_replying_in_kind`: the one accept a host calls. The wallet replies privately to a request that came privately with no reply yet, and with a document (or by adopting the reply already sent) otherwise, so hosts no longer catch code 68 and call the private accept themselves. It is built as `platform_wallet_manager_accept_private_contact_request` is: same arguments and null checks, `catch_spend_panic` and `map_spend_result` for the shielded spend contract, and a null-signer test. The docs of code 68 name it. Co-Authored-By: Claude Opus 5.5 --- .../ERROR_CODE_REGISTRY.md | 2 +- packages/rs-platform-wallet-ffi/src/error.rs | 6 +- .../src/private_contact_request.rs | 152 +++++++++++++++++- 3 files changed, 154 insertions(+), 6 deletions(-) diff --git a/packages/rs-platform-wallet-ffi/ERROR_CODE_REGISTRY.md b/packages/rs-platform-wallet-ffi/ERROR_CODE_REGISTRY.md index 1d9f095a17b..301688c27b6 100644 --- a/packages/rs-platform-wallet-ffi/ERROR_CODE_REGISTRY.md +++ b/packages/rs-platform-wallet-ffi/ERROR_CODE_REGISTRY.md @@ -181,7 +181,7 @@ 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_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 | +| 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 | **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 5085f8047c3..e0e1ea2333f 100644 --- a/packages/rs-platform-wallet-ffi/src/error.rs +++ b/packages/rs-platform-wallet-ffi/src/error.rs @@ -628,8 +628,10 @@ pub enum PlatformWalletFFIResultCode { /// 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_private_contact_request`, - /// instead. 61 to 67 are claimed by open branches, so this takes 68. + /// 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 named thing does not exist. diff --git a/packages/rs-platform-wallet-ffi/src/private_contact_request.rs b/packages/rs-platform-wallet-ffi/src/private_contact_request.rs index a8eea803720..82724044c13 100644 --- a/packages/rs-platform-wallet-ffi/src/private_contact_request.rs +++ b/packages/rs-platform-wallet-ffi/src/private_contact_request.rs @@ -17,13 +17,16 @@ //! The request is signed by the identity signer the public send and accept //! take, and sealed in the resolver-backed signer. Built with the //! `shielded` feature only. +//! - [`platform_wallet_manager_accept_contact_request_replying_in_kind`]: +//! the one accept a host calls. The wallet replies privately to a request +//! that came privately, and with a document otherwise. Built with the +//! `shielded` feature only. //! - [`managed_identity_get_private_contact`]: what a contact's private //! requests carry beyond the `ContactRequest`: whether they came or went //! privately, and the contact's shielded address. //! -//! The public send and accept refuse a contact whose requests came or went -//! privately with `ErrorPrivateContactRequestNeedsPrivateReply`; the host -//! routes the request to the private send or accept. +//! The explicitly public send and accept refuse a contact whose requests came +//! or went privately with `ErrorPrivateContactRequestNeedsPrivateReply`. use platform_wallet::PrivateContact; use rs_sdk_ffi::MnemonicResolverHandle; @@ -534,6 +537,126 @@ unsafe fn accept_private_contact_request_inner( } } +/// Accept a DashPay contact request replying the way it came +/// (`PlatformWallet::accept_contact_request_replying_in_kind`): privately, +/// paid from `account`'s shielded notes, when it came privately and no reply +/// exists yet; otherwise with a `contactRequest` document signed by +/// `signer_handle`, or by adopting the reply already sent. Which reply a +/// request takes is the wallet's decision: this is the one accept a host +/// calls. `shielded_address` is the Orchard address the contact may pay us +/// at privately; null omits it, and a public reply ignores it. +/// +/// Arguments and outputs are as for +/// [`platform_wallet_manager_accept_private_contact_request`]. The spend +/// authority is derived only for a private reply. Errors follow the shielded +/// spend contract: `ErrorShieldedSpendUnconfirmed` means the reply may +/// already be on chain; do not accept again. +/// +/// # Safety +/// Identical contract to +/// [`platform_wallet_manager_accept_private_contact_request`]. +#[cfg(feature = "shielded")] +#[no_mangle] +#[allow(clippy::too_many_arguments)] +pub unsafe extern "C" fn platform_wallet_manager_accept_contact_request_replying_in_kind( + handle: Handle, + wallet_id_bytes: *const u8, + mnemonic_resolver_handle: *mut MnemonicResolverHandle, + account: u32, + request_handle: Handle, + shielded_address: *const u8, + signer_handle: *mut SignerHandle, + out_established_handle: *mut Handle, +) -> PlatformWalletFFIResult { + // Same panic contract as the private accept: a private reply's notes may + // be spent when it strikes. + catch_spend_panic("contact request accept", || { + accept_contact_request_replying_in_kind_inner( + handle, + wallet_id_bytes, + mnemonic_resolver_handle, + account, + request_handle, + shielded_address, + signer_handle, + out_established_handle, + ) + }) +} + +/// Body of [`platform_wallet_manager_accept_contact_request_replying_in_kind`], +/// as an ordinary Rust function so a panic unwinds into [`catch_spend_panic`]. +/// +/// # Safety +/// Identical contract to the export that calls it. +#[cfg(feature = "shielded")] +#[allow(clippy::too_many_arguments)] +unsafe fn accept_contact_request_replying_in_kind_inner( + handle: Handle, + wallet_id_bytes: *const u8, + mnemonic_resolver_handle: *mut MnemonicResolverHandle, + account: u32, + request_handle: Handle, + shielded_address: *const u8, + signer_handle: *mut SignerHandle, + out_established_handle: *mut Handle, +) -> PlatformWalletFFIResult { + check_ptr!(wallet_id_bytes); + check_ptr!(mnemonic_resolver_handle); + check_ptr!(signer_handle); + check_ptr!(out_established_handle); + unsafe { *out_established_handle = NULL_HANDLE }; + + let mut wallet_id = [0u8; 32]; + std::ptr::copy_nonoverlapping(wallet_id_bytes, wallet_id.as_mut_ptr(), 32); + let request = CONTACT_REQUEST_STORAGE.with_item(request_handle, |request| request.clone()); + let request = unwrap_option_or_return!(request); + let shielded_address = read_shielded_address(shielded_address); + + let (wallet, coordinator) = match resolve_wallet_and_coordinator(handle, &wallet_id) { + Ok(pair) => pair, + Err(result) => return result, + }; + let seed = match resolve_seed_from_resolver(mnemonic_resolver_handle, &wallet_id) { + Ok(seed) => seed, + Err(result) => return result, + }; + // SAFETY: the caller pins the resolver handle for the duration of this + // call. + let crypto = unsafe { + resolver_contact_crypto_provider(mnemonic_resolver_handle, wallet_id, wallet.network()) + }; + let signer_addr = signer_handle as usize; + + let result = block_on_worker(async move { + // SAFETY: as in the private accept, the caller pins the signer for the + // call. + let signer: &VTableSigner = &*(signer_addr as *const VTableSigner); + let prover = CachedOrchardProver::new(); + let result = wallet + .accept_contact_request_replying_in_kind( + &coordinator, + seed.as_ref(), + account, + &request, + shielded_address, + signer, + &crypto, + &prover, + ) + .await; + poke_sync_on_unconfirmed(&result, handle); + result + }); + match result { + Ok(contact) => { + unsafe { *out_established_handle = ESTABLISHED_CONTACT_STORAGE.insert(contact) }; + PlatformWalletFFIResult::ok() + } + Err(error) => map_spend_result(Err(error), "contact request accept"), + } +} + #[cfg(test)] mod tests { use super::*; @@ -636,6 +759,29 @@ mod tests { assert_eq!(result.code, PlatformWalletFFIResultCode::ErrorNullPointer); } + /// As the private accept: whichever reply the wallet picks is signed by + /// the identity signer. + #[cfg(feature = "shielded")] + #[test] + fn should_reject_a_null_identity_signer_on_the_accept_replying_in_kind() { + let wallet_id = [1u8; 32]; + let resolver = std::ptr::NonNull::::dangling().as_ptr(); + let mut out = 0xDEAD_BEEF; + let result = unsafe { + platform_wallet_manager_accept_contact_request_replying_in_kind( + 0xDEAD_BEEF, + wallet_id.as_ptr(), + resolver, + 0, + 0xDEAD_BEEF, + std::ptr::null(), + std::ptr::null_mut(), + &mut out, + ) + }; + assert_eq!(result.code, PlatformWalletFFIResultCode::ErrorNullPointer); + } + #[test] fn should_carry_every_field_of_a_private_contact() { let address = [0x55; SHIELDED_ADDRESS_LEN]; From 9f01069ab253975e8f8a12119cd9d81188372386 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Sun, 8 Nov 2026 20:32:30 +0700 Subject: [PATCH 023/269] feat(swift-sdk): accept a contact request replying in kind `PlatformWalletManager.acceptContactRequestReplyingInKind(walletId:resolver:account:request:shieldedAddress:signer:)` is a thin wrapper over the new FFI export: the wallet decides whether the reply is private or a document. The explicitly public and private accepts stay. The file header and the code 68 docs point hosts at it. Co-Authored-By: Claude Opus 5.5 --- .../PlatformWallet/PlatformWalletResult.swift | 3 +- .../PrivateContactRequests.swift | 61 +++++++++++++++++-- 2 files changed, 59 insertions(+), 5 deletions(-) diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift index 2ea5fa7e59c..9081b230dd2 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift @@ -597,7 +597,8 @@ public enum PlatformWalletError: LocalizedError { /// (protocol version 15): a public request to it, in reply or as a /// rotation, would publish the relationship, so the public accept and /// send refuse it and nothing was sent. Accept it with - /// `PlatformWalletManager.acceptPrivateContactRequest`, or send with + /// `PlatformWalletManager.acceptContactRequestReplyingInKind` (which picks + /// the reply itself) or `acceptPrivateContactRequest`, or send with /// `PlatformWalletManager.sendPrivateContactRequest`. case privateContactRequestNeedsPrivateReply(String) case walletAlreadyExists(String) diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PrivateContactRequests.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PrivateContactRequests.swift index 13debee5447..86a5f185d6b 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PrivateContactRequests.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PrivateContactRequests.swift @@ -10,11 +10,13 @@ import DashSDKFFI // privately, and the contact's shielded address, are read with // `ManagedIdentity.privateContact(contactId:)`. // -// Routing: the public `acceptContactRequest` and `sendContactRequest` throw +// Accepting: `PlatformWalletManager.acceptContactRequestReplyingInKind` is +// the one accept a host calls; the wallet replies privately to a request that +// came privately, and with a document otherwise. The explicitly public +// `acceptContactRequest` and `sendContactRequest` throw // `PlatformWalletError.privateContactRequestNeedsPrivateReply` for a contact -// whose requests came or went privately; accept it with -// `PlatformWalletManager.acceptPrivateContactRequest`, or send with -// `PlatformWalletManager.sendPrivateContactRequest`, instead. +// whose requests came or went privately; send to it with +// `PlatformWalletManager.sendPrivateContactRequest` instead. /// How an identity and one contact exchanged private contact requests. Rust /// reads it from the stored requests, so it holds across launches; only the @@ -244,6 +246,57 @@ extension PlatformWalletManager { return EstablishedContact(handle: establishedHandle) } + /// 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; otherwise with a `contactRequest` document signed by + /// `signer`, or by adopting the reply already sent. Which reply a request + /// takes is the wallet's decision, so this is the accept a host calls. + /// `shieldedAddress` is the 43-byte Orchard address the contact may pay + /// us at privately; a public reply ignores it. Throws + /// `PlatformWalletError.shieldedSpendUnconfirmed` when a private reply + /// may already be on chain: do not accept again. + public func acceptContactRequestReplyingInKind( + walletId: Data, + resolver: MnemonicResolver, + account: UInt32 = 0, + request: ContactRequest, + shieldedAddress: Data? = nil, + signer: KeychainSigner + ) async throws -> EstablishedContact { + let handle = try privateContactRequestManagerHandle( + walletId: walletId, + resolver: resolver, + shieldedAddress: shieldedAddress + ) + let requestHandle = request.handle + let signerHandle = signer.handle + let establishedHandle: Handle = try await Task.detached(priority: .userInitiated) { + () -> Handle in + var outHandle: Handle = NULL_HANDLE + let resolverHandle = resolver.handle + // Keep the resolver, the signer and the request alive across the + // FFI call, whose vtable callbacks fire during it. + try withExtendedLifetime((resolver, signer, request)) { + try walletId.withFFIBytes { widPtr in + try withOptionalBytes(shieldedAddress) { addressPtr, _ in + try platform_wallet_manager_accept_contact_request_replying_in_kind( + handle, + widPtr, + resolverHandle, + account, + requestHandle, + addressPtr, + signerHandle, + &outHandle + ).check() + } + } + } + return outHandle + }.value + return EstablishedContact(handle: establishedHandle) + } + /// The manager handle of a private contact request call, refused before /// any FFI call when the arguments cannot be passed: an unconfigured /// manager, a wallet id that is not 32 bytes, a shielded address that is From 1c07538424c0b4f5972f1cb98a74df72dce2887a Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Sun, 8 Nov 2026 20:32:30 +0700 Subject: [PATCH 024/269] refactor(swift-example-app): let the wallet choose how an accepted request is replied to The request list and Add Contact's collision alert call `acceptContactRequestReplyingInKind` once instead of trying the public accept and falling back to the private one on code 68. The `acceptIncomingContactRequest` helper that held that choice is removed. Co-Authored-By: Claude Opus 5.5 --- .../Views/DashPay/AddContactView.swift | 10 ++++---- .../Views/DashPay/ContactRequestsView.swift | 10 ++++---- .../Views/DashPay/ContactsView.swift | 23 ------------------- 3 files changed, 12 insertions(+), 31 deletions(-) diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/AddContactView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/AddContactView.swift index 6576a9006e1..66a1d16bca9 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/AddContactView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/AddContactView.swift @@ -512,10 +512,12 @@ struct AddContactView: View { return } let signer = KeychainSigner(modelContainer: modelContext.container) - try await acceptIncomingContactRequest( - request, - wallet: wallet, - walletManager: walletManager, + // The wallet replies the way the request came: privately + // (paid from the shielded balance) or with a document. + _ = try await walletManager.acceptContactRequestReplyingInKind( + walletId: wallet.walletId, + resolver: MnemonicResolver(), + request: request, signer: signer ) kickDashPaySync(walletManager) diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ContactRequestsView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ContactRequestsView.swift index 99ba9062db5..d5c2a1f52a1 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ContactRequestsView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ContactRequestsView.swift @@ -228,10 +228,12 @@ struct ContactRequestsView: View { return } let signer = KeychainSigner(modelContainer: modelContext.container) - try await acceptIncomingContactRequest( - request, - wallet: wallet, - walletManager: walletManager, + // The wallet replies the way the request came: privately + // (paid from the shielded balance) or with a document. + _ = try await walletManager.acceptContactRequestReplyingInKind( + walletId: wallet.walletId, + resolver: MnemonicResolver(), + request: request, signer: signer ) // Optimistic removal — the persister will promote the diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ContactsView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ContactsView.swift index 4083f1e03a9..e75c18037cc 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ContactsView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ContactsView.swift @@ -235,29 +235,6 @@ func syncPrivateContactRequests(_ wallet: ManagedPlatformWallet) async { _ = try? await wallet.syncPrivateContactRequests() } -/// Accept `request`. Rust refuses a public reply to a contact whose requests -/// came privately, as it would publish the relationship; that one is accepted -/// with a private reply instead, paid from the wallet's shielded balance and -/// signed by the same `signer`. -@MainActor -func acceptIncomingContactRequest( - _ request: ContactRequest, - wallet: ManagedPlatformWallet, - walletManager: PlatformWalletManager, - signer: KeychainSigner -) async throws { - do { - _ = try await wallet.acceptContactRequest(request, signer: signer) - } catch PlatformWalletError.privateContactRequestNeedsPrivateReply { - _ = try await walletManager.acceptPrivateContactRequest( - walletId: wallet.walletId, - resolver: MnemonicResolver(), - request: request, - signer: signer - ) - } -} - // MARK: - Row model + view /// UI model for one established contact row, resolved from the From 399161e8b065ef539ce0f6758fab682006382480 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Sun, 8 Nov 2026 21:43:54 +0700 Subject: [PATCH 025/269] feat(kotlin-sdk): accept a contact request replying in kind `PlatformWalletManager.acceptContactRequestReplyingInKind(walletId, request, shieldedAddress, account)` bridges `platform_wallet_manager_accept_contact_request_replying_in_kind` through the new JNI export `DashpayNative.acceptContactRequestReplyingInKind` (`shielded` feature): the wallet replies privately to a request that came privately with no reply yet, and with a document (or by adopting the reply already sent) otherwise. It is the accept a host calls, so hosts no longer catch code 68 and call the private accept themselves. Port of Swift's `PlatformWalletManager.acceptContactRequestReplyingInKind`. Like the other manager-level shielded spends it signs with the manager's identity signer and takes the spend authority from the manager's resolver. The explicit public and private accepts stay; their KDoc, and code 68's, point at the new accept. `FfiSmokeTest` binds the new symbol. Co-Authored-By: Claude Opus 5.5 --- .../dashfoundation/dashsdk/FfiSmokeTest.kt | 7 ++- .../dashsdk/errors/DashSdkError.kt | 11 ++-- .../dashsdk/ffi/DashpayNative.kt | 19 ++++++ .../dashfoundation/dashsdk/tokens/Dashpay.kt | 7 +-- .../dashsdk/wallet/PlatformWalletManager.kt | 50 +++++++++++++-- packages/rs-unified-sdk-jni/src/dashpay.rs | 61 ++++++++++++++++++- 6 files changed, 139 insertions(+), 16 deletions(-) diff --git a/packages/kotlin-sdk/sdk/src/androidTest/kotlin/org/dashfoundation/dashsdk/FfiSmokeTest.kt b/packages/kotlin-sdk/sdk/src/androidTest/kotlin/org/dashfoundation/dashsdk/FfiSmokeTest.kt index a1767679e48..8a58af68e37 100644 --- a/packages/kotlin-sdk/sdk/src/androidTest/kotlin/org/dashfoundation/dashsdk/FfiSmokeTest.kt +++ b/packages/kotlin-sdk/sdk/src/androidTest/kotlin/org/dashfoundation/dashsdk/FfiSmokeTest.kt @@ -96,7 +96,7 @@ class FfiSmokeTest { * `packages/rs-platform-wallet/src/changeset/core_bridge.rs`. */ /** - * DashPay private contact requests (protocol version 15): the three + * DashPay private contact requests (protocol version 15): the four * `DashpayNative` symbols bind with the descriptors Kotlin declares, and * bad input surfaces as a mapped [DashSDKException] rather than a crash. * A null resolver is refused before any wallet lookup, and a shielded @@ -122,6 +122,11 @@ class FfiSmokeTest { 0L, ByteArray(32), 0L, 0, 0L, ByteArray(42), 0L, ) } + assertThrows(DashSDKException::class.java) { + DashpayNative.acceptContactRequestReplyingInKind( + 0L, ByteArray(32), 0L, 0, 0L, ByteArray(42), 0L, + ) + } } @Test diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/errors/DashSdkError.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/errors/DashSdkError.kt index e5bf9a1d4ef..fa4583fa59e 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/errors/DashSdkError.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/errors/DashSdkError.kt @@ -652,9 +652,10 @@ sealed class DashSdkError( * (protocol version 15), so a public request to it, in reply or as a * rotation, would publish the relationship. The public accept and * send refuse it and nothing was sent. Accept it with - * `PlatformWalletManager.acceptPrivateContactRequest`, or send with - * `PlatformWalletManager.sendPrivateContactRequest`. Not retryable - * as-is: the same public call is refused again. + * `PlatformWalletManager.acceptContactRequestReplyingInKind` (which + * picks the reply itself) or `acceptPrivateContactRequest`, or send + * with `PlatformWalletManager.sendPrivateContactRequest`. Not + * retryable as-is: the same public call is refused again. * ← Swift `PlatformWalletError.privateContactRequestNeedsPrivateReply` * (`PlatformWalletResult.swift`). */ @@ -857,8 +858,8 @@ sealed class DashSdkError( 56 -> PlatformWallet.ShieldedRecoveryCorrupted(message, cause) 57 -> PlatformWallet.ShieldedRecoveryKeysRequired(message, cause) 59 -> PlatformWallet.SpvProcessRestartRequired(message, cause) // ErrorSpvProcessRestartRequired - // ErrorPrivateContactRequestNeedsPrivateReply: the host routes the - // refused public accept (or send) to the private one. + // ErrorPrivateContactRequestNeedsPrivateReply: the explicitly + // public accept or send refused a contact that went private. 68 -> PlatformWallet.PrivateContactRequestNeedsPrivateReply(message, cause) else -> // @Deprecated fallback — see the code-6 arm; code 31 is the diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/DashpayNative.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/DashpayNative.kt index 3fb6fcc752f..4096e78c2e4 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/DashpayNative.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/DashpayNative.kt @@ -327,4 +327,23 @@ internal object DashpayNative { shieldedAddress: ByteArray?, signerHandle: Long, ): Long + + /** + * Accept the incoming contact request [requestHandle] replying the way + * it came: privately, paid from [account]'s shielded notes, when it came + * privately and no reply exists yet; otherwise with a document, or by + * adopting the reply already sent. The wallet decides. Arguments and + * result as for [acceptPrivateContactRequest]. + * ← Swift `PlatformWalletManager.acceptContactRequestReplyingInKind`. + */ + @Suppress("LongParameterList") + external fun acceptContactRequestReplyingInKind( + managerHandle: Long, + walletId: ByteArray, + resolverHandle: Long, + account: Int, + requestHandle: Long, + shieldedAddress: ByteArray?, + signerHandle: Long, + ): Long } diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/tokens/Dashpay.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/tokens/Dashpay.kt index 6787b34bd56..6d777e671db 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/tokens/Dashpay.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/tokens/Dashpay.kt @@ -339,10 +339,9 @@ class Dashpay internal constructor(private val walletHandle: Long, * The incoming contact request from [senderId] to [ourIdentityId] in * local state, or null when there is none (or the identity isn't * managed by this wallet). Close the returned ref when done. This is - * the request `PlatformWalletManager.acceptPrivateContactRequest` - * takes when the public accept refuses a contact whose requests came - * privately ([DashSdkError.PlatformWallet.PrivateContactRequestNeedsPrivateReply]). - * Local read; no network I/O. + * the request `PlatformWalletManager.acceptContactRequestReplyingInKind` + * takes: the accept that replies privately to a request that came + * privately, and with a document otherwise. Local read; no network I/O. * ← Swift `ManagedIdentity.getIncomingContactRequest(senderId:)`. */ suspend fun incomingContactRequest( diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/PlatformWalletManager.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/PlatformWalletManager.kt index e1aae239b36..b7f68b00aa6 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/PlatformWalletManager.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/PlatformWalletManager.kt @@ -2185,10 +2185,10 @@ class PlatformWalletManager( * `PlatformWalletManager.acceptPrivateContactRequest` * (`packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PrivateContactRequests.swift`). * - * The public `Dashpay.acceptContactRequest` refuses such a request with - * [DashSdkError.PlatformWallet.PrivateContactRequestNeedsPrivateReply]; - * read it with `Dashpay.incomingContactRequest` and accept it here. The - * reply is signed by this manager's identity signer ([signerHandle]). + * A host accepts with [acceptContactRequestReplyingInKind], which + * picks this reply itself; this explicit accept stays for a host that + * wants a private reply. The reply is signed by this manager's identity + * signer ([signerHandle]). * [shieldedAddress] is the 43-byte raw Orchard address the contact may * pay us at privately. Blocks for the Halo 2 proof. * @@ -2215,6 +2215,48 @@ class PlatformWalletManager( EstablishedContactRef(handle) } + /** + * Accept a DashPay contact request replying the way it came: privately, + * paid from [account]'s shielded notes on [walletId], when it came + * privately and no reply exists yet; otherwise with a `contactRequest` + * document, or by adopting the reply already sent. Which reply a request + * takes is the wallet's decision, so this is the accept a host calls. + * Port of Swift's `PlatformWalletManager.acceptContactRequestReplyingInKind` + * (`packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PrivateContactRequests.swift`). + * + * [request] is the incoming request (`Dashpay.incomingContactRequest`). + * The reply is signed by this manager's identity signer ([signerHandle]); + * the Orchard spend authority comes from this manager's resolver and is + * derived only for a private reply. [shieldedAddress] is the 43-byte raw + * Orchard address the contact may pay us at privately; a public reply + * ignores it. + * + * Throws [DashSdkError.PlatformWallet.ShieldedSpendUnconfirmed] when a + * private reply may already be on chain: do not accept again. + * + * @return the established contact; close it (or `use {}`) when done. + */ + suspend fun acceptContactRequestReplyingInKind( + walletId: ByteArray, + request: ContactRequestRef, + shieldedAddress: ByteArray? = null, + account: Int = 0, + ): EstablishedContactRef = teardownGate.op { + require(account >= 0) { "account must be non-negative, got $account" } + val handle = mapNativeErrors { + DashpayNative.acceptContactRequestReplyingInKind( + managerHandle, + walletId, + mnemonicResolver.nativeHandle, + account, + request.value, + shieldedAddress, + signerHandle, + ) + } + EstablishedContactRef(handle) + } + /** * Shielded → Core L1 withdrawal (Type 19) — port of Swift's * `PlatformWalletManager.shieldedWithdraw(walletId:account:toCoreAddress:amount:coreFeePerByte:)` diff --git a/packages/rs-unified-sdk-jni/src/dashpay.rs b/packages/rs-unified-sdk-jni/src/dashpay.rs index 15ac9a42322..1b3803f2ec1 100644 --- a/packages/rs-unified-sdk-jni/src/dashpay.rs +++ b/packages/rs-unified-sdk-jni/src/dashpay.rs @@ -1218,9 +1218,11 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_DashpayNative_claimIn // // Mirrors Swift's `PrivateContactRequests.swift`: the store sync is a // wallet-handle call (`ManagedPlatformWallet.syncPrivateContactRequests`); -// the send and the private-reply accept are manager-handle shielded spends +// the send, the private-reply accept and the accept that replies the way a +// request came are manager-handle shielded spends // (`PlatformWalletManager.sendPrivateContactRequest` / -// `acceptPrivateContactRequest`), gated like the other shielded spends. +// `acceptPrivateContactRequest` / `acceptContactRequestReplyingInKind`), +// gated like the other shielded spends. /// The bytes of an Orchard payment address the FFI reads from a /// `shielded_address` pointer: an 11-byte diversifier and a 32-byte @@ -1484,6 +1486,61 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_DashpayNative_acceptP }) } +/// Accept the incoming contact request `requestHandle` replying the way it +/// came (bridges +/// `platform_wallet_manager_accept_contact_request_replying_in_kind`): +/// privately, paid from `account`'s shielded notes, when it came privately +/// and no reply exists yet; otherwise with a `contactRequest` document, or by +/// adopting the reply already sent. The wallet decides; this is the accept a +/// host calls. Arguments and result are as for +/// [`Java_org_dashfoundation_dashsdk_ffi_DashpayNative_acceptPrivateContactRequest`]. +#[cfg(feature = "shielded")] +#[no_mangle] +#[allow(clippy::too_many_arguments)] +pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_DashpayNative_acceptContactRequestReplyingInKind( + mut env: JNIEnv, + _class: JClass, + manager_handle: jlong, + wallet_id: JByteArray, + resolver_handle: jlong, + account: jint, + request_handle: jlong, + shielded_address: JByteArray, + signer_handle: jlong, +) -> jlong { + guard(&mut env, 0, |env| { + if account < 0 { + throw_sdk_exception(env, 1, "account must be non-negative"); + return 0; + } + let Some(wid) = read_id32(env, &wallet_id, "walletId") else { + return 0; + }; + let Ok(address) = read_opt_shielded_address(env, &shielded_address) else { + return 0; + }; + let mut out_handle: Handle = 0; + let result = unsafe { + platform_wallet_ffi::platform_wallet_manager_accept_contact_request_replying_in_kind( + manager_handle as Handle, + wid.as_ptr(), + resolver_handle as *mut MnemonicResolverHandle, + account as u32, + request_handle as Handle, + address + .as_ref() + .map_or(ptr::null(), |address| address.as_ptr()), + signer_handle as *mut SignerHandle, + &mut out_handle, + ) + }; + if take_pwffi_error(env, result) { + return 0; + } + out_handle as jlong + }) +} + #[cfg(test)] mod tests { use super::*; From 3b136217a4045456ff46f75d7c648679cf94933d Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Sun, 8 Nov 2026 21:43:54 +0700 Subject: [PATCH 026/269] test(kotlin-sdk): release real contact request handles in the JNI sync bridge `release_contact_request_handles` destroys the handles the private store sync registers, since Kotlin reads those requests from Room. The new JVM-free test registers two real requests through platform-wallet-ffi's `contact_request_create`, puts their handles in a boxed slice as `ContactRequestHandleArray::from_requests` allocates one, releases the array, and checks the count, the null pointer and zero count left behind, and that `contact_request_get_sender_id` refuses both handles afterward. Co-Authored-By: Claude Opus 5.5 --- packages/rs-unified-sdk-jni/src/dashpay.rs | 77 +++++++++++++++++++++- 1 file changed, 75 insertions(+), 2 deletions(-) diff --git a/packages/rs-unified-sdk-jni/src/dashpay.rs b/packages/rs-unified-sdk-jni/src/dashpay.rs index 1b3803f2ec1..b7a1c9bdddf 100644 --- a/packages/rs-unified-sdk-jni/src/dashpay.rs +++ b/packages/rs-unified-sdk-jni/src/dashpay.rs @@ -1544,9 +1544,52 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_DashpayNative_acceptC #[cfg(test)] mod tests { use super::*; + use platform_wallet_ffi::error::PlatformWalletFFIResultCode; - // The natives above need a JVM; the counter layout Kotlin decodes is - // pure and is pinned here. + // The natives above need a JVM; the counter layout Kotlin decodes and the + // handle release are pure and are pinned here. + + /// A real `ContactRequest` handle, registered through platform-wallet-ffi's + /// `contact_request_create` as the FFI's own enumerators register theirs. + fn registered_request(sender_byte: u8) -> Handle { + let sender = [sender_byte; 32]; + let recipient = [0xB0; 32]; + let encrypted_public_key = [0x33; 96]; + let mut handle: Handle = 0; + let mut result = unsafe { + platform_wallet_ffi::contact_request_create( + sender.as_ptr(), + recipient.as_ptr(), + 1, + 2, + 7, + encrypted_public_key.as_ptr(), + encrypted_public_key.len(), + 100, + 1_700_000_000_000, + &mut handle, + ) + }; + assert_eq!(result.code, PlatformWalletFFIResultCode::Success); + unsafe { platform_wallet_ffi_result_free(&mut result) }; + assert_ne!(handle, 0); + handle + } + + /// The sender id a getter reads off `handle`, or the code it refuses with. + fn sender_id_of(handle: Handle) -> Result<[u8; 32], PlatformWalletFFIResultCode> { + let mut sender = [0u8; 32]; + let mut result = unsafe { + platform_wallet_ffi::contact_request_get_sender_id(handle, sender.as_mut_ptr()) + }; + let code = result.code; + unsafe { platform_wallet_ffi_result_free(&mut result) }; + if code == PlatformWalletFFIResultCode::Success { + Ok(sender) + } else { + Err(code) + } + } #[test] fn should_lay_out_the_private_sync_counters_in_the_order_kotlin_reads() { @@ -1580,4 +1623,34 @@ mod tests { assert_eq!(unsafe { release_contact_request_handles(&mut array) }, 0); assert!(array.handles.is_null()); } + + /// The sync bridge hands Kotlin no handles, so it must destroy every + /// handle the FFI registered as well as free the array: both handles read + /// before the release and are refused after it. + #[test] + fn should_destroy_every_handle_of_the_array_and_free_it() { + let handles = [registered_request(0xA1), registered_request(0xA2)]; + assert_eq!(sender_id_of(handles[0]), Ok([0xA1; 32])); + assert_eq!(sender_id_of(handles[1]), Ok([0xA2; 32])); + + // The `ContactRequestHandleArray` contract: a boxed slice of handles, + // as `ContactRequestHandleArray::from_requests` allocates it. + let boxed: Box<[Handle]> = handles.to_vec().into_boxed_slice(); + let count = boxed.len(); + let mut array = ContactRequestHandleArray { + handles: Box::into_raw(boxed) as *mut Handle, + count, + }; + + assert_eq!(unsafe { release_contact_request_handles(&mut array) }, 2); + assert!(array.handles.is_null()); + assert_eq!(array.count, 0); + for handle in handles { + assert_eq!( + sender_id_of(handle), + Err(PlatformWalletFFIResultCode::NotFound), + "a released handle reads nothing" + ); + } + } } From 58c180a5bfdadf1039b1e1801a8ae1d6494ead64 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Sun, 8 Nov 2026 21:43:54 +0700 Subject: [PATCH 027/269] refactor(kotlin-example-app): let the wallet choose how an accepted request is replied to `acceptIncomingContactRequest` looks the request up and makes the one `acceptContactRequestReplyingInKind` call, instead of trying the public accept and falling back to the private one on code 68. Requests and Add Contact's collision accept keep calling it. Mirrors the Swift app. Co-Authored-By: Claude Opus 5.5 --- .../example/ui/dashpay/DashPaySyncHelpers.kt | 30 +++++++------------ 1 file changed, 11 insertions(+), 19 deletions(-) diff --git a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/DashPaySyncHelpers.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/DashPaySyncHelpers.kt index 6294b4fc2d1..3d1f0b9bc95 100644 --- a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/DashPaySyncHelpers.kt +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/DashPaySyncHelpers.kt @@ -3,7 +3,6 @@ package org.dashfoundation.example.ui.dashpay import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch -import org.dashfoundation.dashsdk.errors.DashSdkError import org.dashfoundation.dashsdk.wallet.ManagedPlatformWallet import org.dashfoundation.dashsdk.wallet.PlatformWalletManager @@ -50,28 +49,21 @@ suspend fun syncPrivateContactRequests( } /** - * Accept the incoming request from [senderId] to [ourIdentityId]. Rust refuses - * a public reply to a contact whose requests came privately, as it would - * publish the relationship; that one is accepted with a private reply - * instead, paid from the wallet's shielded balance and signed by the same - * identity signer. Rust decides which; the app only reacts to the refusal. - * Returns false when the request is not in local state. - * ← Swift `acceptIncomingContactRequest` (`ContactsView.swift`). + * Accept the incoming request from [senderId] to [ourIdentityId] with the + * wallet's one accept, which replies the way the request came: privately + * (paid from the wallet's shielded balance) or with a document, signed by the + * manager's identity signer either way. The wallet decides; the app makes one + * call. Returns false when the request is not in local state. + * ← Swift `PlatformWalletManager.acceptContactRequestReplyingInKind`, as + * `ContactRequestsView.swift` and `AddContactView.swift` call it. */ suspend fun acceptIncomingContactRequest( wallet: ManagedPlatformWallet, manager: PlatformWalletManager, ourIdentityId: ByteArray, senderId: ByteArray, -): Boolean = try { - wallet.dashpay.acceptIncomingRequest( - ourIdentityId = ourIdentityId, - senderId = senderId, - signerHandle = manager.signerHandle, - coreSignerHandle = manager.mnemonicResolverHandle, - ) -} catch (refused: DashSdkError.PlatformWallet.PrivateContactRequestNeedsPrivateReply) { - val request = wallet.dashpay.incomingContactRequest(ourIdentityId, senderId) - request?.use { manager.acceptPrivateContactRequest(wallet.walletId, it).close() } - request != null +): Boolean { + val request = wallet.dashpay.incomingContactRequest(ourIdentityId, senderId) ?: return false + request.use { manager.acceptContactRequestReplyingInKind(wallet.walletId, it).close() } + return true } From 13f4784734dad20528003308c73ae5cbfe4c1fed Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Sun, 8 Nov 2026 21:43:54 +0700 Subject: [PATCH 028/269] docs(kotlin-sdk): track the accept replying in kind in the private contact parity entry `dashpay.private_contact_requests` lists `platform_wallet_manager_accept_contact_request_replying_in_kind`, both hosts' reasons say the example apps accept with it, and the entry cites the wallet's in-kind test and the JNI handle-release test. Statuses are unchanged, so `PARITY_SUMMARY.md` regenerates identical. Co-Authored-By: Claude Opus 5.5 --- docs/sdk/sdk-parity-manifest.json | 24 +++++++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/docs/sdk/sdk-parity-manifest.json b/docs/sdk/sdk-parity-manifest.json index 109342d77a4..f8e76467ca8 100644 --- a/docs/sdk/sdk-parity-manifest.json +++ b/docs/sdk/sdk-parity-manifest.json @@ -41,6 +41,7 @@ "platform_wallet_create_document_with_signer": "packages/rs-platform-wallet-ffi/src/document.rs", "platform_wallet_create_invitation": "packages/rs-platform-wallet-ffi/src/invitation.rs", "platform_wallet_drain_pending_contact_crypto": "packages/rs-platform-wallet-ffi/src/dashpay.rs", + "platform_wallet_manager_accept_contact_request_replying_in_kind": "packages/rs-platform-wallet-ffi/src/private_contact_request.rs", "platform_wallet_manager_accept_private_contact_request": "packages/rs-platform-wallet-ffi/src/private_contact_request.rs", "platform_wallet_manager_create_with_persistence_capabilities": "packages/rs-platform-wallet-ffi/src/manager.rs", "platform_wallet_manager_persistence_capabilities": "packages/rs-platform-wallet-ffi/src/manager.rs", @@ -776,11 +777,12 @@ }, { "id": "dashpay.private_contact_requests", - "title": "DashPay private contact requests: store sync, shielded send, private-reply accept, private contact read, code 68 routing", + "title": "DashPay private contact requests: store sync, shielded send, accept replying in kind, private-reply accept, private contact read, code 68 routing", "area": "correctness", "shared_apis": [ "platform_wallet_sync_private_contact_requests", "platform_wallet_manager_send_private_contact_request", + "platform_wallet_manager_accept_contact_request_replying_in_kind", "platform_wallet_manager_accept_private_contact_request", "managed_identity_get_private_contact" ], @@ -793,13 +795,13 @@ "sdk": "partial", "example_app": "partial", "restart": "required", - "reason": "PrivateContactRequests.swift wraps the store sync, the shielded send and private-reply accept (signed with the identity signer the public calls take) and PrivateContactInfo; code 68 is typed and pinned in ErrorHandlingTests. The sync, send and accept have no automated or recorded gate: they need a protocol version 15 network with shielded funds. The private marker (core height 0) round-trips through the persisted coreHeightCreatedAt, but no test covers a restart. The example app has the send-privately toggle, the private-accept fallback on code 68 and the private sync on pull-to-refresh, with no recorded gate." + "reason": "PrivateContactRequests.swift wraps the store sync, the shielded send, the accept replying in kind (the wallet replies privately to a request that came privately, and with a document otherwise), the explicit private-reply accept and PrivateContactInfo, all signed with the identity signer the public calls take; code 68 is typed and pinned in ErrorHandlingTests. The sync, send and accepts have no automated or recorded gate: a private reply needs a protocol version 15 network with shielded funds. The private marker (core height 0) round-trips through the persisted coreHeightCreatedAt, but no test covers a restart. The example app has the send-privately toggle, accepts with acceptContactRequestReplyingInKind and syncs the private store on pull-to-refresh, with no recorded gate." }, "kotlin": { "sdk": "partial", "example_app": "partial", "restart": "required", - "reason": "Dashpay.syncPrivateContactRequests and PlatformWalletManager.sendPrivateContactRequest / acceptPrivateContactRequest bridge the same FFI (signed with the manager's identity signer), and code 68 maps to PlatformWallet.PrivateContactRequestNeedsPrivateReply (DashSdkErrorTest). managed_identity_get_private_contact is not bridged: the Swift example app has no caller for it. JNI descriptors are covered only by the FfiSmokeTest symbol test; the flows need a protocol version 15 network with shielded funds and have no recorded gate, and no test covers a restart. The example app falls back to the private accept on code 68 and syncs the private store on pull-to-refresh; it has no send-privately toggle." + "reason": "Dashpay.syncPrivateContactRequests and PlatformWalletManager.sendPrivateContactRequest / acceptContactRequestReplyingInKind / acceptPrivateContactRequest bridge the same FFI (signed with the manager's identity signer), and code 68 maps to PlatformWallet.PrivateContactRequestNeedsPrivateReply (DashSdkErrorTest). managed_identity_get_private_contact is not bridged: the Swift example app has no caller for it. JNI descriptors are covered only by the FfiSmokeTest symbol test; the flows need a protocol version 15 network with shielded funds and have no recorded gate, and no test covers a restart. The example app accepts with acceptContactRequestReplyingInKind and syncs the private store on pull-to-refresh; it has no send-privately toggle." } }, "verification": [ @@ -811,6 +813,14 @@ "command": "cargo test -p platform-wallet --lib should_send_read_and_accept_a_private_contact_request", "covers_restart": false }, + { + "host": "shared", + "kind": "unit", + "file": "packages/rs-platform-wallet/src/wallet/identity/network/private_contact_requests.rs", + "id": "should_accept_a_public_request_replying_in_kind", + "command": "cargo test -p platform-wallet --lib should_accept_a_public_request_replying_in_kind", + "covers_restart": false + }, { "host": "shared", "kind": "unit", @@ -843,6 +853,14 @@ "command": "cargo test -p rs-unified-sdk-jni --lib should_lay_out_the_private_sync_counters_in_the_order_kotlin_reads", "covers_restart": false }, + { + "host": "kotlin", + "kind": "unit", + "file": "packages/rs-unified-sdk-jni/src/dashpay.rs", + "id": "should_destroy_every_handle_of_the_array_and_free_it", + "command": "cargo test -p rs-unified-sdk-jni --lib should_destroy_every_handle_of_the_array_and_free_it", + "covers_restart": false + }, { "host": "kotlin", "kind": "device", From d4efa2e5db79f84e3c369d973988d0c17c6eaf71 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Sun, 8 Nov 2026 21:52:13 +0700 Subject: [PATCH 029/269] 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 030/269] 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 5358ce1bf95cf37e040087439fa4f471b86c3ea2 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 9 Nov 2026 00:12:18 +0700 Subject: [PATCH 031/269] fix(rs-sdk-ffi): build the resolver signer's scan keys with the SDK's derive - `scan_private_document_page` builds its keys with `PrivateDocumentScanKeys::derive`, which wipes the derived keys on an error too. The signer keeps only the mnemonic resolve and the per-path derivation; its own scan-keys helper and `WipingSecretKeys` are gone. - `seal_private_contact_request` takes `platform_version` last and passes it to the SDK seal, which validates the payload with dpp. - Tests follow the SDK: signing data and seals take the platform version, and the read takes `ProvedIdentity` entries proved at the heights the scan needs. Co-Authored-By: Claude Opus 5.5 --- .../src/mnemonic_resolver_core_signer.rs | 91 ++++++++----------- 1 file changed, 39 insertions(+), 52 deletions(-) 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 f27eeb698ce..0da3c0dbd4e 100644 --- a/packages/rs-sdk-ffi/src/mnemonic_resolver_core_signer.rs +++ b/packages/rs-sdk-ffi/src/mnemonic_resolver_core_signer.rs @@ -65,9 +65,9 @@ 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::{ - private_contact_request_outgoing_viewing_key, seal_private_contact_request, - PrivateContactRequest, PrivateContactRequestSender, + seal_private_contact_request, PrivateContactRequest, PrivateContactRequestSender, }; use dash_sdk::platform::private_document_entries::PrivateDocumentEntries; use dash_sdk::platform::private_document_scan::{PrivateDocumentScan, PrivateDocumentScanKeys}; @@ -622,8 +622,10 @@ impl MnemonicResolverCoreSigner { /// 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. Every key comes from one - /// resolve and is dropped here; only the sorted entries return. + /// 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)>)], @@ -638,7 +640,11 @@ impl MnemonicResolverCoreSigner { document_type_name, }; self.resolve_master(|master| { - let keys = private_document_scan_keys(master, recipients, senders)?; + // `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)) }) } @@ -665,6 +671,7 @@ impl MnemonicResolverCoreSigner { 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))?; @@ -681,6 +688,7 @@ impl MnemonicResolverCoreSigner { recipient_key_id, request, esk_seed, + platform_version, ) .map_err(MnemonicResolverSignerError::PrivateContactRequest) } @@ -700,34 +708,6 @@ fn secret_key_at( Ok(WipingSecretKey(secret)) } -/// The scan keys of `recipients` and `senders`, derived below `master`: each -/// recipient's secret keys, and the outgoing viewing key of each sender -/// encryption key ([`private_contact_request_outgoing_viewing_key`]). The -/// copies made here are wiped before this returns. -fn private_document_scan_keys( - master: &ExtendedPrivKey, - recipients: &[(Identifier, Vec<(KeyID, DerivationPath)>)], - senders: &[(Identifier, KeyID, DerivationPath)], -) -> Result { - let mut keys = PrivateDocumentScanKeys::new(); - for (identity_id, paths) in recipients { - let mut secret_keys = WipingSecretKeys(Vec::with_capacity(paths.len())); - for (key_id, path) in paths { - secret_keys - .0 - .push((*key_id, secret_key_at(master, path)?.0)); - } - keys = keys.with_recipient(*identity_id, &secret_keys.0); - } - for (identity_id, key_id, path) in senders { - let secret = secret_key_at(master, path)?; - let outgoing_viewing_key = - Zeroizing::new(private_contact_request_outgoing_viewing_key(&secret.0)); - keys = keys.with_sender(*identity_id, *key_id, *outgoing_viewing_key); - } - Ok(keys) -} - /// Result of [`MnemonicResolverCoreSigner::contact_info_seal`]. pub struct ContactInfoSealed { /// `encToUserId` ciphertext (AES-256-ECB of the 32-byte contact id). @@ -753,17 +733,6 @@ impl Drop for WipingSecretKey { } } -/// Scrubs the secret keys of one scan recipient on every exit path. -struct WipingSecretKeys(Vec<(KeyID, secp256k1::SecretKey)>); - -impl Drop for WipingSecretKeys { - fn drop(&mut self) { - for (_, secret_key) in &mut self.0 { - secret_key.non_secure_erase(); - } - } -} - #[async_trait] impl Signer for MnemonicResolverCoreSigner { type Error = MnemonicResolverSignerError; @@ -1393,8 +1362,9 @@ mod tests { 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_signature, - private_contact_request_signing_data, private_contact_requests_from_scan, + 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; @@ -1605,6 +1575,7 @@ mod tests { recipient, RECIPIENT_KEY_ID, &request(), + PlatformVersion::latest(), ) .expect("the keys check"); private_contact_request_signature(&signer, &signing_key, &signing_data) @@ -1636,6 +1607,7 @@ mod tests { RECIPIENT_KEY_ID, &request(), &[esk_seed; 32], + PlatformVersion::latest(), ) .expect("the request seals") } @@ -1768,6 +1740,7 @@ mod tests { RECIPIENT_KEY_ID, &request(), &[7; 32], + PlatformVersion::latest(), ) .expect("the signer seals the request"); assert_eq!(sealed, sent_to_recipient(&contract, 7).await); @@ -1808,15 +1781,26 @@ mod tests { assert!(scan.rejected.is_empty()); assert!(scan.is_complete()); - let identities: BTreeMap<_, _> = [sender(), recipient()] - .into_iter() - .map(|identity| (identity.id(), Some(identity))) - .collect(); + let needed = private_contact_request_identities(&scan); assert_eq!( - private_contact_request_identities(&scan), - identities.keys().copied().collect(), + 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, @@ -1896,6 +1880,7 @@ mod tests { RECIPIENT_KEY_ID, &request(), &[4; 32], + PlatformVersion::latest(), ) .expect("the signer seals the request"); assert_eq!(resolves.load(Ordering::SeqCst), 2); @@ -1931,6 +1916,7 @@ mod tests { RECIPIENT_KEY_ID, &request(), &[7; 32], + PlatformVersion::latest(), ) .expect_err("the key at that path is not the sender's"); assert!( @@ -1974,6 +1960,7 @@ mod tests { RECIPIENT_KEY_ID, &request(), &[7; 32], + PlatformVersion::latest(), ) .expect_err("the signature is not the signing key's"); assert!( From 0510a5bbec660449e527ebf686cb224f354c3bb5 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 9 Nov 2026 00:12:18 +0700 Subject: [PATCH 032/269] fix(platform-wallet): pay an in-kind accept's private reply only when it is one, in every build `platform_wallet_manager_accept_contact_request_replying_in_kind` no longer looks up the shielded coordinator or reads the mnemonic up front. It hands the wallet a payment that, only for a private reply, looks the coordinator up and resolves the seed on a blocking thread. A public or adopted reply therefore works on a manager without `platform_wallet_manager_configure_shielded` and reads no mnemonic for the payment (an FFI test pins both). A failure of that lookup returns the code the lookup reported. A reply for which no payment was asked keeps the codes the public accept returns; once one was asked, errors keep the shielded spend contract. The export and its JNI-facing contract exist in every build. Without the `shielded` feature it calls `DashPayView::accept_contact_request_replying_in_kind` with a payment that refuses: a request that needs a private reply fails with the new `ErrorShieldedBuildRequired` (69), and a public or adopted reply works as before. No existing code meant "shielded unsupported". Also: - `ErrorProtocolVersionNotActive` (70): the SDK's lasting `ProtocolVersionNotActive` refusal, such as the private store before protocol version 15, was `ErrorUnknown`; hosts can now stay quiet about it. A refusal that may lift soon keeps the catch-all. - A missing mnemonic while scanning or sealing the private store maps to the key-unavailable outcome (31), through the signer's position-0 marker in a `ProtocolError::Generic`, matched by variant; a test drives it through the resolver-backed provider. - The seal passes `platform_version`. - The private store sync runs on `run_on_big_stack_thread`: the SDK's identity fetch stream trips rust-lang/rust#100013 against `block_on_worker`'s `Send + 'static` bounds. Both codes are in `ERROR_CODE_REGISTRY.md`. Co-Authored-By: Claude Opus 5.5 --- .../ERROR_CODE_REGISTRY.md | 6 +- .../rs-platform-wallet-ffi/src/dashpay.rs | 91 +++- packages/rs-platform-wallet-ffi/src/error.rs | 56 ++ .../src/private_contact_request.rs | 506 +++++++++++++++--- .../src/shielded_send.rs | 24 + 5 files changed, 610 insertions(+), 73 deletions(-) diff --git a/packages/rs-platform-wallet-ffi/ERROR_CODE_REGISTRY.md b/packages/rs-platform-wallet-ffi/ERROR_CODE_REGISTRY.md index 301688c27b6..5874c733b44 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: 69**: 27–68 are all claimed (27, 29, 31, 34–42 +**Next allocatable integer: 71**: 27–70 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 -proposed for DashPay private contact requests; 28, 30, +to 70 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 @@ -182,6 +182,8 @@ Fork-era numbers remain in the collision history, which is immutable record. | 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 | **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/dashpay.rs b/packages/rs-platform-wallet-ffi/src/dashpay.rs index 0dbe5026682..b1c3135d6cb 100644 --- a/packages/rs-platform-wallet-ffi/src/dashpay.rs +++ b/packages/rs-platform-wallet-ffi/src/dashpay.rs @@ -41,6 +41,8 @@ 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, @@ -1276,6 +1278,7 @@ impl platform_wallet::ContactCryptoProvider for ResolverContactCryptoProvider { recipient_key_id: KeyID, request: &PrivateContactRequest, esk_seed: &[u8; 32], + platform_version: &PlatformVersion, ) -> Result { self.signer .seal_private_contact_request( @@ -1289,19 +1292,26 @@ impl platform_wallet::ContactCryptoProvider for ResolverContactCryptoProvider { 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, the rest are reported as the other provider -/// methods report theirs. +/// 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()), } } @@ -1735,6 +1745,83 @@ pub unsafe extern "C" fn platform_wallet_drainable_contact_crypto_count( mod tests { use super::*; + mod private_store_scan_errors { + use super::*; + use platform_wallet::ContactCryptoProvider; + use rs_sdk_ffi::{ + dash_sdk_mnemonic_resolver_create, dash_sdk_mnemonic_resolver_destroy, + mnemonic_resolver_result, + }; + use std::ffi::c_void; + use std::str::FromStr; + + unsafe extern "C" fn no_mnemonic( + _ctx: *const c_void, + _wallet_id_bytes: *const u8, + _out_buf: *mut c_char, + _out_capacity: usize, + _out_len: *mut usize, + ) -> i32 { + mnemonic_resolver_result::NOT_FOUND + } + + unsafe extern "C" fn noop_destroy(_ctx: *mut c_void) {} + + /// A scan of the private store whose resolver holds no mnemonic for the + /// wallet reaches the host as the key-unavailable outcome (code 31), as + /// a signer's missing key does, so the host routes it to key repair + /// instead of reading a generic failure. + #[test] + fn should_report_a_missing_mnemonic_while_scanning_as_key_unavailable() { + let resolver = unsafe { + dash_sdk_mnemonic_resolver_create(std::ptr::null_mut(), no_mnemonic, noop_destroy) + }; + let provider = unsafe { + resolver_contact_crypto_provider(resolver, [0u8; 32], key_wallet::Network::Testnet) + }; + let keys = PrivateDocumentKeyPaths { + recipients: vec![( + Identifier::from([0xB0; 32]), + vec![( + 2, + key_wallet::bip32::DerivationPath::from_str("m/9'/1'/5'/0'/0'/1'/2'") + .expect("valid path"), + )], + )], + senders: Vec::new(), + }; + let page = PrivateDocumentEntries { + entries: Vec::new(), + total_count: 0, + }; + let error = runtime() + .block_on(provider.scan_private_document_page( + &keys, + Identifier::from([0xD0; 32]), + "privateContactRequest", + 0, + &page, + )) + .expect_err("no mnemonic, no keys"); + let result = PlatformWalletFFIResult::from(error); + assert_eq!( + result.code, + PlatformWalletFFIResultCode::ErrorSigningKeyUnavailable + ); + + // Another resolver failure keeps the code it had. + let other = PlatformWalletFFIResult::from(private_document_signer_error( + MnemonicResolverSignerError::BufferTooSmall, + )); + assert_ne!( + other.code, + PlatformWalletFFIResultCode::ErrorSigningKeyUnavailable + ); + + unsafe { dash_sdk_mnemonic_resolver_destroy(resolver) }; + } + } + // 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-platform-wallet-ffi/src/error.rs b/packages/rs-platform-wallet-ffi/src/error.rs index e0e1ea2333f..5c43e898632 100644 --- a/packages/rs-platform-wallet-ffi/src/error.rs +++ b/packages/rs-platform-wallet-ffi/src/error.rs @@ -634,6 +634,22 @@ pub enum PlatformWalletFFIResultCode { /// 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, + /// The named thing does not exist. /// /// Originally (and still mostly) the code for every `Option` returned as an @@ -1186,6 +1202,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()); @@ -2715,6 +2740,37 @@ mod identity_balance_error_tests { mod private_contact_request_error_tests { use super::*; + /// 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( diff --git a/packages/rs-platform-wallet-ffi/src/private_contact_request.rs b/packages/rs-platform-wallet-ffi/src/private_contact_request.rs index 82724044c13..c5eb551ab44 100644 --- a/packages/rs-platform-wallet-ffi/src/private_contact_request.rs +++ b/packages/rs-platform-wallet-ffi/src/private_contact_request.rs @@ -19,8 +19,9 @@ //! `shielded` feature only. //! - [`platform_wallet_manager_accept_contact_request_replying_in_kind`]: //! the one accept a host calls. The wallet replies privately to a request -//! that came privately, and with a document otherwise. Built with the -//! `shielded` feature only. +//! that came privately, and with a document otherwise. Exported in every +//! build; without the `shielded` feature a private reply fails with +//! `ErrorShieldedBuildRequired`. //! - [`managed_identity_get_private_contact`]: what a contact's private //! requests carry beyond the `ContactRequest`: whether they came or went //! privately, and the contact's shielded address. @@ -28,39 +29,45 @@ //! The explicitly public send and accept refuse a contact whose requests came //! or went privately with `ErrorPrivateContactRequestNeedsPrivateReply`. -use platform_wallet::PrivateContact; -use rs_sdk_ffi::MnemonicResolverHandle; -#[cfg(feature = "shielded")] -use rs_sdk_ffi::{SignerHandle, VTableSigner}; +use std::sync::{Arc, Mutex}; +use platform_wallet::{PlatformWalletError, PrivateContact}; +use rs_sdk_ffi::{MnemonicResolverHandle, SignerHandle, VTableSigner}; + +use crate::contact_request::CONTACT_REQUEST_STORAGE; use crate::dashpay::{resolver_contact_crypto_provider, ContactRequestHandleArray}; use crate::error::*; +use crate::established_contact::ESTABLISHED_CONTACT_STORAGE; use crate::handle::*; -use crate::runtime::block_on_worker; +use crate::runtime::{block_on_worker, run_on_big_stack_thread, runtime}; use crate::types::*; use crate::{check_ptr, unwrap_option_or_return, unwrap_result_or_return}; +#[cfg(not(feature = "shielded"))] +use dash_sdk::dapi_grpc::platform::v0::ResponseMetadata; + #[cfg(feature = "shielded")] use std::ffi::CStr; #[cfg(feature = "shielded")] use std::os::raw::c_char; +#[cfg(feature = "shielded")] +use std::sync::atomic::{AtomicBool, Ordering}; #[cfg(feature = "shielded")] -use platform_wallet::wallet::shielded::CachedOrchardProver; +use platform_wallet::wallet::shielded::{CachedOrchardProver, NetworkShieldedCoordinator}; #[cfg(feature = "shielded")] -use platform_wallet::AutoAcceptProofSource; - +use platform_wallet::{AutoAcceptProofSource, PrivateReplyPayment}; #[cfg(feature = "shielded")] -use crate::contact_request::CONTACT_REQUEST_STORAGE; +use zeroize::Zeroizing; + #[cfg(feature = "shielded")] use crate::dashpay::read_auto_accept_proof; #[cfg(feature = "shielded")] -use crate::established_contact::ESTABLISHED_CONTACT_STORAGE; -#[cfg(feature = "shielded")] use crate::identity_keys_from_mnemonic::resolve_seed_from_resolver; #[cfg(feature = "shielded")] use crate::shielded_send::{ - catch_spend_panic, map_spend_result, poke_sync_on_unconfirmed, resolve_wallet_and_coordinator, + catch_spend_panic, map_spend_result, poke_sync_on_unconfirmed, resolve_shielded_coordinator, + resolve_wallet_and_coordinator, }; /// The bytes of an Orchard payment address: an 11-byte diversifier and a @@ -145,12 +152,26 @@ pub unsafe extern "C" fn platform_wallet_sync_private_contact_requests( // call. let provider = unsafe { resolver_contact_crypto_provider(core_signer_handle, wallet_id, network) }; - let result = block_on_worker(async move { - identity - .dashpay() - .sync_private_contact_requests(&provider, max_pages as usize) - .await + // `run_on_big_stack_thread` rather than `block_on_worker`: the pass's + // future trips rustc's implied-lifetime-bound limitation (rust-lang/rust + // issue #100013) against `block_on_worker`'s `Send + 'static` bounds, + // through the SDK's identity fetch stream. + let spawned = run_on_big_stack_thread(|| { + runtime().block_on( + identity + .dashpay() + .sync_private_contact_requests(&provider, max_pages as usize), + ) }); + let result = match spawned { + Ok(result) => result, + Err(error) => { + return PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorWalletOperation, + format!("failed to spawn big-stack thread for the private contact sync: {error}"), + ); + } + }; let report = unwrap_result_or_return!(result); unsafe { *out_report = PrivateContactSyncReportFFI { @@ -242,7 +263,6 @@ pub unsafe extern "C" fn managed_identity_get_private_contact( /// /// # Safety /// A non-null `ptr` must point to [`SHIELDED_ADDRESS_LEN`] readable bytes. -#[cfg(feature = "shielded")] unsafe fn read_shielded_address(ptr: *const u8) -> Option<[u8; SHIELDED_ADDRESS_LEN]> { if ptr.is_null() { return None; @@ -538,24 +558,39 @@ unsafe fn accept_private_contact_request_inner( } /// Accept a DashPay contact request replying the way it came -/// (`PlatformWallet::accept_contact_request_replying_in_kind`): privately, -/// paid from `account`'s shielded notes, when it came privately and no reply -/// exists yet; otherwise with a `contactRequest` document signed by -/// `signer_handle`, or by adopting the reply already sent. Which reply a -/// request takes is the wallet's decision: this is the one accept a host -/// calls. `shielded_address` is the Orchard address the contact may pay us -/// at privately; null omits it, and a public reply ignores it. +/// (`accept_contact_request_replying_in_kind`): privately, paid from +/// `account`'s shielded notes, when it came privately and no reply exists +/// yet; otherwise with a `contactRequest` document signed by `signer_handle`, +/// or by adopting the reply already sent. Which reply a request takes is the +/// wallet's decision: this is the one accept a host calls. `shielded_address` +/// is the Orchard address the contact may pay us at privately; null omits it, +/// and a public reply ignores it. Exported in every build. +/// +/// Only a private reply needs shielded support and the spend authority: the +/// shielded coordinator is looked up, and the mnemonic read through +/// `mnemonic_resolver_handle`, when the wallet asks for the payment. A public +/// or adopted reply works on a manager without +/// `platform_wallet_manager_configure_shielded` and reads no mnemonic for the +/// payment (the DashPay keys still come from the resolver). In a build +/// without the `shielded` feature a request that needs a private reply fails +/// with `ErrorShieldedBuildRequired`, and every other accept works as in a +/// shielded build. /// -/// Arguments and outputs are as for -/// [`platform_wallet_manager_accept_private_contact_request`]. The spend -/// authority is derived only for a private reply. Errors follow the shielded -/// spend contract: `ErrorShieldedSpendUnconfirmed` means the reply may -/// already be on chain; do not accept again. +/// Errors: a public or adopted reply fails with the codes the public accept +/// returns. Once a private reply's payment is asked for, errors follow the +/// shielded spend contract: `ErrorShieldedSpendUnconfirmed` means the reply +/// may already be on chain; do not accept again. A failed payment lookup +/// returns the code the lookup reported. /// /// # Safety -/// Identical contract to -/// [`platform_wallet_manager_accept_private_contact_request`]. -#[cfg(feature = "shielded")] +/// - `wallet_id_bytes` must point to 32 readable bytes. +/// - `mnemonic_resolver_handle` must be a valid, non-destroyed handle; the +/// caller retains ownership and pins it for the call. +/// - `shielded_address`, when non-null, must point to 43 readable bytes. +/// - `signer_handle` must be a valid, non-destroyed `*mut SignerHandle` +/// produced by `dash_sdk_signer_create_with_ctx`; the caller retains +/// ownership and pins it for the call. +/// - `out_established_handle` must be a valid, writable pointer. #[no_mangle] #[allow(clippy::too_many_arguments)] pub unsafe extern "C" fn platform_wallet_manager_accept_contact_request_replying_in_kind( @@ -568,9 +603,7 @@ pub unsafe extern "C" fn platform_wallet_manager_accept_contact_request_replying signer_handle: *mut SignerHandle, out_established_handle: *mut Handle, ) -> PlatformWalletFFIResult { - // Same panic contract as the private accept: a private reply's notes may - // be spent when it strikes. - catch_spend_panic("contact request accept", || { + let accept = || { accept_contact_request_replying_in_kind_inner( handle, wallet_id_bytes, @@ -581,15 +614,98 @@ pub unsafe extern "C" fn platform_wallet_manager_accept_contact_request_replying signer_handle, out_established_handle, ) - }) + }; + // Same panic contract as the private accept: a private reply's notes may + // be spent when it strikes. A build without shielded support spends none. + #[cfg(feature = "shielded")] + let result = catch_spend_panic("contact request accept", accept); + #[cfg(not(feature = "shielded"))] + let result = accept(); + result +} + +/// Why a private reply could not be paid for, as the FFI reports it: the +/// export returns this code and message rather than the wallet error the +/// payment hands back to the wallet. Kept as owned parts, since a result +/// cannot cross threads. +type PaymentFailure = (PlatformWalletFFIResultCode, String); + +/// The parts of a failed FFI `result`; its message is freed with it. +#[cfg(feature = "shielded")] +fn payment_failure(result: PlatformWalletFFIResult) -> PaymentFailure { + let message = if result.message.is_null() { + String::new() + } else { + // SAFETY: a non-null message is a NUL-terminated string the FFI made. + unsafe { CStr::from_ptr(result.message) } + .to_string_lossy() + .into_owned() + }; + (result.code, message) +} + +/// Record `failure` for the export to return, and the wallet error the +/// payment hands back to the wallet in its place. +fn record_payment_failure( + slot: &Mutex>, + failure: PaymentFailure, +) -> PlatformWalletError { + let error = PlatformWalletError::InvalidParameter(failure.1.clone()); + if let Ok(mut recorded) = slot.lock() { + *recorded = Some(failure); + } + error +} + +/// The wallet of `wallet_id` under the manager `handle`, without the +/// shielded coordinator, which only a private reply needs. Wait-free (the +/// manager's wallet map is read without a lock or the runtime), with the +/// errors `resolve_wallet_and_coordinator` reports. +fn resolve_manager_wallet( + handle: Handle, + wallet_id: &[u8; 32], +) -> Result, PlatformWalletFFIResult> { + match PLATFORM_WALLET_MANAGER_STORAGE + .with_item(handle, |manager| manager.get_wallet_blocking(wallet_id)) + { + None => Err(PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorInvalidHandle, + format!("invalid manager handle: {handle}"), + )), + Some(None) => Err(PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorWalletOperation, + format!("wallet not found: {}", hex::encode(wallet_id)), + )), + Some(Some(wallet)) => Ok(wallet), + } +} + +/// What a private reply is paid with, looked up only when the wallet asks: +/// the shielded coordinator, then the seed read through the resolver at +/// `resolver_addr`. Runs on a blocking thread (the coordinator lookup blocks +/// on the runtime, and the resolver calls into the host). +/// +/// # Safety +/// `resolver_addr` must be the address of a valid `MnemonicResolverHandle` +/// that the export's caller pins for the call. +#[cfg(feature = "shielded")] +unsafe fn private_reply_payment_parts( + handle: Handle, + wallet_id: [u8; 32], + resolver_addr: usize, +) -> Result<(Arc, Zeroizing<[u8; 64]>), PaymentFailure> { + let coordinator = resolve_shielded_coordinator(handle).map_err(payment_failure)?; + let seed = resolve_seed_from_resolver(resolver_addr as *mut MnemonicResolverHandle, &wallet_id) + .map_err(payment_failure)?; + Ok((coordinator, seed)) } /// Body of [`platform_wallet_manager_accept_contact_request_replying_in_kind`], -/// as an ordinary Rust function so a panic unwinds into [`catch_spend_panic`]. +/// as an ordinary Rust function so a panic unwinds into `catch_spend_panic` +/// in a shielded build. /// /// # Safety /// Identical contract to the export that calls it. -#[cfg(feature = "shielded")] #[allow(clippy::too_many_arguments)] unsafe fn accept_contact_request_replying_in_kind_inner( handle: Handle, @@ -613,12 +729,8 @@ unsafe fn accept_contact_request_replying_in_kind_inner( let request = unwrap_option_or_return!(request); let shielded_address = read_shielded_address(shielded_address); - let (wallet, coordinator) = match resolve_wallet_and_coordinator(handle, &wallet_id) { - Ok(pair) => pair, - Err(result) => return result, - }; - let seed = match resolve_seed_from_resolver(mnemonic_resolver_handle, &wallet_id) { - Ok(seed) => seed, + let wallet = match resolve_manager_wallet(handle, &wallet_id) { + Ok(wallet) => wallet, Err(result) => return result, }; // SAFETY: the caller pins the resolver handle for the duration of this @@ -627,33 +739,111 @@ unsafe fn accept_contact_request_replying_in_kind_inner( resolver_contact_crypto_provider(mnemonic_resolver_handle, wallet_id, wallet.network()) }; let signer_addr = signer_handle as usize; + // Why paying for a private reply failed, when it did: the export returns + // that, not the wallet error standing in for it. + let payment_failure_slot: Arc>> = Arc::new(Mutex::new(None)); + let failure_slot = Arc::clone(&payment_failure_slot); + + #[cfg(feature = "shielded")] + let payment_asked = Arc::new(AtomicBool::new(false)); + #[cfg(feature = "shielded")] + let result = { + let asked = Arc::clone(&payment_asked); + let resolver_addr = mnemonic_resolver_handle as usize; + block_on_worker(async move { + // SAFETY: as in the private accept, the caller pins the signer for + // the call. + let signer: &VTableSigner = &*(signer_addr as *const VTableSigner); + let prover = CachedOrchardProver::new(); + let result = wallet + .accept_contact_request_replying_in_kind( + &request, + shielded_address, + signer, + &crypto, + || async { + asked.store(true, Ordering::SeqCst); + let parts = tokio::task::spawn_blocking(move || { + // SAFETY: the caller pins the resolver for the call, + // and this export blocks until the task completes. + unsafe { private_reply_payment_parts(handle, wallet_id, resolver_addr) } + }) + .await + .unwrap_or_else(|_| { + Err(( + PlatformWalletFFIResultCode::ErrorUnknown, + "the private reply's payment lookup panicked".to_string(), + )) + }); + match parts { + Ok((coordinator, seed)) => Ok(PrivateReplyPayment { + coordinator, + seed, + account, + prover: &prover, + }), + Err(failure) => Err(record_payment_failure(&failure_slot, failure)), + } + }, + ) + .await; + poke_sync_on_unconfirmed(&result, handle); + result + }) + }; + // Without shielded support a private reply cannot be paid for; every + // other reply is the same as in a shielded build. + #[cfg(not(feature = "shielded"))] + let result = { + let _ = account; + let identity = wallet.identity().clone(); + block_on_worker(async move { + // SAFETY: the caller pins the signer for the call. + let signer: &VTableSigner = &*(signer_addr as *const VTableSigner); + identity + .dashpay() + .accept_contact_request_replying_in_kind( + &request, + shielded_address, + signer, + &crypto, + |_entry| async move { + Err::(record_payment_failure( + &failure_slot, + ( + PlatformWalletFFIResultCode::ErrorShieldedBuildRequired, + "this contact request needs a private reply, which is paid \ + from the shielded pool; this build has no shielded support" + .to_string(), + ), + )) + }, + ) + .await + }) + }; - let result = block_on_worker(async move { - // SAFETY: as in the private accept, the caller pins the signer for the - // call. - let signer: &VTableSigner = &*(signer_addr as *const VTableSigner); - let prover = CachedOrchardProver::new(); - let result = wallet - .accept_contact_request_replying_in_kind( - &coordinator, - seed.as_ref(), - account, - &request, - shielded_address, - signer, - &crypto, - &prover, - ) - .await; - poke_sync_on_unconfirmed(&result, handle); - result - }); match result { Ok(contact) => { unsafe { *out_established_handle = ESTABLISHED_CONTACT_STORAGE.insert(contact) }; PlatformWalletFFIResult::ok() } - Err(error) => map_spend_result(Err(error), "contact request accept"), + Err(error) => { + if let Some((code, message)) = payment_failure_slot + .lock() + .ok() + .and_then(|mut slot| slot.take()) + { + return PlatformWalletFFIResult::err(code, message); + } + #[cfg(feature = "shielded")] + if payment_asked.load(Ordering::SeqCst) { + return map_spend_result(Err(error), "contact request accept"); + } + // No payment was asked for, so nothing was spent: report the + // reply's failure as the public accept does. + PlatformWalletFFIResult::from(error) + } } } @@ -761,7 +951,6 @@ mod tests { /// As the private accept: whichever reply the wallet picks is signed by /// the identity signer. - #[cfg(feature = "shielded")] #[test] fn should_reject_a_null_identity_signer_on_the_accept_replying_in_kind() { let wallet_id = [1u8; 32]; @@ -782,6 +971,185 @@ mod tests { assert_eq!(result.code, PlatformWalletFFIResultCode::ErrorNullPointer); } + /// A request that did not come privately is accepted with a document, which + /// needs neither shielded support nor the spend authority: on a manager + /// whose shielded support was never configured (and in a build without + /// it), the accept gets past the lookups to the wallet's own refusal (the + /// recipient is not one of its identities, reported as the public accept + /// reports it), and the resolver is never asked for the mnemonic. + #[test] + fn should_accept_publicly_without_shielded_support_or_a_mnemonic_read() { + use crate::contact_request::contact_request_destroy; + use crate::event_handler::EventHandlerCallbacks; + use crate::manager::{ + platform_wallet_manager_create, platform_wallet_manager_create_wallet_from_mnemonic, + platform_wallet_manager_destroy, + }; + use crate::persistence::PersistenceCallbacks; + use platform_wallet::ContactRequest; + use rs_sdk_ffi::{ + dash_sdk_mnemonic_resolver_create, dash_sdk_mnemonic_resolver_destroy, + dash_sdk_signer_create, dash_sdk_signer_destroy, mnemonic_resolver_result, + SignCompletionCallback, + }; + use std::ffi::{c_void, CStr}; + use std::os::raw::c_char; + use std::sync::atomic::{AtomicUsize, Ordering}; + + unsafe extern "C" fn begin(_: *mut c_void, _: *const u8) -> i32 { + 0 + } + unsafe extern "C" fn end(_: *mut c_void, _: *const u8, _: bool) -> i32 { + 0 + } + unsafe extern "C" fn counting_resolve( + ctx: *const c_void, + _wallet_id: *const u8, + _out: *mut c_char, + _capacity: usize, + _out_len: *mut usize, + ) -> i32 { + (*(ctx as *const AtomicUsize)).fetch_add(1, Ordering::SeqCst); + mnemonic_resolver_result::NOT_FOUND + } + unsafe extern "C" fn noop_destroy(_ctx: *mut c_void) {} + // A public reply to this request is refused before anything is signed; + // a signature request would fail rather than hang. + unsafe extern "C" fn refuse_to_sign( + _signer: *const c_void, + _pubkey: *const u8, + _pubkey_len: usize, + _key_type: u8, + _data: *const u8, + _data_len: usize, + completion_ctx: *mut c_void, + completion: SignCompletionCallback, + ) { + completion( + completion_ctx, + std::ptr::null(), + 0, + 1, + c"no key in this test".as_ptr(), + ); + } + unsafe extern "C" fn cannot_sign( + _signer: *const c_void, + _pubkey: *const u8, + _pubkey_len: usize, + _key_type: u8, + ) -> bool { + false + } + + let sdk = dash_sdk::SdkBuilder::new_mock().build().expect("mock sdk"); + let persistence = PersistenceCallbacks { + on_changeset_begin_fn: Some(begin), + on_changeset_end_fn: Some(end), + ..Default::default() + }; + let events = EventHandlerCallbacks { + context: std::ptr::null_mut(), + on_wallet_event_fn: None, + on_error_fn: None, + on_platform_address_sync_completed_fn: None, + on_shielded_sync_completed_fn: None, + on_shielded_sync_progress_fn: None, + on_shielded_tree_progress_fn: None, + release_fn: None, + }; + let mut manager = NULL_HANDLE; + let created = unsafe { + platform_wallet_manager_create( + &sdk as *const dash_sdk::Sdk as *const c_void, + &persistence, + &events, + &mut manager, + ) + }; + assert_eq!(created.code, PlatformWalletFFIResultCode::Success); + + let mnemonic = c"abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"; + let mut wallet_handle = NULL_HANDLE; + let mut wallet_id = [0u8; 32]; + let added = unsafe { + platform_wallet_manager_create_wallet_from_mnemonic( + manager, + mnemonic.as_ptr(), + FFINetwork::Testnet, + 0, + &mut wallet_handle, + &mut wallet_id, + ) + }; + assert_eq!( + added.code, + PlatformWalletFFIResultCode::Success, + "the wallet is added" + ); + + // A request to an identity the wallet does not manage. + let request = ContactRequest::new( + [0xA0; 32].into(), + [0xB0; 32].into(), + 1, + 2, + 7, + vec![0x33; 96], + 100, + 1_700_000_000_000, + ); + let request_handle = CONTACT_REQUEST_STORAGE.insert(request); + let reads = AtomicUsize::new(0); + let resolver = unsafe { + dash_sdk_mnemonic_resolver_create( + &reads as *const AtomicUsize as *mut c_void, + counting_resolve, + noop_destroy, + ) + }; + let signer = unsafe { dash_sdk_signer_create(refuse_to_sign, cannot_sign, None) }; + + let mut established = 0xDEAD_BEEF; + let result = unsafe { + platform_wallet_manager_accept_contact_request_replying_in_kind( + manager, + wallet_id.as_ptr(), + resolver, + 0, + request_handle, + std::ptr::null(), + signer, + &mut established, + ) + }; + // Exactly what the public accept reports for this refusal (the + // wallet's `IdentityNotFound`), not the missing-coordinator error an + // up-front lookup gave. + let expected = + PlatformWalletFFIResult::from(PlatformWalletError::IdentityNotFound([0xB0; 32].into())); + let message_of = |result: &PlatformWalletFFIResult| { + if result.message.is_null() { + String::new() + } else { + unsafe { CStr::from_ptr(result.message) } + .to_string_lossy() + .into_owned() + } + }; + assert_eq!(result.code, expected.code, "{}", message_of(&result)); + assert_eq!(message_of(&result), message_of(&expected)); + assert_eq!(established, NULL_HANDLE); + assert_eq!(reads.load(Ordering::SeqCst), 0, "no mnemonic was read"); + + let _ = contact_request_destroy(request_handle); + unsafe { + dash_sdk_signer_destroy(signer); + dash_sdk_mnemonic_resolver_destroy(resolver); + } + let _ = unsafe { platform_wallet_manager_destroy(manager) }; + } + #[test] fn should_carry_every_field_of_a_private_contact() { let address = [0x55; SHIELDED_ADDRESS_LEN]; diff --git a/packages/rs-platform-wallet-ffi/src/shielded_send.rs b/packages/rs-platform-wallet-ffi/src/shielded_send.rs index 0564ad299fc..532b84edda7 100644 --- a/packages/rs-platform-wallet-ffi/src/shielded_send.rs +++ b/packages/rs-platform-wallet-ffi/src/shielded_send.rs @@ -2327,6 +2327,30 @@ pub(crate) fn resolve_wallet_and_coordinator( Ok((wallet, coordinator)) } +/// The coordinator half of [`resolve_wallet_and_coordinator`], with the same +/// errors. It blocks on the runtime, so it must not run on one of its async +/// workers: call it from the export's thread or from `spawn_blocking`. +pub(crate) fn resolve_shielded_coordinator( + handle: Handle, +) -> Result< + std::sync::Arc, + PlatformWalletFFIResult, +> { + match PLATFORM_WALLET_MANAGER_STORAGE.with_item(handle, |manager| { + runtime().block_on(manager.shielded_coordinator()) + }) { + None => Err(PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorInvalidHandle, + format!("invalid manager handle: {handle}"), + )), + Some(None) => Err(PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorWalletOperation, + "shielded support not configured: call platform_wallet_manager_configure_shielded first", + )), + Some(Some(coordinator)) => Ok(coordinator), + } +} + #[cfg(test)] mod tests { use super::*; From 0e210ed73f36b284243c0bc9bd0ceaaac7558ebd Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 9 Nov 2026 00:12:18 +0700 Subject: [PATCH 033/269] fix(swift-sdk): count private contact calls as native operations and type codes 69 and 70 - `sendPrivateContactRequest`, `acceptPrivateContactRequest` and `acceptContactRequestReplyingInKind` are admitted with `admitNativeOp` and finished with `finishNativeOp`, so a teardown waits for them as it does for the other counted manager calls. - `PlatformWalletError.shieldedBuildRequired` (69) and `.protocolVersionNotActive` (70), with `ErrorHandlingTests` pins. - The in-kind accept's docs say a public or adopted reply needs neither shielded support nor a payment mnemonic read. Co-Authored-By: Claude Opus 5.5 --- .../PlatformWallet/PlatformWalletResult.swift | 29 +++++++++++++++++ .../PrivateContactRequests.swift | 21 +++++++++++-- .../ErrorHandlingTests.swift | 31 +++++++++++++++++++ 3 files changed, 78 insertions(+), 3 deletions(-) diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift index 9081b230dd2..17ed64cf4bb 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift @@ -269,6 +269,15 @@ public enum PlatformWalletResultCode: Int32, Sendable { /// public request to it, in reply or as a rotation, would publish the /// relationship. Nothing was sent; accept or send it privately. case errorPrivateContactRequestNeedsPrivateReply = 68 + /// The operation needs the shielded pool, and the native library was + /// built without shielded support. Nothing was sent; not retryable on + /// this build. + case errorShieldedBuildRequired = 69 + /// The network is proven at a protocol version below the one that + /// serves the request (for example the private contact request store + /// before protocol version 15), so the SDK did not send it. Expected on + /// such a network; it lifts when the network upgrades. + case errorProtocolVersionNotActive = 70 /// The named thing does not exist. Besides the handle/lookup failures this /// has always covered, BOTH deferred-send paths report the /// wallet-was-REMOVED case here. @@ -402,6 +411,10 @@ public enum PlatformWalletResultCode: Int32, Sendable { self = .errorAssetLockTooManyInputs case PLATFORM_WALLET_FFI_RESULT_CODE_ERROR_PRIVATE_CONTACT_REQUEST_NEEDS_PRIVATE_REPLY: self = .errorPrivateContactRequestNeedsPrivateReply + case PLATFORM_WALLET_FFI_RESULT_CODE_ERROR_SHIELDED_BUILD_REQUIRED: + self = .errorShieldedBuildRequired + case PLATFORM_WALLET_FFI_RESULT_CODE_ERROR_PROTOCOL_VERSION_NOT_ACTIVE: + self = .errorProtocolVersionNotActive case PLATFORM_WALLET_FFI_RESULT_CODE_NOT_FOUND: self = .notFound case PLATFORM_WALLET_FFI_RESULT_CODE_ERROR_UNKNOWN: @@ -601,6 +614,17 @@ public enum PlatformWalletError: LocalizedError { /// the reply itself) or `acceptPrivateContactRequest`, or send with /// `PlatformWalletManager.sendPrivateContactRequest`. case privateContactRequestNeedsPrivateReply(String) + /// The operation needs the shielded pool, and the native library was + /// built without shielded support: today, accepting a contact request + /// that came privately with + /// `PlatformWalletManager.acceptContactRequestReplyingInKind`. Nothing + /// was sent; a public or adopted reply works on such a build. + case shieldedBuildRequired(String) + /// The network does not serve the request yet: it is proven at a protocol + /// version below the one that does, so the SDK did not send it. Expected + /// on such a network (for example the private contact request store before + /// protocol version 15); a host may stay quiet about it. + case protocolVersionNotActive(String) case walletAlreadyExists(String) /// Definitive shielded-broadcast failure: the shielded transition /// (identity-create or a spend — unshield / transfer / withdrawal) was @@ -827,6 +851,7 @@ public enum PlatformWalletError: LocalizedError { .assetLockFundingMismatch(let m), .assetLockInsufficientFunds(let m), .assetLockTooManyInputs(let m), .privateContactRequestNeedsPrivateReply(let m), + .shieldedBuildRequired(let m), .protocolVersionNotActive(let m), .walletAlreadyExists(let m), .shieldedBroadcastFailed(let m), .shieldedBroadcastUnconfirmed(let m), .shieldedSpendUnconfirmed(let m), .shieldedNoRecordedAnchor(let m), .shieldedInsufficientBalance(let m), @@ -947,6 +972,10 @@ public enum PlatformWalletError: LocalizedError { case .errorAssetLockTooManyInputs: self = .assetLockTooManyInputs(detail) case .errorPrivateContactRequestNeedsPrivateReply: self = .privateContactRequestNeedsPrivateReply(detail) + case .errorShieldedBuildRequired: + self = .shieldedBuildRequired(detail) + case .errorProtocolVersionNotActive: + self = .protocolVersionNotActive(detail) case .errorWalletAlreadyExists: self = .walletAlreadyExists(detail) case .errorShieldedBroadcastFailed: self = .shieldedBroadcastFailed(detail) case .errorShieldedBroadcastUnconfirmed: self = .shieldedBroadcastUnconfirmed(detail) diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PrivateContactRequests.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PrivateContactRequests.swift index 86a5f185d6b..756fbd01e99 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PrivateContactRequests.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PrivateContactRequests.swift @@ -160,6 +160,10 @@ extension PlatformWalletManager { resolver: resolver, shieldedAddress: shieldedAddress ) + // Counted as the other native manager operations are, so a teardown + // waits for this call to return before it frees the manager. + try admitNativeOp("sendPrivateContactRequest") + defer { finishNativeOp() } let signerHandle = signer.handle let requestHandle: Handle = try await Task.detached(priority: .userInitiated) { () -> Handle in @@ -219,6 +223,10 @@ extension PlatformWalletManager { resolver: resolver, shieldedAddress: shieldedAddress ) + // Counted as the other native manager operations are, so a teardown + // waits for this call to return before it frees the manager. + try admitNativeOp("acceptPrivateContactRequest") + defer { finishNativeOp() } let requestHandle = request.handle let signerHandle = signer.handle let establishedHandle: Handle = try await Task.detached(priority: .userInitiated) { @@ -252,9 +260,12 @@ extension PlatformWalletManager { /// `signer`, or by adopting the reply already sent. Which reply a request /// takes is the wallet's decision, so this is the accept a host calls. /// `shieldedAddress` is the 43-byte Orchard address the contact may pay - /// us at privately; a public reply ignores it. Throws - /// `PlatformWalletError.shieldedSpendUnconfirmed` when a private reply - /// may already be on chain: do not accept again. + /// us at privately; a public reply ignores it. A public or adopted reply + /// needs neither shielded support nor a mnemonic read for the payment. + /// Throws `PlatformWalletError.shieldedSpendUnconfirmed` when a private + /// reply may already be on chain: do not accept again; and + /// `PlatformWalletError.shieldedBuildRequired` when the request needs a + /// private reply and the native library has no shielded support. public func acceptContactRequestReplyingInKind( walletId: Data, resolver: MnemonicResolver, @@ -268,6 +279,10 @@ extension PlatformWalletManager { resolver: resolver, shieldedAddress: shieldedAddress ) + // Counted as the other native manager operations are, so a teardown + // waits for this call to return before it frees the manager. + try admitNativeOp("acceptContactRequestReplyingInKind") + defer { finishNativeOp() } let requestHandle = request.handle let signerHandle = signer.handle let establishedHandle: Handle = try await Task.detached(priority: .userInitiated) { diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/ErrorHandlingTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/ErrorHandlingTests.swift index 0cedd0ba809..2627d093dcc 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/ErrorHandlingTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/ErrorHandlingTests.swift @@ -475,6 +475,37 @@ final class ErrorHandlingTests: XCTestCase { XCTAssertEqual(error.errorDescription, detail) } + func testShouldTellAPrivateReplyOnABuildWithoutShieldedSupportByItsOwnCode() { + let code = PlatformWalletResultCode( + ffi: PLATFORM_WALLET_FFI_RESULT_CODE_ERROR_SHIELDED_BUILD_REQUIRED + ) + XCTAssertEqual(code, .errorShieldedBuildRequired) + XCTAssertEqual(code.rawValue, 69) + let detail = "this contact request needs a private reply, which is paid " + + "from the shielded pool; this build has no shielded support" + let error = PlatformWalletError(code: code, message: detail) + guard case .shieldedBuildRequired(let message) = error else { + return XCTFail("lost typed shielded-build error") + } + XCTAssertEqual(message, detail) + XCTAssertEqual(error.errorDescription, detail) + } + + func testShouldTellARequestTheNetworkDoesNotServeYetByItsOwnCode() { + let code = PlatformWalletResultCode( + ffi: PLATFORM_WALLET_FFI_RESULT_CODE_ERROR_PROTOCOL_VERSION_NOT_ACTIVE + ) + XCTAssertEqual(code, .errorProtocolVersionNotActive) + XCTAssertEqual(code.rawValue, 70) + let detail = "get_private_document_entries needs protocol version 15; " + + "the network is proven at protocol version 14" + let error = PlatformWalletError(code: code, message: detail) + guard case .protocolVersionNotActive(let message) = error else { + return XCTFail("lost typed protocol-version error") + } + XCTAssertEqual(message, detail) + } + func testShouldReadEveryFieldOfAPrivateContact() { var ffi = PrivateContactFFI() ffi.received_privately = true From 28ddeb84dc728bc1207e993289270a230c517d46 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 9 Nov 2026 00:12:18 +0700 Subject: [PATCH 034/269] fix(swift-example-app): keep unconfirmed private submissions pending and report private refresh failures - A private send or an in-kind accept whose result could not be confirmed (`shieldedSpendUnconfirmed`) stays pending, as `SendViewModel` treats an unconfirmed spend: Add Contact shows a Done-only notice and no longer enables Send, and the request row keeps its pending state without its buttons until the next sync settles it. - The request list's private refresh stays quiet when the network does not serve the private store yet (code 70), and shows any other failure, and requests read but not saved, in a notice above the list. - `ContactRequestSubmissionOutcome` and `PrivateContactRefreshNotice` hold those two readings, pinned by `ContactRequestOutcomesTests`. Co-Authored-By: Claude Opus 5.5 --- .../Views/DashPay/AddContactView.swift | 34 ++++++++++++- .../DashPay/ContactRequestOutcomes.swift | 50 +++++++++++++++++++ .../Views/DashPay/ContactRequestsView.swift | 42 ++++++++++++++-- .../Views/DashPay/ContactsView.swift | 19 +++++-- .../ContactRequestOutcomesTests.swift | 47 +++++++++++++++++ 5 files changed, 181 insertions(+), 11 deletions(-) create mode 100644 packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ContactRequestOutcomes.swift create mode 100644 packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/ContactRequestOutcomesTests.swift diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/AddContactView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/AddContactView.swift index 66a1d16bca9..a72cf1021c6 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/AddContactView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/AddContactView.swift @@ -57,6 +57,11 @@ struct AddContactView: View { @State private var isSending = false @State private var errorMessage: String? + /// Set when a private send or reply may have gone through (its result + /// could not be confirmed): the view only offers to close, never to + /// submit again, as `SendTransactionView` does for an unconfirmed spend. + @State private var unconfirmedNotice: String? + /// Send-collision flow. @State private var showCollisionAlert = false @State private var collisionRecipient: Identifier? @@ -94,6 +99,7 @@ struct AddContactView: View { resolvedRecipient != nil && resolvedRecipient != identity.identityId && !isSending + && unconfirmedNotice == nil } var body: some View { @@ -154,6 +160,16 @@ struct AddContactView: View { } message: { _ in Text("This person already sent you a request — accept it instead?") } + .alert( + "Submitted", + isPresented: .constant(unconfirmedNotice != nil) + ) { + Button("Done") { dismiss() } + } message: { + if let unconfirmedNotice { + Text(unconfirmedNotice) + } + } } } @@ -489,7 +505,15 @@ struct AddContactView: View { kickDashPaySync(walletManager) dismiss() } catch { - errorMessage = error.localizedDescription + switch ContactRequestSubmissionOutcome.of(error) { + case .mayHaveGoneThrough: + // Pending, not failed: the next sync settles it. + onSent(recipient, mode == .dpns ? selectedResult?.fullName : nil) + kickDashPaySync(walletManager) + unconfirmedNotice = ContactRequestSubmissionOutcome.mayHaveGoneThroughMessage + case .failed(let message): + errorMessage = message + } } } } @@ -523,7 +547,13 @@ struct AddContactView: View { kickDashPaySync(walletManager) dismiss() } catch { - errorMessage = "Accept failed: \(error.localizedDescription)" + switch ContactRequestSubmissionOutcome.of(error) { + case .mayHaveGoneThrough: + kickDashPaySync(walletManager) + unconfirmedNotice = ContactRequestSubmissionOutcome.mayHaveGoneThroughMessage + case .failed(let message): + errorMessage = "Accept failed: \(message)" + } } } } diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ContactRequestOutcomes.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ContactRequestOutcomes.swift new file mode 100644 index 00000000000..7d01c7dde9e --- /dev/null +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ContactRequestOutcomes.swift @@ -0,0 +1,50 @@ +import Foundation +import SwiftDashSDK + +/// How a contact request send or accept that threw ended, for the DashPay +/// views. Follows `SendViewModel`: a shielded spend whose result could not be +/// confirmed may already be on chain (Rust keeps its notes reserved), so the +/// view keeps it pending and never offers a fresh submission; the next sync +/// settles it. Every other error is an ordinary failure the user may retry. +enum ContactRequestSubmissionOutcome: Equatable { + /// The private send or reply may have gone through: do not submit again. + case mayHaveGoneThrough + /// It failed; the message is the error's. + case failed(String) + + static let mayHaveGoneThroughMessage = "This may have gone through. It shows up " + + "after the next sync confirms it; do not submit it again." + + static func of(_ error: Error) -> ContactRequestSubmissionOutcome { + if let walletError = error as? PlatformWalletError, + case .shieldedSpendUnconfirmed = walletError { + return .mayHaveGoneThrough + } + return .failed(error.localizedDescription) + } +} + +/// What the request list's private-store refresh tells the user. A network +/// that does not serve the store yet (before protocol version 15) is expected +/// and stays quiet; an operational failure, or requests read but not saved, +/// is shown so the user knows to refresh again. +enum PrivateContactRefreshNotice { + /// The notice a pass that read the store leaves, or nil when every + /// identity's requests reached disk. + static func notice(unpersistedIdentityCount: UInt32) -> String? { + guard unpersistedIdentityCount > 0 else { return nil } + return "Private requests for \(unpersistedIdentityCount) " + + (unpersistedIdentityCount == 1 ? "identity were" : "identities were") + + " read but not saved. Pull to refresh again." + } + + /// The notice a pass that failed leaves, or nil when the network does not + /// serve the private store yet. + static func notice(for error: Error) -> String? { + if let walletError = error as? PlatformWalletError, + case .protocolVersionNotActive = walletError { + return nil + } + return "Private requests could not be read: \(error.localizedDescription)" + } +} diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ContactRequestsView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ContactRequestsView.swift index d5c2a1f52a1..2c7ca07d4d8 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ContactRequestsView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ContactRequestsView.swift @@ -41,6 +41,14 @@ struct ContactRequestsView: View { /// with an inline error on the row). @State private var rowErrors: [Data: String] = [:] + /// Contact ids whose accept may have gone through (a private reply whose + /// result could not be confirmed): the row stays pending, without its + /// buttons, until the next sync settles it. Never offered again here. + @State private var unconfirmedIds: Set = [] + + /// What the last private-store refresh has to tell the user, if anything. + @State private var privateRefreshNotice: String? + init(identity: PersistentIdentity, optimisticSentIds: Binding>) { self.identity = identity _optimisticSentIds = optimisticSentIds @@ -105,6 +113,7 @@ struct ContactRequestsView: View { if incomingPending.isEmpty && outgoingPending.isEmpty && optimisticOutgoing.isEmpty { List { + privateRefreshNoticeSection DashPayListEmptyRow( icon: "tray", title: "No pending requests", @@ -114,6 +123,7 @@ struct ContactRequestsView: View { .listStyle(.insetGrouped) } else { List { + privateRefreshNoticeSection if !incomingPending.isEmpty { Section { ForEach(incomingPending, id: \.contactIdentityId) { row in @@ -126,8 +136,11 @@ struct ContactRequestsView: View { // contact is accepted (established rows load it normally). avatarUrl: nil, createdAtMillis: row.createdAtMillis, - isInFlight: inFlightIds.contains(row.contactIdentityId), - errorMessage: rowErrors[row.contactIdentityId], + isInFlight: inFlightIds.contains(row.contactIdentityId) + || unconfirmedIds.contains(row.contactIdentityId), + errorMessage: unconfirmedIds.contains(row.contactIdentityId) + ? ContactRequestSubmissionOutcome.mayHaveGoneThroughMessage + : rowErrors[row.contactIdentityId], onAccept: { accept(contactId: row.contactIdentityId) }, onIgnore: { ignore(contactId: row.contactIdentityId) } ) @@ -166,7 +179,7 @@ struct ContactRequestsView: View { .refreshable { await attachOrStartSync(walletManager) if let wallet = try? requireWallet() { - await syncPrivateContactRequests(wallet) + privateRefreshNotice = await syncPrivateContactRequests(wallet) } } .onChange(of: rowSignature) { _, _ in @@ -184,6 +197,19 @@ struct ContactRequestsView: View { } } + /// The last private-store refresh's notice, when it has one. + @ViewBuilder + private var privateRefreshNoticeSection: some View { + if let privateRefreshNotice { + Section { + Text(privateRefreshNotice) + .font(.caption) + .foregroundColor(.orange) + .accessibilityIdentifier("dashpay.requests.privateRefreshNotice") + } + } + } + // MARK: - Overlay maintenance /// Drop overlay entries the `@Query` already reflects: @@ -241,7 +267,15 @@ struct ContactRequestsView: View { removedOverlayIds.insert(contactId) kickDashPaySync(walletManager) } catch { - rowErrors[contactId] = "Accept failed: \(error.localizedDescription)" + switch ContactRequestSubmissionOutcome.of(error) { + case .mayHaveGoneThrough: + // As `SendViewModel` treats an unconfirmed spend: not a + // failure to retry. The next sync settles it. + unconfirmedIds.insert(contactId) + kickDashPaySync(walletManager) + case .failed(let message): + rowErrors[contactId] = "Accept failed: \(message)" + } } } } diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ContactsView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ContactsView.swift index e75c18037cc..c423c7f7f27 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ContactsView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ContactsView.swift @@ -227,12 +227,21 @@ func kickDashPaySync(_ walletManager: PlatformWalletManager) { } /// Read DashPay's private contact request store (protocol version 15) for -/// `wallet`. Best effort: a network before protocol version 15 has no such -/// store. Private requests reach the wallet only through this read, so it -/// runs with every pull-to-refresh of the request list. +/// `wallet`, returning what the refresh should tell the user, or nil. +/// Private requests reach the wallet only through this read, so it runs with +/// every pull-to-refresh of the request list. A network before protocol +/// version 15 does not serve the store yet, which is expected and quiet; any +/// other failure, or requests read but not saved, is reported. @MainActor -func syncPrivateContactRequests(_ wallet: ManagedPlatformWallet) async { - _ = try? await wallet.syncPrivateContactRequests() +func syncPrivateContactRequests(_ wallet: ManagedPlatformWallet) async -> String? { + do { + let result = try await wallet.syncPrivateContactRequests() + return PrivateContactRefreshNotice.notice( + unpersistedIdentityCount: result.unpersistedIdentityCount + ) + } catch { + return PrivateContactRefreshNotice.notice(for: error) + } } // MARK: - Row model + view diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/ContactRequestOutcomesTests.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/ContactRequestOutcomesTests.swift new file mode 100644 index 00000000000..14b383204d6 --- /dev/null +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/ContactRequestOutcomesTests.swift @@ -0,0 +1,47 @@ +import XCTest +import SwiftDashSDK +@testable import SwiftExampleApp + +/// Pins how the DashPay views read a failed contact request submission and a +/// private-store refresh: an unconfirmed private send or reply stays pending +/// (no fresh submission is offered), and a network that does not serve the +/// private store yet is quiet while real failures are shown. +final class ContactRequestOutcomesTests: XCTestCase { + + func testShouldKeepAnUnconfirmedPrivateSubmissionPending() { + let outcome = ContactRequestSubmissionOutcome.of( + PlatformWalletError.shieldedSpendUnconfirmed("result could not be confirmed") + ) + XCTAssertEqual(outcome, .mayHaveGoneThrough) + } + + func testShouldOfferARetryForAnOrdinaryFailure() { + let error = PlatformWalletError.shieldedBroadcastFailed("rejected by the relay") + XCTAssertEqual( + ContactRequestSubmissionOutcome.of(error), + .failed(error.localizedDescription) + ) + } + + func testShouldStayQuietWhenTheNetworkDoesNotServeThePrivateStoreYet() { + XCTAssertNil(PrivateContactRefreshNotice.notice( + for: PlatformWalletError.protocolVersionNotActive("needs protocol version 15") + )) + } + + func testShouldShowAPrivateRefreshThatFailed() { + let error = PlatformWalletError.walletOperation("no node answered") + XCTAssertEqual( + PrivateContactRefreshNotice.notice(for: error), + "Private requests could not be read: \(error.localizedDescription)" + ) + } + + func testShouldShowRequestsThatWereReadButNotSaved() { + XCTAssertNil(PrivateContactRefreshNotice.notice(unpersistedIdentityCount: 0)) + XCTAssertEqual( + PrivateContactRefreshNotice.notice(unpersistedIdentityCount: 2), + "Private requests for 2 identities were read but not saved. Pull to refresh again." + ) + } +} From bb29bd677d2155a2a734fd1cfe3426e0af7d985e Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 9 Nov 2026 00:21:24 +0700 Subject: [PATCH 035/269] fix(kotlin-sdk): export the in-kind accept in every build and type codes 69 and 70 - `DashpayNative.acceptContactRequestReplyingInKind` and the helpers it uses are no longer behind `shielded`, matching the FFI export, which now exists in every build: on `./build_android.sh --no-shielded` a public or adopted reply works, and only a request that needs a private reply is refused, typed. The private send and accept bridges stay shielded-only. - `DashSdkError.PlatformWallet.ShieldedBuildRequired` (69) and `ProtocolVersionNotActive` (70), pinned in `DashSdkErrorTest`. - The in-kind accept's KDoc names both and says a public or adopted reply needs neither shielded support nor a payment mnemonic read. The manager's private send and accepts already run under its teardown gate, so nothing changes there. Co-Authored-By: Claude Opus 5.5 --- .../dashsdk/errors/DashSdkError.kt | 28 +++++++++++++++++++ .../dashsdk/wallet/PlatformWalletManager.kt | 8 ++++-- .../dashsdk/errors/DashSdkErrorTest.kt | 21 ++++++++++++++ packages/rs-unified-sdk-jni/src/dashpay.rs | 18 ++++++------ 4 files changed, 63 insertions(+), 12 deletions(-) diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/errors/DashSdkError.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/errors/DashSdkError.kt index fa4583fa59e..c2643aa4924 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/errors/DashSdkError.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/errors/DashSdkError.kt @@ -662,6 +662,32 @@ sealed class DashSdkError( class PrivateContactRequestNeedsPrivateReply(message: String, cause: Throwable? = null) : PlatformWallet(message, cause) + /** + * `ErrorShieldedBuildRequired` (native code 69). The operation needs + * the shielded pool and the native library was built without the + * `shielded` feature (`./build_android.sh --no-shielded`): today, + * accepting a contact request that came privately with + * `PlatformWalletManager.acceptContactRequestReplyingInKind`. Nothing + * was sent; a public or adopted reply works on such a build. Not + * retryable on the same build. + * ← Swift `PlatformWalletError.shieldedBuildRequired`. + */ + class ShieldedBuildRequired(message: String, cause: Throwable? = null) : + PlatformWallet(message, cause) + + /** + * `ErrorProtocolVersionNotActive` (native code 70). The SDK did not + * send the request: 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. Expected on such a + * network, so a host may stay quiet about it; it lifts when the + * network upgrades. A refusal that may lift sooner (the version could + * not be confirmed) arrives as [Generic] instead. + * ← Swift `PlatformWalletError.protocolVersionNotActive`. + */ + class ProtocolVersionNotActive(message: String, cause: Throwable? = null) : + PlatformWallet(message, cause) + /** * Any other `PlatformWalletFFIResultCode` without a dedicated type. * Carries the platform-wallet [nativeCode] (already de-offset) and @@ -861,6 +887,8 @@ sealed class DashSdkError( // ErrorPrivateContactRequestNeedsPrivateReply: the explicitly // public accept or send refused a contact that went private. 68 -> PlatformWallet.PrivateContactRequestNeedsPrivateReply(message, cause) + 69 -> PlatformWallet.ShieldedBuildRequired(message, cause) // ErrorShieldedBuildRequired + 70 -> PlatformWallet.ProtocolVersionNotActive(message, cause) // ErrorProtocolVersionNotActive else -> // @Deprecated fallback — see the code-6 arm; code 31 is the // real discriminator. diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/PlatformWalletManager.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/PlatformWalletManager.kt index b7f68b00aa6..35c333012e0 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/PlatformWalletManager.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/PlatformWalletManager.kt @@ -2231,8 +2231,12 @@ class PlatformWalletManager( * Orchard address the contact may pay us at privately; a public reply * ignores it. * - * Throws [DashSdkError.PlatformWallet.ShieldedSpendUnconfirmed] when a - * private reply may already be on chain: do not accept again. + * A public or adopted reply needs neither shielded support nor a + * mnemonic read for the payment. Throws + * [DashSdkError.PlatformWallet.ShieldedSpendUnconfirmed] when a private + * reply may already be on chain: do not accept again; and + * [DashSdkError.PlatformWallet.ShieldedBuildRequired] when the request + * needs a private reply and the native library has no shielded support. * * @return the established contact; close it (or `use {}`) when done. */ diff --git a/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/errors/DashSdkErrorTest.kt b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/errors/DashSdkErrorTest.kt index f244bea164e..44501c20ea8 100644 --- a/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/errors/DashSdkErrorTest.kt +++ b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/errors/DashSdkErrorTest.kt @@ -275,6 +275,27 @@ class DashSdkErrorTest { assertFalse("the same public call is refused again", mapped.isRetryable) } + @Test + fun shouldTellAPrivateReplyOnABuildWithoutShieldedSupportByItsOwnCode() { + val detail = "this contact request needs a private reply, which is paid " + + "from the shielded pool; this build has no shielded support" + val native = DashSDKException(DashSdkError.PLATFORM_WALLET_CODE_OFFSET + 69, detail) + val mapped = DashSdkError.fromNative(native) + assertTrue(mapped is DashSdkError.PlatformWallet.ShieldedBuildRequired) + assertEquals(detail, mapped.message) + assertFalse("the same build refuses it again", mapped.isRetryable) + } + + @Test + fun shouldTellARequestTheNetworkDoesNotServeYetByItsOwnCode() { + val detail = "get_private_document_entries needs protocol version 15; " + + "the network is proven at protocol version 14" + val native = DashSDKException(DashSdkError.PLATFORM_WALLET_CODE_OFFSET + 70, detail) + val mapped = DashSdkError.fromNative(native) + assertTrue(mapped is DashSdkError.PlatformWallet.ProtocolVersionNotActive) + assertEquals(detail, mapped.message) + } + @Test fun platformShieldCapacityCode41MapsTyped() { val message = diff --git a/packages/rs-unified-sdk-jni/src/dashpay.rs b/packages/rs-unified-sdk-jni/src/dashpay.rs index b7a1c9bdddf..025a892171d 100644 --- a/packages/rs-unified-sdk-jni/src/dashpay.rs +++ b/packages/rs-unified-sdk-jni/src/dashpay.rs @@ -1218,16 +1218,15 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_DashpayNative_claimIn // // Mirrors Swift's `PrivateContactRequests.swift`: the store sync is a // wallet-handle call (`ManagedPlatformWallet.syncPrivateContactRequests`); -// the send, the private-reply accept and the accept that replies the way a -// request came are manager-handle shielded spends +// the send and the private-reply accept are manager-handle shielded spends // (`PlatformWalletManager.sendPrivateContactRequest` / -// `acceptPrivateContactRequest` / `acceptContactRequestReplyingInKind`), -// gated like the other shielded spends. +// `acceptPrivateContactRequest`), gated like the other shielded spends. The +// accept that replies the way a request came +// (`acceptContactRequestReplyingInKind`) is exported in every build. /// The bytes of an Orchard payment address the FFI reads from a /// `shielded_address` pointer: an 11-byte diversifier and a 32-byte /// transmission key. -#[cfg(feature = "shielded")] const SHIELDED_ADDRESS_LEN: usize = 43; /// The longs [`Java_org_dashfoundation_dashsdk_ffi_DashpayNative_syncPrivateContactRequests`] @@ -1316,7 +1315,6 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_DashpayNative_syncPri /// Copy an optional Java `byte[]`; JVM null is `None`. Throws and returns /// `Err` on a JNI error. `field` names the argument in the message. -#[cfg(feature = "shielded")] fn read_opt_bytes(env: &mut JNIEnv, arr: &JByteArray, field: &str) -> Result>, ()> { if arr.is_null() { return Ok(None); @@ -1335,7 +1333,6 @@ fn read_opt_bytes(env: &mut JNIEnv, arr: &JByteArray, field: &str) -> Result Result>, ()> { let address = read_opt_bytes(env, arr, "shieldedAddress")?; if let Some(address) = &address { @@ -1492,9 +1489,10 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_DashpayNative_acceptP /// privately, paid from `account`'s shielded notes, when it came privately /// and no reply exists yet; otherwise with a `contactRequest` document, or by /// adopting the reply already sent. The wallet decides; this is the accept a -/// host calls. Arguments and result are as for -/// [`Java_org_dashfoundation_dashsdk_ffi_DashpayNative_acceptPrivateContactRequest`]. -#[cfg(feature = "shielded")] +/// host calls. Arguments and result are as for the private accept above. +/// Exported in every build: without the `shielded` feature a request that +/// needs a private reply is refused with `ErrorShieldedBuildRequired`, and a +/// public or adopted reply works as in a shielded build. #[no_mangle] #[allow(clippy::too_many_arguments)] pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_DashpayNative_acceptContactRequestReplyingInKind( From a27385734fd8fe15f83d281e42df6c16d841033c Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 9 Nov 2026 00:21:24 +0700 Subject: [PATCH 036/269] fix(kotlin-example-app): keep an unconfirmed private reply pending and report private refresh failures As the Swift app does (`ContactRequestOutcomes.swift`): - An accept whose private reply could not be confirmed (`ShieldedSpendUnconfirmed`) stays pending, as the send screen treats an unconfirmed spend: the request row keeps its pending state without its buttons, and Add Contact shows a Done-only dialog and no longer enables Send. - The request list's private refresh stays quiet when the network does not serve the private store yet (code 70), and shows any other failure, and requests read but not saved, above the list. - `ContactRequestSubmissionOutcome` and `PrivateContactRefreshNotice` hold those readings, pinned by `ContactRequestOutcomesTest`. Co-Authored-By: Claude Opus 5.5 --- .../example/ui/dashpay/AddContactScreen.kt | 28 ++++++++- .../ui/dashpay/ContactRequestOutcomes.kt | 57 ++++++++++++++++++ .../ui/dashpay/ContactRequestsScreen.kt | 37 ++++++++++-- .../example/ui/dashpay/DashPaySyncHelpers.kt | 18 ++++-- .../ui/dashpay/ContactRequestOutcomesTest.kt | 58 +++++++++++++++++++ 5 files changed, 187 insertions(+), 11 deletions(-) create mode 100644 packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/ContactRequestOutcomes.kt create mode 100644 packages/kotlin-sdk/KotlinExampleApp/app/src/test/java/org/dashfoundation/example/ui/dashpay/ContactRequestOutcomesTest.kt diff --git a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/AddContactScreen.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/AddContactScreen.kt index e787616ba7d..1a06c468a46 100644 --- a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/AddContactScreen.kt +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/AddContactScreen.kt @@ -98,6 +98,10 @@ fun AddContactScreen(identityIdHex: String, navController: NavHostController) { var accountLabel by remember { mutableStateOf("") } var isSending by remember { mutableStateOf(false) } var errorMessage by remember { mutableStateOf(null) } + // Set when an accept's private reply may have gone through (its result + // could not be confirmed): the screen only offers to close, never to + // submit again, as the send screen does for an unconfirmed spend. + var unconfirmedNotice by remember { mutableStateOf(null) } var collisionRecipient by remember { mutableStateOf(null) } var previewProfile by remember { mutableStateOf(null) } @@ -107,7 +111,8 @@ fun AddContactScreen(identityIdHex: String, navController: NavHostController) { AddMode.IDENTITY_ID -> parsedIdentityId } val recipientIsSelf = resolvedRecipient?.contentEquals(idBytes) == true - val canSend = resolvedRecipient != null && !recipientIsSelf && !isSending + val canSend = resolvedRecipient != null && !recipientIsSelf && !isSending && + unconfirmedNotice == null // Scan-to-send: consume the QR string handed back by the scanner screen // (the house pattern — observe the saved-state flow, then null it out). @@ -232,7 +237,15 @@ fun AddContactScreen(identityIdHex: String, navController: NavHostController) { "accept it from Requests." } } catch (e: Exception) { - errorMessage = "Accept failed: ${e.message ?: "unknown error"}" + when (val outcome = ContactRequestSubmissionOutcome.of(e)) { + ContactRequestSubmissionOutcome.MayHaveGoneThrough -> { + // Pending, not failed: the next sync settles it. + kickDashPaySync(scope, m) + unconfirmedNotice = ContactRequestSubmissionOutcome.MAY_HAVE_GONE_THROUGH_MESSAGE + } + is ContactRequestSubmissionOutcome.Failed -> + errorMessage = "Accept failed: ${outcome.message}" + } } finally { isSending = false } @@ -366,6 +379,17 @@ fun AddContactScreen(identityIdHex: String, navController: NavHostController) { }, ) } + + unconfirmedNotice?.let { notice -> + AlertDialog( + onDismissRequest = { navController.popBackStack() }, + title = { Text("Submitted") }, + text = { Text(notice) }, + confirmButton = { + TextButton(onClick = { navController.popBackStack() }) { Text("Done") } + }, + ) + } } @Composable diff --git a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/ContactRequestOutcomes.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/ContactRequestOutcomes.kt new file mode 100644 index 00000000000..97fd72c29e8 --- /dev/null +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/ContactRequestOutcomes.kt @@ -0,0 +1,57 @@ +package org.dashfoundation.example.ui.dashpay + +import org.dashfoundation.dashsdk.errors.DashSdkError + +/** + * How a contact request accept that threw ended, for the DashPay screens. + * Follows the send screen (and iOS `SendViewModel.swift`): a shielded spend + * whose result could not be confirmed may already be on chain (Rust keeps its + * notes reserved), so the screen keeps it pending and never offers a fresh + * submission; the next sync settles it. Every other error is an ordinary + * failure the user may retry. + * ← Swift `ContactRequestSubmissionOutcome` (`ContactRequestOutcomes.swift`). + */ +sealed interface ContactRequestSubmissionOutcome { + /** The private reply may have gone through: do not submit again. */ + data object MayHaveGoneThrough : ContactRequestSubmissionOutcome + + /** It failed; the message is the error's. */ + data class Failed(val message: String) : ContactRequestSubmissionOutcome + + companion object { + const val MAY_HAVE_GONE_THROUGH_MESSAGE = "This may have gone through. It shows up " + + "after the next sync confirms it; do not submit it again." + + fun of(error: Throwable): ContactRequestSubmissionOutcome = + if (error is DashSdkError.PlatformWallet.ShieldedSpendUnconfirmed) { + MayHaveGoneThrough + } else { + Failed(error.message ?: "unknown error") + } + } +} + +/** + * What the request list's private-store refresh tells the user. A network + * that does not serve the store yet (before protocol version 15) is expected + * and stays quiet; an operational failure, or requests read but not saved, + * is shown so the user knows to refresh again. + * ← Swift `PrivateContactRefreshNotice` (`ContactRequestOutcomes.swift`). + */ +object PrivateContactRefreshNotice { + /** The notice a pass that read the store leaves, or null when every identity's requests reached disk. */ + fun notice(unpersistedIdentityCount: Long): String? { + if (unpersistedIdentityCount <= 0) return null + val who = if (unpersistedIdentityCount == 1L) "identity were" else "identities were" + return "Private requests for $unpersistedIdentityCount $who read but not saved. " + + "Pull to refresh again." + } + + /** The notice a pass that failed leaves, or null when the network does not serve the private store yet. */ + fun notice(error: Throwable): String? = + if (error is DashSdkError.PlatformWallet.ProtocolVersionNotActive) { + null + } else { + "Private requests could not be read: ${error.message ?: "unknown error"}" + } +} diff --git a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/ContactRequestsScreen.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/ContactRequestsScreen.kt index a51d282cf09..5a102240207 100644 --- a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/ContactRequestsScreen.kt +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/ContactRequestsScreen.kt @@ -92,6 +92,12 @@ fun ContactRequestsScreen(identityIdHex: String, navController: NavHostControlle var removedOverlayIds by remember { mutableStateOf(emptySet()) } var rowErrors by remember { mutableStateOf(emptyMap()) } var isRefreshing by remember { mutableStateOf(false) } + // Hex ids whose accept may have gone through (a private reply whose result + // could not be confirmed): the row stays pending, without its buttons, + // until the next sync settles it. Never offered again here. + var unconfirmedIds by remember { mutableStateOf(emptySet()) } + // What the last private-store refresh has to tell the user, if anything. + var privateRefreshNotice by remember { mutableStateOf(null) } // Prune the optimistic-removal overlay against the backing query: keep a // hex only while its pair is STILL incoming-only, so an entry is dropped @@ -168,7 +174,16 @@ fun ContactRequestsScreen(identityIdHex: String, navController: NavHostControlle rowErrors = rowErrors + (hex to "Request not in local state — pull to refresh") } } catch (e: Exception) { - rowErrors = rowErrors + (hex to "Accept failed: ${e.message ?: "unknown error"}") + when (val outcome = ContactRequestSubmissionOutcome.of(e)) { + ContactRequestSubmissionOutcome.MayHaveGoneThrough -> { + // As the send screen treats an unconfirmed spend: not + // a failure to retry. The next sync settles it. + unconfirmedIds = unconfirmedIds + hex + kickDashPaySync(scope, m) + } + is ContactRequestSubmissionOutcome.Failed -> + rowErrors = rowErrors + (hex to "Accept failed: ${outcome.message}") + } } finally { inFlightIds = inFlightIds - hex } @@ -211,7 +226,7 @@ fun ContactRequestsScreen(identityIdHex: String, navController: NavHostControlle isRefreshing = true manager?.let { m -> attachOrStartSync(m) - wallet?.let { syncPrivateContactRequests(it, m) } + wallet?.let { privateRefreshNotice = syncPrivateContactRequests(it, m) } } isRefreshing = false } @@ -223,6 +238,16 @@ fun ContactRequestsScreen(identityIdHex: String, navController: NavHostControlle contentPadding = PaddingValues(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp), ) { + privateRefreshNotice?.let { notice -> + item { + Text( + notice, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + modifier = Modifier.testTag("dashpay.requests.privateRefreshNotice"), + ) + } + } if (incomingPending.isEmpty() && outgoingPending.isEmpty()) { item { DashPayEmptyRow( @@ -238,8 +263,12 @@ fun ContactRequestsScreen(identityIdHex: String, navController: NavHostControlle val hex = row.contactId.toHex() IncomingRequestRow( item = row, - isInFlight = inFlightIds.contains(hex), - errorMessage = rowErrors[hex], + isInFlight = inFlightIds.contains(hex) || unconfirmedIds.contains(hex), + errorMessage = if (unconfirmedIds.contains(hex)) { + ContactRequestSubmissionOutcome.MAY_HAVE_GONE_THROUGH_MESSAGE + } else { + rowErrors[hex] + }, onAccept = { accept(row.contactId) }, onIgnore = { ignore(row.contactId) }, ) diff --git a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/DashPaySyncHelpers.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/DashPaySyncHelpers.kt index 3d1f0b9bc95..1e48f973385 100644 --- a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/DashPaySyncHelpers.kt +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/DashPaySyncHelpers.kt @@ -1,5 +1,6 @@ package org.dashfoundation.example.ui.dashpay +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch @@ -36,16 +37,23 @@ fun kickDashPaySync(scope: CoroutineScope, manager: PlatformWalletManager) { /** * Read DashPay's private contact request store (protocol version 15) for - * [wallet]. Best effort: a network before protocol version 15 has no such - * store. Private requests reach the wallet only through this read, so it - * runs with every pull-to-refresh of the request list. + * [wallet], returning what the refresh should tell the user, or null. + * Private requests reach the wallet only through this read, so it runs with + * every pull-to-refresh of the request list. A network before protocol + * version 15 does not serve the store yet, which is expected and quiet; any + * other failure, or requests read but not saved, is reported. * ← Swift `syncPrivateContactRequests` (`ContactsView.swift`). */ suspend fun syncPrivateContactRequests( wallet: ManagedPlatformWallet, manager: PlatformWalletManager, -) { - runCatching { wallet.dashpay.syncPrivateContactRequests(manager.mnemonicResolverHandle) } +): String? = try { + val result = wallet.dashpay.syncPrivateContactRequests(manager.mnemonicResolverHandle) + PrivateContactRefreshNotice.notice(result.unpersistedIdentityCount) +} catch (e: CancellationException) { + throw e +} catch (e: Exception) { + PrivateContactRefreshNotice.notice(e) } /** diff --git a/packages/kotlin-sdk/KotlinExampleApp/app/src/test/java/org/dashfoundation/example/ui/dashpay/ContactRequestOutcomesTest.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/test/java/org/dashfoundation/example/ui/dashpay/ContactRequestOutcomesTest.kt new file mode 100644 index 00000000000..e398f8ee37f --- /dev/null +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/test/java/org/dashfoundation/example/ui/dashpay/ContactRequestOutcomesTest.kt @@ -0,0 +1,58 @@ +package org.dashfoundation.example.ui.dashpay + +import org.dashfoundation.dashsdk.errors.DashSdkError +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +/** + * Port of `ContactRequestOutcomesTests.swift`: an unconfirmed private reply + * stays pending (no fresh submission is offered), and a network that does + * not serve the private store yet is quiet while real failures are shown. + */ +class ContactRequestOutcomesTest { + + @Test + fun shouldKeepAnUnconfirmedPrivateSubmissionPending() { + val error = DashSdkError.PlatformWallet.ShieldedSpendUnconfirmed("result could not be confirmed") + assertEquals( + ContactRequestSubmissionOutcome.MayHaveGoneThrough, + ContactRequestSubmissionOutcome.of(error), + ) + } + + @Test + fun shouldOfferARetryForAnOrdinaryFailure() { + val error = DashSdkError.PlatformWallet.ShieldedBroadcastFailed("rejected by the relay") + assertEquals( + ContactRequestSubmissionOutcome.Failed("rejected by the relay"), + ContactRequestSubmissionOutcome.of(error), + ) + } + + @Test + fun shouldStayQuietWhenTheNetworkDoesNotServeThePrivateStoreYet() { + assertNull( + PrivateContactRefreshNotice.notice( + DashSdkError.PlatformWallet.ProtocolVersionNotActive("needs protocol version 15"), + ), + ) + } + + @Test + fun shouldShowAPrivateRefreshThatFailed() { + assertEquals( + "Private requests could not be read: no node answered", + PrivateContactRefreshNotice.notice(DashSdkError.PlatformWallet.WalletOperation("no node answered")), + ) + } + + @Test + fun shouldShowRequestsThatWereReadButNotSaved() { + assertNull(PrivateContactRefreshNotice.notice(unpersistedIdentityCount = 0)) + assertEquals( + "Private requests for 2 identities were read but not saved. Pull to refresh again.", + PrivateContactRefreshNotice.notice(unpersistedIdentityCount = 2), + ) + } +} From 89d2330796a78010002ec49a68943fec3726c27e Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 9 Nov 2026 00:21:24 +0700 Subject: [PATCH 037/269] docs(kotlin-sdk): record the pending, refresh and build-without-shielded behaviour in the private contact parity entry Both hosts' reasons now say the in-kind accept works without shielded support for a public or adopted reply, codes 68 to 70 are typed, and the example apps keep an unconfirmed private submission pending and report private refresh failures. The entry cites the FFI test of an accept on a manager without shielded support and both apps' outcome tests. Statuses are unchanged, so `PARITY_SUMMARY.md` regenerates identical. Co-Authored-By: Claude Opus 5.5 --- docs/sdk/sdk-parity-manifest.json | 28 ++++++++++++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/docs/sdk/sdk-parity-manifest.json b/docs/sdk/sdk-parity-manifest.json index f8e76467ca8..e8126c7a963 100644 --- a/docs/sdk/sdk-parity-manifest.json +++ b/docs/sdk/sdk-parity-manifest.json @@ -795,13 +795,13 @@ "sdk": "partial", "example_app": "partial", "restart": "required", - "reason": "PrivateContactRequests.swift wraps the store sync, the shielded send, the accept replying in kind (the wallet replies privately to a request that came privately, and with a document otherwise), the explicit private-reply accept and PrivateContactInfo, all signed with the identity signer the public calls take; code 68 is typed and pinned in ErrorHandlingTests. The sync, send and accepts have no automated or recorded gate: a private reply needs a protocol version 15 network with shielded funds. The private marker (core height 0) round-trips through the persisted coreHeightCreatedAt, but no test covers a restart. The example app has the send-privately toggle, accepts with acceptContactRequestReplyingInKind and syncs the private store on pull-to-refresh, with no recorded gate." + "reason": "PrivateContactRequests.swift wraps the store sync, the shielded send, the accept replying in kind (the wallet replies privately to a request that came privately, and with a document otherwise; a public or adopted reply needs no shielded support or payment mnemonic read, and a build without shielded support refuses only a private reply, with code 69), the explicit private-reply accept and PrivateContactInfo, all signed with the identity signer the public calls take and counted as native manager operations; codes 68 to 70 are typed and pinned in ErrorHandlingTests. The sync, send and accepts have no automated or recorded gate: a private reply needs a protocol version 15 network with shielded funds. The private marker (core height 0) round-trips through the persisted coreHeightCreatedAt, but no test covers a restart. The example app has the send-privately toggle, accepts with acceptContactRequestReplyingInKind, keeps an unconfirmed private send or reply pending, and reports private refresh failures (quiet before protocol version 15), with no recorded gate." }, "kotlin": { "sdk": "partial", "example_app": "partial", "restart": "required", - "reason": "Dashpay.syncPrivateContactRequests and PlatformWalletManager.sendPrivateContactRequest / acceptContactRequestReplyingInKind / acceptPrivateContactRequest bridge the same FFI (signed with the manager's identity signer), and code 68 maps to PlatformWallet.PrivateContactRequestNeedsPrivateReply (DashSdkErrorTest). managed_identity_get_private_contact is not bridged: the Swift example app has no caller for it. JNI descriptors are covered only by the FfiSmokeTest symbol test; the flows need a protocol version 15 network with shielded funds and have no recorded gate, and no test covers a restart. The example app accepts with acceptContactRequestReplyingInKind and syncs the private store on pull-to-refresh; it has no send-privately toggle." + "reason": "Dashpay.syncPrivateContactRequests and PlatformWalletManager.sendPrivateContactRequest / acceptContactRequestReplyingInKind / acceptPrivateContactRequest bridge the same FFI (signed with the manager's identity signer, behind the manager's teardown gate); the in-kind accept is exported in every build, and codes 68 to 70 map to typed PlatformWallet errors (DashSdkErrorTest). managed_identity_get_private_contact is not bridged: the Swift example app has no caller for it. JNI descriptors are covered only by the FfiSmokeTest symbol test; the flows need a protocol version 15 network with shielded funds and have no recorded gate, and no test covers a restart. The example app accepts with acceptContactRequestReplyingInKind, keeps an unconfirmed reply pending, and reports private refresh failures (quiet before protocol version 15); it has no send-privately toggle." } }, "verification": [ @@ -821,6 +821,14 @@ "command": "cargo test -p platform-wallet --lib should_accept_a_public_request_replying_in_kind", "covers_restart": false }, + { + "host": "shared", + "kind": "unit", + "file": "packages/rs-platform-wallet-ffi/src/private_contact_request.rs", + "id": "should_accept_publicly_without_shielded_support_or_a_mnemonic_read", + "command": "cargo test -p platform-wallet-ffi --lib should_accept_publicly_without_shielded_support_or_a_mnemonic_read", + "covers_restart": false + }, { "host": "shared", "kind": "unit", @@ -837,6 +845,14 @@ "command": "cd packages/swift-sdk && xcodebuild test -scheme SwiftDashSDK -destination 'platform=iOS Simulator,name=iPhone 17 Pro' -only-testing:SwiftDashSDKTests/ErrorHandlingTests", "covers_restart": false }, + { + "host": "swift", + "kind": "unit", + "file": "packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/ContactRequestOutcomesTests.swift", + "id": "testShouldKeepAnUnconfirmedPrivateSubmissionPending", + "command": "xcodebuild test -project packages/swift-sdk/SwiftExampleApp/SwiftExampleApp.xcodeproj -scheme SwiftExampleApp -destination 'platform=iOS Simulator,name=iPhone 17 Pro' -only-testing:SwiftExampleAppTests/ContactRequestOutcomesTests", + "covers_restart": false + }, { "host": "kotlin", "kind": "unit", @@ -845,6 +861,14 @@ "command": "cd packages/kotlin-sdk && ./gradlew :sdk:testDebugUnitTest --tests '*DashSdkErrorTest*'", "covers_restart": false }, + { + "host": "kotlin", + "kind": "unit", + "file": "packages/kotlin-sdk/KotlinExampleApp/app/src/test/java/org/dashfoundation/example/ui/dashpay/ContactRequestOutcomesTest.kt", + "id": "shouldKeepAnUnconfirmedPrivateSubmissionPending", + "command": "cd packages/kotlin-sdk && ./gradlew :app:testDebugUnitTest --tests '*ContactRequestOutcomesTest*'", + "covers_restart": false + }, { "host": "kotlin", "kind": "unit", From 473a4405f6968fe2c13da7975f27e6893a1a6a64 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 9 Nov 2026 00:27:34 +0700 Subject: [PATCH 038/269] 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 039/269] 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 91910099945e71b1d6b2d308f3af71325a190b7f Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 9 Nov 2026 01:00:19 +0700 Subject: [PATCH 040/269] fix(platform-wallet): give a pending private contact request its own code and read it per contact - `PlatformWalletError::PrivateContactRequestPending` maps to the new `ErrorPrivateContactRequestPending` (71), both in the blanket conversion and in the shielded spend contract the private send and accepts use, which would otherwise flatten it to `ErrorWalletOperation`. A private request to that contact is in flight or paid and not yet read back; nothing new was paid, and a later private sync settles it. Recorded in `ERROR_CODE_REGISTRY.md`. - `managed_identity_get_private_reply_pending(identity_handle, contact_id, out_pending)` is a pure read of `DashPayState::private_reply_pending`, so hosts disable a second send or accept from wallet-owned state. It is a getter rather than a `PrivateContactFFI` field because the latter answers NotFound until a request has come or gone privately, while a first private send is already pending. - The private store sync runs on `block_on_worker` again: the SDK's identity fetch future is `Send` now, so the big-stack thread is no longer needed (`block_on_worker` already polls on the 8 MB worker stack). Co-Authored-By: Claude Opus 5.5 --- .../ERROR_CODE_REGISTRY.md | 5 +- packages/rs-platform-wallet-ffi/src/error.rs | 35 ++++++ .../src/private_contact_request.rs | 106 ++++++++++++++---- .../src/shielded_send.rs | 4 + 4 files changed, 128 insertions(+), 22 deletions(-) diff --git a/packages/rs-platform-wallet-ffi/ERROR_CODE_REGISTRY.md b/packages/rs-platform-wallet-ffi/ERROR_CODE_REGISTRY.md index 5874c733b44..4b2c4501e83 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: 71**: 27–70 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 @@ -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 70 proposed for DashPay private contact requests; 28, 30, +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 @@ -184,6 +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. Nothing new was paid; a later private contact request sync 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 5c43e898632..7455c6d30be 100644 --- a/packages/rs-platform-wallet-ffi/src/error.rs +++ b/packages/rs-platform-wallet-ffi/src/error.rs @@ -650,6 +650,14 @@ pub enum PlatformWalletFFIResultCode { /// 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. + /// Nothing new was paid. Do not submit it again: a later private contact + /// request sync (`platform_wallet_sync_private_contact_requests`) + /// settles it. + ErrorPrivateContactRequestPending = 71, + /// The named thing does not exist. /// /// Originally (and still mostly) the code for every `Option` returned as an @@ -1093,6 +1101,12 @@ impl From for PlatformWalletFFIResult { 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 @@ -2739,6 +2753,8 @@ mod identity_balance_error_tests { #[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 @@ -2783,4 +2799,23 @@ mod private_contact_request_error_tests { 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/private_contact_request.rs b/packages/rs-platform-wallet-ffi/src/private_contact_request.rs index c5eb551ab44..c477198941a 100644 --- a/packages/rs-platform-wallet-ffi/src/private_contact_request.rs +++ b/packages/rs-platform-wallet-ffi/src/private_contact_request.rs @@ -25,6 +25,9 @@ //! - [`managed_identity_get_private_contact`]: what a contact's private //! requests carry beyond the `ContactRequest`: whether they came or went //! privately, and the contact's shielded address. +//! - [`managed_identity_get_private_reply_pending`]: whether a private request +//! to a contact is in flight or not yet read back, so a host offers no +//! second send or reply. //! //! The explicitly public send and accept refuse a contact whose requests came //! or went privately with `ErrorPrivateContactRequestNeedsPrivateReply`. @@ -39,7 +42,7 @@ use crate::dashpay::{resolver_contact_crypto_provider, ContactRequestHandleArray use crate::error::*; use crate::established_contact::ESTABLISHED_CONTACT_STORAGE; use crate::handle::*; -use crate::runtime::{block_on_worker, run_on_big_stack_thread, runtime}; +use crate::runtime::block_on_worker; use crate::types::*; use crate::{check_ptr, unwrap_option_or_return, unwrap_result_or_return}; @@ -152,26 +155,12 @@ pub unsafe extern "C" fn platform_wallet_sync_private_contact_requests( // call. let provider = unsafe { resolver_contact_crypto_provider(core_signer_handle, wallet_id, network) }; - // `run_on_big_stack_thread` rather than `block_on_worker`: the pass's - // future trips rustc's implied-lifetime-bound limitation (rust-lang/rust - // issue #100013) against `block_on_worker`'s `Send + 'static` bounds, - // through the SDK's identity fetch stream. - let spawned = run_on_big_stack_thread(|| { - runtime().block_on( - identity - .dashpay() - .sync_private_contact_requests(&provider, max_pages as usize), - ) + let result = block_on_worker(async move { + identity + .dashpay() + .sync_private_contact_requests(&provider, max_pages as usize) + .await }); - let result = match spawned { - Ok(result) => result, - Err(error) => { - return PlatformWalletFFIResult::err( - PlatformWalletFFIResultCode::ErrorWalletOperation, - format!("failed to spawn big-stack thread for the private contact sync: {error}"), - ); - } - }; let report = unwrap_result_or_return!(result); unsafe { *out_report = PrivateContactSyncReportFFI { @@ -259,6 +248,35 @@ pub unsafe extern "C" fn managed_identity_get_private_contact( PlatformWalletFFIResult::ok() } +/// Whether a private contact request from this identity to `contact_id` is +/// being paid for, or was paid for and is not yet read back from the store +/// (`DashPayState::private_reply_pending`), into `out_pending`. While it is, +/// the wallet refuses another private send or reply to the contact with +/// `ErrorPrivateContactRequestPending`, so a host shows the contact as pending +/// and offers neither; a later `platform_wallet_sync_private_contact_requests` +/// settles it. A pure read of the managed-identity snapshot. The wallet keeps +/// it in memory only, so it is false after a restart. +/// +/// # Safety +/// - `contact_id` must point to 32 readable bytes. +/// - `out_pending` must be a valid, writable pointer. +#[no_mangle] +pub unsafe extern "C" fn managed_identity_get_private_reply_pending( + identity_handle: Handle, + contact_id: *const u8, + out_pending: *mut bool, +) -> PlatformWalletFFIResult { + check_ptr!(out_pending); + unsafe { *out_pending = false }; + let contact_id = unwrap_result_or_return!(unsafe { read_identifier(contact_id) }); + let option = MANAGED_IDENTITY_STORAGE.with_item(identity_handle, |identity| { + identity.dashpay().private_reply_pending(&contact_id) + }); + let pending = unwrap_option_or_return!(option); + unsafe { *out_pending = pending }; + PlatformWalletFFIResult::ok() +} + /// Copy an optional Orchard address: null means none. /// /// # Safety @@ -1150,6 +1168,54 @@ mod tests { let _ = unsafe { platform_wallet_manager_destroy(manager) }; } + /// A managed identity with no private request in flight reports none; an + /// unknown handle is not found and leaves the flag false; a null output is + /// refused. Reserving a reply is the wallet's own (its tests pin the + /// reservation), so this pins the read. + #[test] + fn should_read_whether_a_private_reply_is_pending() { + use dpp::identity::{Identity, IdentityV0}; + use platform_wallet::ManagedIdentity; + + let identity = Identity::V0(IdentityV0 { + id: [0xB0; 32].into(), + public_keys: Default::default(), + balance: 0, + revision: 0, + }); + let handle = MANAGED_IDENTITY_STORAGE.insert(ManagedIdentity::new(identity, 0)); + let contact_id = [0xA0u8; 32]; + + let mut pending = true; + let result = unsafe { + managed_identity_get_private_reply_pending(handle, contact_id.as_ptr(), &mut pending) + }; + assert_eq!(result.code, PlatformWalletFFIResultCode::Success); + assert!(!pending, "nothing is in flight to the contact"); + + let mut pending = true; + let result = unsafe { + managed_identity_get_private_reply_pending( + 0xDEAD_BEEF, + contact_id.as_ptr(), + &mut pending, + ) + }; + assert_eq!(result.code, PlatformWalletFFIResultCode::NotFound); + assert!(!pending); + + let result = unsafe { + managed_identity_get_private_reply_pending( + handle, + contact_id.as_ptr(), + std::ptr::null_mut(), + ) + }; + assert_eq!(result.code, PlatformWalletFFIResultCode::ErrorNullPointer); + + let _ = MANAGED_IDENTITY_STORAGE.remove(handle); + } + #[test] fn should_carry_every_field_of_a_private_contact() { let address = [0x55; SHIELDED_ADDRESS_LEN]; diff --git a/packages/rs-platform-wallet-ffi/src/shielded_send.rs b/packages/rs-platform-wallet-ffi/src/shielded_send.rs index 532b84edda7..ceb21c93a5c 100644 --- a/packages/rs-platform-wallet-ffi/src/shielded_send.rs +++ b/packages/rs-platform-wallet-ffi/src/shielded_send.rs @@ -620,6 +620,10 @@ pub(crate) 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. + Err(e @ PlatformWalletError::PrivateContactRequestPending(..)) => 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 00487b02265eb06b461aee7f3656a9d50dbd7ea9 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 9 Nov 2026 01:00:19 +0700 Subject: [PATCH 041/269] feat(swift-sdk): type a pending private contact request and read it per contact - `PlatformWalletError.privateContactRequestPending` (71), with an `ErrorHandlingTests` pin. The private send, private accept and in-kind accept docs name it. - `ManagedIdentity.privateReplyPending(contactId:)` wraps `managed_identity_get_private_reply_pending`. Co-Authored-By: Claude Opus 5.5 --- .../PlatformWallet/PlatformWalletResult.swift | 15 +++++++++++ .../PrivateContactRequests.swift | 27 ++++++++++++++++--- .../ErrorHandlingTests.swift | 15 +++++++++++ 3 files changed, 54 insertions(+), 3 deletions(-) diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift index 17ed64cf4bb..7e205db7fee 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift @@ -278,6 +278,10 @@ public enum PlatformWalletResultCode: Int32, Sendable { /// before protocol version 15), so the SDK did not send it. Expected on /// such a network; it lifts when the network upgrades. case errorProtocolVersionNotActive = 70 + /// A private contact request to that identity is in flight, or paid for + /// and not yet read back from the store. Nothing new was paid; a later + /// private contact request sync settles it. Do not submit it again. + case errorPrivateContactRequestPending = 71 /// The named thing does not exist. Besides the handle/lookup failures this /// has always covered, BOTH deferred-send paths report the /// wallet-was-REMOVED case here. @@ -415,6 +419,8 @@ public enum PlatformWalletResultCode: Int32, Sendable { self = .errorShieldedBuildRequired case PLATFORM_WALLET_FFI_RESULT_CODE_ERROR_PROTOCOL_VERSION_NOT_ACTIVE: self = .errorProtocolVersionNotActive + case PLATFORM_WALLET_FFI_RESULT_CODE_ERROR_PRIVATE_CONTACT_REQUEST_PENDING: + self = .errorPrivateContactRequestPending case PLATFORM_WALLET_FFI_RESULT_CODE_NOT_FOUND: self = .notFound case PLATFORM_WALLET_FFI_RESULT_CODE_ERROR_UNKNOWN: @@ -625,6 +631,12 @@ public enum PlatformWalletError: LocalizedError { /// on such a network (for example the private contact request store before /// protocol version 15); a host may stay quiet about it. case protocolVersionNotActive(String) + /// A private contact request to that identity is in flight (another + /// private send or reply reserved it), or was paid for and is not yet + /// read back from the store. Nothing new was paid; a later + /// `ManagedPlatformWallet.syncPrivateContactRequests` settles it. Pending, + /// not failed: do not submit it again. + case privateContactRequestPending(String) case walletAlreadyExists(String) /// Definitive shielded-broadcast failure: the shielded transition /// (identity-create or a spend — unshield / transfer / withdrawal) was @@ -852,6 +864,7 @@ public enum PlatformWalletError: LocalizedError { .assetLockTooManyInputs(let m), .privateContactRequestNeedsPrivateReply(let m), .shieldedBuildRequired(let m), .protocolVersionNotActive(let m), + .privateContactRequestPending(let m), .walletAlreadyExists(let m), .shieldedBroadcastFailed(let m), .shieldedBroadcastUnconfirmed(let m), .shieldedSpendUnconfirmed(let m), .shieldedNoRecordedAnchor(let m), .shieldedInsufficientBalance(let m), @@ -976,6 +989,8 @@ public enum PlatformWalletError: LocalizedError { self = .shieldedBuildRequired(detail) case .errorProtocolVersionNotActive: self = .protocolVersionNotActive(detail) + case .errorPrivateContactRequestPending: + self = .privateContactRequestPending(detail) case .errorWalletAlreadyExists: self = .walletAlreadyExists(detail) case .errorShieldedBroadcastFailed: self = .shieldedBroadcastFailed(detail) case .errorShieldedBroadcastUnconfirmed: self = .shieldedBroadcastUnconfirmed(detail) diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PrivateContactRequests.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PrivateContactRequests.swift index 756fbd01e99..50522dbd86c 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PrivateContactRequests.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PrivateContactRequests.swift @@ -77,6 +77,22 @@ extension ManagedIdentity { try result.throwIfError() return PrivateContactInfo(ffi: ffi) } + + /// Whether a private contact request from this identity to `contactId` + /// is being paid for, or was paid for and is not yet read back from the + /// store. While it is, Rust refuses another private send or reply to the + /// contact (`PlatformWalletError.privateContactRequestPending`), so show + /// the contact as pending and offer neither; a private sync settles it. + /// Read from this snapshot: take a fresh `ManagedIdentity` to see a + /// change. Rust keeps it in memory only, so it is false after a relaunch. + public func privateReplyPending(contactId: Identifier) throws -> Bool { + var pending = false + let result = contactId.withFFIBytes { idPtr in + PlatformWalletResult(managed_identity_get_private_reply_pending(handle, idPtr, &pending)) + } + try result.throwIfError() + return pending + } } extension ManagedPlatformWallet { @@ -143,7 +159,9 @@ extension PlatformWalletManager { /// signs the request with one of the sender's authentication keys, as it /// signs a public request's transition. Throws /// `PlatformWalletError.shieldedSpendUnconfirmed` when the payment may - /// already be on chain: do not send again. + /// already be on chain, and `.privateContactRequestPending` when a + /// private request to the recipient is in flight or paid and not yet read + /// back: either way do not send again; a later private sync settles it. public func sendPrivateContactRequest( walletId: Data, resolver: MnemonicResolver, @@ -209,7 +227,8 @@ extension PlatformWalletManager { /// privately and paying for the reply from `account`'s shielded notes. /// `shieldedAddress` is the 43-byte Orchard address the contact may pay /// us at privately. `signer` is the identity signer - /// `acceptContactRequest` takes; it signs the reply. + /// `acceptContactRequest` takes; it signs the reply. An unconfirmed or + /// pending reply throws as `sendPrivateContactRequest` does. public func acceptPrivateContactRequest( walletId: Data, resolver: MnemonicResolver, @@ -263,7 +282,9 @@ extension PlatformWalletManager { /// us at privately; a public reply ignores it. A public or adopted reply /// needs neither shielded support nor a mnemonic read for the payment. /// Throws `PlatformWalletError.shieldedSpendUnconfirmed` when a private - /// reply may already be on chain: do not accept again; and + /// reply may already be on chain, or `.privateContactRequestPending` when + /// one is in flight or paid and not yet read back: do not accept again; + /// and /// `PlatformWalletError.shieldedBuildRequired` when the request needs a /// private reply and the native library has no shielded support. public func acceptContactRequestReplyingInKind( diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/ErrorHandlingTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/ErrorHandlingTests.swift index 2627d093dcc..df94cd5b6f5 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/ErrorHandlingTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/ErrorHandlingTests.swift @@ -506,6 +506,21 @@ final class ErrorHandlingTests: XCTestCase { XCTAssertEqual(message, detail) } + func testShouldReportAPendingPrivateRequestByItsOwnCode() { + let code = PlatformWalletResultCode( + ffi: PLATFORM_WALLET_FFI_RESULT_CODE_ERROR_PRIVATE_CONTACT_REQUEST_PENDING + ) + XCTAssertEqual(code, .errorPrivateContactRequestPending) + XCTAssertEqual(code.rawValue, 71) + let detail = "a private contact request to 9fRYTJZW is in flight or not yet read " + + "back from the store; sync private contact requests to settle it" + let error = PlatformWalletError(code: code, message: detail) + guard case .privateContactRequestPending(let message) = error else { + return XCTFail("lost typed pending error") + } + XCTAssertEqual(message, detail) + } + func testShouldReadEveryFieldOfAPrivateContact() { var ffi = PrivateContactFFI() ffi.received_privately = true From a69a15363797c88d0145e1d9dd74a9e552234626 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 9 Nov 2026 01:00:19 +0700 Subject: [PATCH 042/269] fix(swift-example-app): read a pending private reply from the wallet The request rows and Add Contact's Send read whether the wallet has a private request to the contact in flight or not yet read back (`privateRepliesPending`, over `ManagedIdentity.privateReplyPending`), instead of keeping an unconfirmed accept in screen-local state. While it is set, the row shows pending without its buttons and Send is off; it clears when a private sync settles it, and it holds when the screen is left and reopened. The views re-read it on appear, after an accept or send, and after each sync pass. `privateContactRequestPending` (71) is the same outcome as an unconfirmed spend: pending, no fresh submission. `ContactRequestOutcomesTests` covers it and the row message. Co-Authored-By: Claude Opus 5.5 --- .../Views/DashPay/AddContactView.swift | 29 ++++++++++++++ .../DashPay/ContactRequestOutcomes.swift | 22 +++++++++-- .../Views/DashPay/ContactRequestsView.swift | 39 ++++++++++++++----- .../Views/DashPay/ContactsView.swift | 19 +++++++++ .../ContactRequestOutcomesTests.swift | 19 +++++++++ 5 files changed, 114 insertions(+), 14 deletions(-) diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/AddContactView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/AddContactView.swift index a72cf1021c6..c5d35021ecf 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/AddContactView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/AddContactView.swift @@ -62,6 +62,12 @@ struct AddContactView: View { /// submit again, as `SendTransactionView` does for an unconfirmed spend. @State private var unconfirmedNotice: String? + /// Whether the wallet has a private request to the resolved recipient in + /// flight, or paid and not yet read back (`privateRepliesPending`). While + /// it does, Send is off; a private sync settles it. Read from the wallet, + /// so reopening this sheet keeps it. + @State private var recipientPending = false + /// Send-collision flow. @State private var showCollisionAlert = false @State private var collisionRecipient: Identifier? @@ -100,6 +106,7 @@ struct AddContactView: View { && resolvedRecipient != identity.identityId && !isSending && unconfirmedNotice == nil + && !recipientPending } var body: some View { @@ -138,6 +145,7 @@ struct AddContactView: View { } } .navigationTitle("Add Contact") + .task(id: resolvedRecipient) { reloadRecipientPending() } .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .navigationBarLeading) { @@ -404,7 +412,26 @@ struct AddContactView: View { } .disabled(!canSend) .accessibilityIdentifier("dashpay.addContact.send") + } footer: { + if recipientPending { + Text(ContactRequestSubmissionOutcome.mayHaveGoneThroughMessage) + .accessibilityIdentifier("dashpay.addContact.pending") + } + } + } + + /// Re-read from the wallet whether a private request to the resolved + /// recipient is pending. + private func reloadRecipientPending() { + guard let recipient = resolvedRecipient, let wallet = try? requireWallet() else { + recipientPending = false + return } + recipientPending = privateRepliesPending( + wallet: wallet, + identityId: identity.identityId, + contactIds: [recipient] + ).contains(recipient) } private func previewDisplayName( @@ -514,6 +541,7 @@ struct AddContactView: View { case .failed(let message): errorMessage = message } + reloadRecipientPending() } } } @@ -554,6 +582,7 @@ struct AddContactView: View { case .failed(let message): errorMessage = "Accept failed: \(message)" } + reloadRecipientPending() } } } diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ContactRequestOutcomes.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ContactRequestOutcomes.swift index 7d01c7dde9e..70e1e28e28c 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ContactRequestOutcomes.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ContactRequestOutcomes.swift @@ -5,7 +5,10 @@ import SwiftDashSDK /// views. Follows `SendViewModel`: a shielded spend whose result could not be /// confirmed may already be on chain (Rust keeps its notes reserved), so the /// view keeps it pending and never offers a fresh submission; the next sync -/// settles it. Every other error is an ordinary failure the user may retry. +/// settles it. A private request Rust reports as pending (one to the same +/// contact in flight, or paid and not yet read back) is the same outcome: +/// nothing new was paid, and the next private sync settles it. Every other +/// error is an ordinary failure the user may retry. enum ContactRequestSubmissionOutcome: Equatable { /// The private send or reply may have gone through: do not submit again. case mayHaveGoneThrough @@ -15,10 +18,21 @@ enum ContactRequestSubmissionOutcome: Equatable { static let mayHaveGoneThroughMessage = "This may have gone through. It shows up " + "after the next sync confirms it; do not submit it again." + /// The message a request row shows: the pending notice while the wallet + /// has a private reply to the contact pending, whatever error an earlier + /// attempt left, and that error otherwise. + static func rowMessage(pending: Bool, error: String?) -> String? { + pending ? mayHaveGoneThroughMessage : error + } + static func of(_ error: Error) -> ContactRequestSubmissionOutcome { - if let walletError = error as? PlatformWalletError, - case .shieldedSpendUnconfirmed = walletError { - return .mayHaveGoneThrough + if let walletError = error as? PlatformWalletError { + switch walletError { + case .shieldedSpendUnconfirmed, .privateContactRequestPending: + return .mayHaveGoneThrough + default: + break + } } return .failed(error.localizedDescription) } diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ContactRequestsView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ContactRequestsView.swift index 2c7ca07d4d8..2ccaa3ab012 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ContactRequestsView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ContactRequestsView.swift @@ -41,10 +41,12 @@ struct ContactRequestsView: View { /// with an inline error on the row). @State private var rowErrors: [Data: String] = [:] - /// Contact ids whose accept may have gone through (a private reply whose - /// result could not be confirmed): the row stays pending, without its - /// buttons, until the next sync settles it. Never offered again here. - @State private var unconfirmedIds: Set = [] + /// Contact ids with a private reply in flight, or paid and not yet read + /// back, as the wallet records it (`privateRepliesPending`): the row stays + /// pending, without its buttons, until a private sync settles it. Read + /// from the wallet on appear, after an accept and after each sync pass, + /// so leaving and reopening the screen keeps it. + @State private var walletPendingIds: Set = [] /// What the last private-store refresh has to tell the user, if anything. @State private var privateRefreshNotice: String? @@ -137,10 +139,11 @@ struct ContactRequestsView: View { avatarUrl: nil, createdAtMillis: row.createdAtMillis, isInFlight: inFlightIds.contains(row.contactIdentityId) - || unconfirmedIds.contains(row.contactIdentityId), - errorMessage: unconfirmedIds.contains(row.contactIdentityId) - ? ContactRequestSubmissionOutcome.mayHaveGoneThroughMessage - : rowErrors[row.contactIdentityId], + || walletPendingIds.contains(row.contactIdentityId), + errorMessage: ContactRequestSubmissionOutcome.rowMessage( + pending: walletPendingIds.contains(row.contactIdentityId), + error: rowErrors[row.contactIdentityId] + ), onAccept: { accept(contactId: row.contactIdentityId) }, onIgnore: { ignore(contactId: row.contactIdentityId) } ) @@ -181,9 +184,12 @@ struct ContactRequestsView: View { if let wallet = try? requireWallet() { privateRefreshNotice = await syncPrivateContactRequests(wallet) } + reloadPendingReplies() } + .task { reloadPendingReplies() } .onChange(of: rowSignature) { _, _ in pruneOverlays() + reloadPendingReplies() } .onChange(of: walletManager.dashPaySyncIsSyncing) { _, syncing in // Fallback clearing rule: after the next completed @@ -193,6 +199,7 @@ struct ContactRequestsView: View { if !syncing { removedOverlayIds.removeAll() optimisticSentIds.removeAll() + reloadPendingReplies() } } } @@ -210,6 +217,17 @@ struct ContactRequestsView: View { } } + /// Re-read which incoming rows have a private reply pending, from the + /// wallet. + private func reloadPendingReplies() { + guard let wallet = try? requireWallet() else { return } + walletPendingIds = privateRepliesPending( + wallet: wallet, + identityId: identity.identityId, + contactIds: incomingPending.map(\.contactIdentityId) + ) + } + // MARK: - Overlay maintenance /// Drop overlay entries the `@Query` already reflects: @@ -270,13 +288,14 @@ struct ContactRequestsView: View { switch ContactRequestSubmissionOutcome.of(error) { case .mayHaveGoneThrough: // As `SendViewModel` treats an unconfirmed spend: not a - // failure to retry. The next sync settles it. - unconfirmedIds.insert(contactId) + // failure to retry. The wallet keeps the reply pending, + // and the row reads that; the next sync settles it. kickDashPaySync(walletManager) case .failed(let message): rowErrors[contactId] = "Accept failed: \(message)" } } + reloadPendingReplies() } } diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ContactsView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ContactsView.swift index c423c7f7f27..3d7d9109f00 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ContactsView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ContactsView.swift @@ -226,6 +226,25 @@ func kickDashPaySync(_ walletManager: PlatformWalletManager) { Task { _ = try? await walletManager.dashPaySyncNow() } } +/// The contacts among `contactIds` that `identityId` has a private request +/// to in flight, or paid and not yet read back, as the wallet records it +/// (`ManagedIdentity.privateReplyPending`, one snapshot). While one is, Rust +/// refuses another private send or reply to it, so the views show it as +/// pending and offer neither; a private sync settles it. Empty when the +/// identity is not managed. +@MainActor +func privateRepliesPending( + wallet: ManagedPlatformWallet, + identityId: Data, + contactIds: [Data] +) -> Set { + guard !contactIds.isEmpty, + let managed = try? wallet.managedIdentity(identityId: identityId) else { + return [] + } + return Set(contactIds.filter { (try? managed.privateReplyPending(contactId: $0)) == true }) +} + /// Read DashPay's private contact request store (protocol version 15) for /// `wallet`, returning what the refresh should tell the user, or nil. /// Private requests reach the wallet only through this read, so it runs with diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/ContactRequestOutcomesTests.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/ContactRequestOutcomesTests.swift index 14b383204d6..bc6a22619cb 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/ContactRequestOutcomesTests.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/ContactRequestOutcomesTests.swift @@ -15,6 +15,25 @@ final class ContactRequestOutcomesTests: XCTestCase { XCTAssertEqual(outcome, .mayHaveGoneThrough) } + func testShouldKeepAPendingPrivateRequestPending() { + let outcome = ContactRequestSubmissionOutcome.of( + PlatformWalletError.privateContactRequestPending("not yet read back from the store") + ) + XCTAssertEqual(outcome, .mayHaveGoneThrough) + } + + func testShouldShowAWalletPendingReplyOverAnEarlierError() { + XCTAssertEqual( + ContactRequestSubmissionOutcome.rowMessage(pending: true, error: "Accept failed: boom"), + ContactRequestSubmissionOutcome.mayHaveGoneThroughMessage + ) + XCTAssertEqual( + ContactRequestSubmissionOutcome.rowMessage(pending: false, error: "Accept failed: boom"), + "Accept failed: boom" + ) + XCTAssertNil(ContactRequestSubmissionOutcome.rowMessage(pending: false, error: nil)) + } + func testShouldOfferARetryForAnOrdinaryFailure() { let error = PlatformWalletError.shieldedBroadcastFailed("rejected by the relay") XCTAssertEqual( From 4a4826a85aca9b7dc1647a2262be82b3ce61d8a4 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 9 Nov 2026 01:08:49 +0700 Subject: [PATCH 043/269] 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 34cc1e78ccfd7be3a1415e6c52d3ad9b84e8c535 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 9 Nov 2026 01:26:07 +0700 Subject: [PATCH 044/269] feat(kotlin-sdk): read a contact's private requests and pending reply, and type code 71 - JNI and `DashpayNative` bridge `managed_identity_get_private_contact` (a compact JSON object, null when nothing went privately, as Swift's nil) and `managed_identity_get_private_reply_pending`. - `Dashpay.privateContact(ourIdentityId, contactId)` and `Dashpay.privateRepliesPending(ourIdentityId, contactIds)` read them from a fresh managed-identity snapshot. The second lets a host disable a second send or accept from wallet-owned state. Ports of Swift's `ManagedIdentity.privateContact(contactId:)` and `privateReplyPending(contactId:)`, which the Swift app now calls. - `DashSdkError.PlatformWallet.PrivateContactRequestPending` (71), pinned in `DashSdkErrorTest`. - `FfiSmokeTest` binds both new symbols. The JNI crate pins the JSON layout Kotlin decodes. Co-Authored-By: Claude Opus 5.5 --- .../dashfoundation/dashsdk/FfiSmokeTest.kt | 8 +- .../dashsdk/errors/DashSdkError.kt | 13 ++ .../dashsdk/ffi/DashpayNative.kt | 17 +++ .../dashfoundation/dashsdk/tokens/Dashpay.kt | 85 ++++++++++++ .../dashsdk/errors/DashSdkErrorTest.kt | 11 ++ packages/rs-unified-sdk-jni/src/dashpay.rs | 128 +++++++++++++++++- 6 files changed, 257 insertions(+), 5 deletions(-) diff --git a/packages/kotlin-sdk/sdk/src/androidTest/kotlin/org/dashfoundation/dashsdk/FfiSmokeTest.kt b/packages/kotlin-sdk/sdk/src/androidTest/kotlin/org/dashfoundation/dashsdk/FfiSmokeTest.kt index 8a58af68e37..1285795cc2f 100644 --- a/packages/kotlin-sdk/sdk/src/androidTest/kotlin/org/dashfoundation/dashsdk/FfiSmokeTest.kt +++ b/packages/kotlin-sdk/sdk/src/androidTest/kotlin/org/dashfoundation/dashsdk/FfiSmokeTest.kt @@ -96,7 +96,7 @@ class FfiSmokeTest { * `packages/rs-platform-wallet/src/changeset/core_bridge.rs`. */ /** - * DashPay private contact requests (protocol version 15): the four + * DashPay private contact requests (protocol version 15): the six * `DashpayNative` symbols bind with the descriptors Kotlin declares, and * bad input surfaces as a mapped [DashSDKException] rather than a crash. * A null resolver is refused before any wallet lookup, and a shielded @@ -127,6 +127,12 @@ class FfiSmokeTest { 0L, ByteArray(32), 0L, 0, 0L, ByteArray(42), 0L, ) } + // The private contact read is benign on a miss (null, as Swift's + // nil); the pending read reports an unknown handle. + assertNull(DashpayNative.managedIdentityPrivateContact(0L, ByteArray(32))) + assertThrows(DashSDKException::class.java) { + DashpayNative.managedIdentityPrivateReplyPending(0L, ByteArray(32)) + } } @Test diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/errors/DashSdkError.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/errors/DashSdkError.kt index c2643aa4924..e8dca35cecb 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/errors/DashSdkError.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/errors/DashSdkError.kt @@ -688,6 +688,18 @@ sealed class DashSdkError( class ProtocolVersionNotActive(message: String, cause: Throwable? = null) : PlatformWallet(message, cause) + /** + * `ErrorPrivateContactRequestPending` (native code 71). A private + * contact request to that identity is in flight (another private send + * or reply reserved it), or was paid for and is not yet read back from + * the store. Nothing new was paid; a later + * `Dashpay.syncPrivateContactRequests` settles it. Pending, not + * failed: do not submit it again. + * ← Swift `PlatformWalletError.privateContactRequestPending`. + */ + class PrivateContactRequestPending(message: String, cause: Throwable? = null) : + PlatformWallet(message, cause) + /** * Any other `PlatformWalletFFIResultCode` without a dedicated type. * Carries the platform-wallet [nativeCode] (already de-offset) and @@ -889,6 +901,7 @@ sealed class DashSdkError( 68 -> PlatformWallet.PrivateContactRequestNeedsPrivateReply(message, cause) 69 -> PlatformWallet.ShieldedBuildRequired(message, cause) // ErrorShieldedBuildRequired 70 -> PlatformWallet.ProtocolVersionNotActive(message, cause) // ErrorProtocolVersionNotActive + 71 -> PlatformWallet.PrivateContactRequestPending(message, cause) // ErrorPrivateContactRequestPending else -> // @Deprecated fallback — see the code-6 arm; code 31 is the // real discriminator. diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/DashpayNative.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/DashpayNative.kt index 4096e78c2e4..3d1a97ce53f 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/DashpayNative.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/DashpayNative.kt @@ -336,6 +336,23 @@ internal object DashpayNative { * result as for [acceptPrivateContactRequest]. * ← Swift `PlatformWalletManager.acceptContactRequestReplyingInKind`. */ + /** + * What a managed identity's private contact requests with [contactId] + * carry: a JSON object (`receivedPrivately`, `sentPrivately`, + * `shieldedAddress` lower hex or null), or null when neither tracked + * request between them came or went privately. + * ← Swift `ManagedIdentity.privateContact(contactId:)`. + */ + external fun managedIdentityPrivateContact(identityHandle: Long, contactId: ByteArray): String? + + /** + * Whether a private contact request from the managed identity to + * [contactId] is being paid for, or was paid for and is not yet read + * back from the store. A pure read; in memory only on the Rust side. + * ← Swift `ManagedIdentity.privateReplyPending(contactId:)`. + */ + external fun managedIdentityPrivateReplyPending(identityHandle: Long, contactId: ByteArray): Boolean + @Suppress("LongParameterList") external fun acceptContactRequestReplyingInKind( managerHandle: Long, diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/tokens/Dashpay.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/tokens/Dashpay.kt index 6d777e671db..bc36e55427a 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/tokens/Dashpay.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/tokens/Dashpay.kt @@ -287,6 +287,45 @@ class Dashpay internal constructor(private val walletHandle: Long, // and [contacts] read it. The send and the private-reply accept are // shielded spends and live on `PlatformWalletManager`. + /** + * How an identity and one contact exchanged private contact requests. + * ← Swift `PrivateContactInfo`. + */ + data class PrivateContactInfo( + /** The contact's request came privately, so accepting it replies privately. */ + val receivedPrivately: Boolean, + /** This identity's request to the contact went privately. */ + val sentPrivately: Boolean, + /** The 43-byte Orchard address the contact asked to be paid at privately. */ + val shieldedAddress: ByteArray?, + ) { + override fun equals(other: Any?): Boolean = + other is PrivateContactInfo && receivedPrivately == other.receivedPrivately && + sentPrivately == other.sentPrivately && + (shieldedAddress?.contentEquals(other.shieldedAddress) ?: (other.shieldedAddress == null)) + + override fun hashCode(): Int = + (31 * receivedPrivately.hashCode() + sentPrivately.hashCode()) * 31 + + (shieldedAddress?.contentHashCode() ?: 0) + + internal companion object { + /** Decode the JSON object `DashpayNative.managedIdentityPrivateContact` returns. */ + fun fromJson(json: String): PrivateContactInfo { + val obj = org.json.JSONObject(json) + val address = if (obj.isNull("shieldedAddress")) { + null + } else { + obj.getString("shieldedAddress").chunked(2).map { it.toInt(16).toByte() }.toByteArray() + } + return PrivateContactInfo( + receivedPrivately = obj.getBoolean("receivedPrivately"), + sentPrivately = obj.getBoolean("sentPrivately"), + shieldedAddress = address, + ) + } + } + } + /** What one pass over DashPay's private contact request store did. */ data class PrivateContactSyncResult( /** The requests the pass ingested, received and sent, including ones already held. */ @@ -335,6 +374,52 @@ class Dashpay internal constructor(private val walletHandle: Long, ) } + /** + * How [ourIdentityId] and [contactId] exchanged private contact + * requests: whether the contact's request came privately, whether ours + * went privately, and the shielded address the contact asked to be paid + * at (only when its request came privately, and only once a private + * sync this launch has read it). Null when neither tracked request + * between them came or went privately, or the identity isn't managed by + * this wallet. Read from a fresh managed-identity snapshot; local read. + * ← Swift `ManagedIdentity.privateContact(contactId:)` + * (packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PrivateContactRequests.swift). + */ + suspend fun privateContact( + ourIdentityId: ByteArray, + contactId: ByteArray, + ): PrivateContactInfo? = withContext(Dispatchers.IO) { + mapNativeErrors { + withManagedIdentity(ourIdentityId) { handle -> + DashpayNative.managedIdentityPrivateContact(handle, contactId) + }?.let(PrivateContactInfo::fromJson) + } + } + + /** + * The contacts among [contactIds] that [ourIdentityId] has a private + * contact request to in flight, or paid for and not yet read back from + * the store, as the wallet records it, read from one fresh + * managed-identity snapshot. While one is, Rust refuses another private + * send or reply to it ([DashSdkError.PlatformWallet.PrivateContactRequestPending]), + * so a host shows it as pending and offers neither; a private sync + * settles it. Empty when the identity isn't managed. Rust keeps it in + * memory only, so it is empty after a relaunch. + * ← Swift `ManagedIdentity.privateReplyPending(contactId:)` + * (packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PrivateContactRequests.swift). + */ + suspend fun privateRepliesPending( + ourIdentityId: ByteArray, + contactIds: List, + ): List = withContext(Dispatchers.IO) { + if (contactIds.isEmpty()) return@withContext emptyList() + mapNativeErrors { + withManagedIdentity(ourIdentityId) { handle -> + contactIds.filter { DashpayNative.managedIdentityPrivateReplyPending(handle, it) } + } ?: emptyList() + } + } + /** * The incoming contact request from [senderId] to [ourIdentityId] in * local state, or null when there is none (or the identity isn't diff --git a/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/errors/DashSdkErrorTest.kt b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/errors/DashSdkErrorTest.kt index 44501c20ea8..a4a39f7f994 100644 --- a/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/errors/DashSdkErrorTest.kt +++ b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/errors/DashSdkErrorTest.kt @@ -296,6 +296,17 @@ class DashSdkErrorTest { assertEquals(detail, mapped.message) } + @Test + fun shouldReportAPendingPrivateRequestByItsOwnCode() { + val detail = "a private contact request to 9fRYTJZW is in flight or not yet read " + + "back from the store; sync private contact requests to settle it" + val native = DashSDKException(DashSdkError.PLATFORM_WALLET_CODE_OFFSET + 71, detail) + val mapped = DashSdkError.fromNative(native) + assertTrue(mapped is DashSdkError.PlatformWallet.PrivateContactRequestPending) + assertEquals(detail, mapped.message) + assertFalse("pending, not a failure to retry", mapped.isRetryable) + } + @Test fun platformShieldCapacityCode41MapsTyped() { val message = diff --git a/packages/rs-unified-sdk-jni/src/dashpay.rs b/packages/rs-unified-sdk-jni/src/dashpay.rs index 025a892171d..e2d51979e07 100644 --- a/packages/rs-unified-sdk-jni/src/dashpay.rs +++ b/packages/rs-unified-sdk-jni/src/dashpay.rs @@ -23,15 +23,17 @@ use crate::identity::ManagedIdentityHandleGuard; use crate::pubkey_rows::decode_registration_pubkeys_blob; use crate::support::{guard, take_pwffi_error, throw_sdk_exception}; use jni::objects::{JByteArray, JClass, JString, JValue}; -use jni::sys::{jboolean, jint, jlong, jlongArray, jobject, jstring}; +use jni::sys::{jboolean, jint, jlong, jlongArray, jobject, jstring, JNI_FALSE, JNI_TRUE}; use jni::JNIEnv; use platform_wallet_ffi::core_wallet_types::OutPointFFI; use platform_wallet_ffi::dashpay_profile::DashPayProfileFFI; -use platform_wallet_ffi::error::platform_wallet_ffi_result_free; +use platform_wallet_ffi::error::{platform_wallet_ffi_result_free, PlatformWalletFFIResultCode}; use platform_wallet_ffi::handle::Handle; use platform_wallet_ffi::identity_registration_with_signer::IdentityPubkeyFFI; use platform_wallet_ffi::invitation::InvitationPreviewFFI; -use platform_wallet_ffi::{ContactRequestHandleArray, PrivateContactSyncReportFFI}; +use platform_wallet_ffi::{ + ContactRequestHandleArray, PrivateContactFFI, PrivateContactSyncReportFFI, +}; use rs_sdk_ffi::{MnemonicResolverHandle, SignerHandle}; use std::ffi::CStr; use std::os::raw::c_char; @@ -1313,6 +1315,102 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_DashpayNative_syncPri }) } +/// A private contact read as the compact JSON object `managedIdentityPrivateContact` +/// returns: `receivedPrivately`, `sentPrivately`, and `shieldedAddress` (the +/// 43-byte Orchard address as lower hex, or `null`). +fn private_contact_json(contact: &PrivateContactFFI) -> String { + let address = if contact.has_shielded_address { + let hex: String = contact + .shielded_address + .iter() + .map(|byte| format!("{byte:02x}")) + .collect(); + json_string(&hex) + } else { + "null".to_string() + }; + format!( + "{{\"receivedPrivately\":{},\"sentPrivately\":{},\"shieldedAddress\":{}}}", + contact.received_privately, contact.sent_privately, address + ) +} + +/// What this identity's private contact requests with `contactId` carry off a +/// managed-identity handle (bridges `managed_identity_get_private_contact`): +/// the JSON object of [`private_contact_json`], or null when neither tracked +/// request between them came or went privately (the FFI's NotFound, benign +/// here as for `TokensNative.getIncomingContactRequest`). +/// ← Swift `ManagedIdentity.privateContact(contactId:)`. +#[no_mangle] +pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_DashpayNative_managedIdentityPrivateContact( + mut env: JNIEnv, + _class: JClass, + identity_handle: jlong, + contact_id: JByteArray, +) -> jstring { + guard(&mut env, ptr::null_mut(), |env| { + let Some(id) = read_id32(env, &contact_id, "contactId") else { + return ptr::null_mut(); + }; + let mut contact = PrivateContactFFI { + received_privately: false, + sent_privately: false, + has_shielded_address: false, + shielded_address: [0; 43], + }; + let mut result = unsafe { + platform_wallet_ffi::managed_identity_get_private_contact( + identity_handle as Handle, + id.as_ptr(), + &mut contact, + ) + }; + if result.code == PlatformWalletFFIResultCode::NotFound { + unsafe { platform_wallet_ffi_result_free(&mut result) }; + return ptr::null_mut(); + } + if take_pwffi_error(env, result) { + return ptr::null_mut(); + } + new_jstring(env, private_contact_json(&contact)) + }) +} + +/// Whether a private contact request from this identity to `contactId` is +/// being paid for, or was paid for and is not yet read back from the store, +/// off a managed-identity handle (bridges +/// `managed_identity_get_private_reply_pending`). A pure read; the wallet +/// keeps it in memory only. ← Swift `ManagedIdentity.privateReplyPending(contactId:)`. +#[no_mangle] +pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_DashpayNative_managedIdentityPrivateReplyPending( + mut env: JNIEnv, + _class: JClass, + identity_handle: jlong, + contact_id: JByteArray, +) -> jboolean { + guard(&mut env, JNI_FALSE, |env| { + let Some(id) = read_id32(env, &contact_id, "contactId") else { + return JNI_FALSE; + }; + let mut pending = false; + let result = unsafe { + platform_wallet_ffi::managed_identity_get_private_reply_pending( + identity_handle as Handle, + id.as_ptr(), + &mut pending, + ) + }; + if take_pwffi_error(env, result) { + return JNI_FALSE; + } + if pending { + JNI_TRUE + } else { + JNI_FALSE + } + }) +} + /// Copy an optional Java `byte[]`; JVM null is `None`. Throws and returns /// `Err` on a JNI error. `field` names the argument in the message. fn read_opt_bytes(env: &mut JNIEnv, arr: &JByteArray, field: &str) -> Result>, ()> { @@ -1542,7 +1640,6 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_DashpayNative_acceptC #[cfg(test)] mod tests { use super::*; - use platform_wallet_ffi::error::PlatformWalletFFIResultCode; // The natives above need a JVM; the counter layout Kotlin decodes and the // handle release are pure and are pinned here. @@ -1615,6 +1712,29 @@ mod tests { assert_eq!(private_contact_sync_longs(0, &report)[4], i64::MAX); } + #[test] + fn should_render_a_private_contact_as_the_json_kotlin_reads() { + let mut contact = PrivateContactFFI { + received_privately: true, + sent_privately: false, + has_shielded_address: true, + shielded_address: [0xab; 43], + }; + assert_eq!( + private_contact_json(&contact), + format!( + "{{\"receivedPrivately\":true,\"sentPrivately\":false,\"shieldedAddress\":\"{}\"}}", + "ab".repeat(43) + ) + ); + contact.has_shielded_address = false; + contact.sent_privately = true; + assert_eq!( + private_contact_json(&contact), + "{\"receivedPrivately\":true,\"sentPrivately\":true,\"shieldedAddress\":null}" + ); + } + #[test] fn should_release_an_empty_handle_array_as_zero_requests() { let mut array = ContactRequestHandleArray::empty(); From f5a8e76c07e59dd31431717c55ea23d997c3f645 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 9 Nov 2026 01:26:07 +0700 Subject: [PATCH 045/269] fix(kotlin-example-app): read a pending private reply from the wallet As the Swift app does: the Requests rows and Add Contact's Send and collision accept read whether the wallet has a private request to the contact in flight or not yet read back (`privateRepliesPending`), instead of keeping an unconfirmed accept in a `remember` set. While it is set, the row shows pending without its buttons, and Send and the collision accept are off. It clears when a private sync settles it and holds when the screen is left and reopened. Requests re-reads it on composition and row changes, after an accept, on refresh and after each sync pass; Add Contact re-reads it for the resolved recipient and after an accept. `PrivateContactRequestPending` (71) is the same outcome as an unconfirmed spend: pending, no fresh submission. `ContactRequestOutcomesTest` covers it and the row message. Co-Authored-By: Claude Opus 5.5 --- .../example/ui/dashpay/AddContactScreen.kt | 24 +++++++++- .../ui/dashpay/ContactRequestOutcomes.kt | 26 +++++++---- .../ui/dashpay/ContactRequestsScreen.kt | 45 ++++++++++++++----- .../example/ui/dashpay/DashPaySyncHelpers.kt | 22 +++++++++ .../ui/dashpay/ContactRequestOutcomesTest.kt | 22 +++++++++ 5 files changed, 118 insertions(+), 21 deletions(-) diff --git a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/AddContactScreen.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/AddContactScreen.kt index 1a06c468a46..9c47c5149c6 100644 --- a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/AddContactScreen.kt +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/AddContactScreen.kt @@ -102,6 +102,12 @@ fun AddContactScreen(identityIdHex: String, navController: NavHostController) { // could not be confirmed): the screen only offers to close, never to // submit again, as the send screen does for an unconfirmed spend. var unconfirmedNotice by remember { mutableStateOf(null) } + // Whether the wallet has a private request to the resolved recipient in + // flight, or paid and not yet read back (`privateRepliesPending`). While + // it does, Send and the collision accept are off; a private sync settles + // it. Read from the wallet, so reopening this screen keeps it. + var recipientPending by remember { mutableStateOf(false) } + var pendingReadVersion by remember { mutableStateOf(0) } var collisionRecipient by remember { mutableStateOf(null) } var previewProfile by remember { mutableStateOf(null) } @@ -112,7 +118,7 @@ fun AddContactScreen(identityIdHex: String, navController: NavHostController) { } val recipientIsSelf = resolvedRecipient?.contentEquals(idBytes) == true val canSend = resolvedRecipient != null && !recipientIsSelf && !isSending && - unconfirmedNotice == null + unconfirmedNotice == null && !recipientPending // Scan-to-send: consume the QR string handed back by the scanner screen // (the house pattern — observe the saved-state flow, then null it out). @@ -173,6 +179,12 @@ fun AddContactScreen(identityIdHex: String, navController: NavHostController) { // Cache-only profile for the preview card (local read; most unknown // identities won't have one). val recipientHex = resolvedRecipient?.toHex() + LaunchedEffect(recipientHex, wallet, pendingReadVersion) { + val recipient = resolvedRecipient + val w = wallet + recipientPending = recipient != null && w != null && + recipient.toHex() in privateRepliesPending(w, idBytes, listOf(recipient)) + } LaunchedEffect(recipientHex) { val recipient = resolvedRecipient val w = wallet @@ -219,6 +231,8 @@ fun AddContactScreen(identityIdHex: String, navController: NavHostController) { fun acceptIncoming(recipient: ByteArray) { val w = wallet ?: return val m = manager ?: return + // A private reply to this contact is already pending in the wallet. + if (recipientPending) return isSending = true errorMessage = null scope.launch { @@ -248,6 +262,7 @@ fun AddContactScreen(identityIdHex: String, navController: NavHostController) { } } finally { isSending = false + pendingReadVersion++ } } } @@ -347,6 +362,13 @@ fun AddContactScreen(identityIdHex: String, navController: NavHostController) { enabled = canSend, modifier = Modifier.fillMaxWidth().testTag("dashpay.addContact.send"), ) { attemptSend() } + if (recipientPending) { + Text( + ContactRequestSubmissionOutcome.MAY_HAVE_GONE_THROUGH_MESSAGE, + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.testTag("dashpay.addContact.pending"), + ) + } } if (errorMessage != null) { diff --git a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/ContactRequestOutcomes.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/ContactRequestOutcomes.kt index 97fd72c29e8..eeb67a10f60 100644 --- a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/ContactRequestOutcomes.kt +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/ContactRequestOutcomes.kt @@ -7,8 +7,10 @@ import org.dashfoundation.dashsdk.errors.DashSdkError * Follows the send screen (and iOS `SendViewModel.swift`): a shielded spend * whose result could not be confirmed may already be on chain (Rust keeps its * notes reserved), so the screen keeps it pending and never offers a fresh - * submission; the next sync settles it. Every other error is an ordinary - * failure the user may retry. + * submission; the next sync settles it. A private request Rust reports as + * pending (one to the same contact in flight, or paid and not yet read back) + * is the same outcome: nothing new was paid, and the next private sync + * settles it. Every other error is an ordinary failure the user may retry. * ← Swift `ContactRequestSubmissionOutcome` (`ContactRequestOutcomes.swift`). */ sealed interface ContactRequestSubmissionOutcome { @@ -22,12 +24,20 @@ sealed interface ContactRequestSubmissionOutcome { const val MAY_HAVE_GONE_THROUGH_MESSAGE = "This may have gone through. It shows up " + "after the next sync confirms it; do not submit it again." - fun of(error: Throwable): ContactRequestSubmissionOutcome = - if (error is DashSdkError.PlatformWallet.ShieldedSpendUnconfirmed) { - MayHaveGoneThrough - } else { - Failed(error.message ?: "unknown error") - } + fun of(error: Throwable): ContactRequestSubmissionOutcome = when (error) { + is DashSdkError.PlatformWallet.ShieldedSpendUnconfirmed, + is DashSdkError.PlatformWallet.PrivateContactRequestPending, + -> MayHaveGoneThrough + else -> Failed(error.message ?: "unknown error") + } + + /** + * The message a request row shows: the pending notice while the + * wallet has a private reply to the contact pending, whatever error an + * earlier attempt left, and that error otherwise. + */ + fun rowMessage(pending: Boolean, error: String?): String? = + if (pending) MAY_HAVE_GONE_THROUGH_MESSAGE else error } } diff --git a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/ContactRequestsScreen.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/ContactRequestsScreen.kt index 5a102240207..817e30e67d6 100644 --- a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/ContactRequestsScreen.kt +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/ContactRequestsScreen.kt @@ -92,10 +92,14 @@ fun ContactRequestsScreen(identityIdHex: String, navController: NavHostControlle var removedOverlayIds by remember { mutableStateOf(emptySet()) } var rowErrors by remember { mutableStateOf(emptyMap()) } var isRefreshing by remember { mutableStateOf(false) } - // Hex ids whose accept may have gone through (a private reply whose result - // could not be confirmed): the row stays pending, without its buttons, - // until the next sync settles it. Never offered again here. - var unconfirmedIds by remember { mutableStateOf(emptySet()) } + // Hex ids with a private reply in flight, or paid and not yet read back, + // as the wallet records it (`privateRepliesPending`): the row stays + // pending, without its buttons, until a private sync settles it. Read + // from the wallet, not kept by this screen, so leaving and reopening it + // keeps the state. Re-read on composition and row changes, after an + // accept, on refresh and after each sync pass. + var walletPendingIds by remember { mutableStateOf(emptySet()) } + var pendingReadVersion by remember { mutableStateOf(0) } // What the last private-store refresh has to tell the user, if anything. var privateRefreshNotice by remember { mutableStateOf(null) } @@ -153,6 +157,22 @@ fun ContactRequestsScreen(identityIdHex: String, navController: NavHostControlle .sortedByDescending { it.createdAtMillis } } + LaunchedEffect(wallet, rows, pendingReadVersion) { + val w = wallet + walletPendingIds = if (w == null) { + emptySet() + } else { + privateRepliesPending( + wallet = w, + ourIdentityId = idBytes, + contactIds = rows.filter { !it.isOutgoing }.map { it.contactIdentityId }, + ) + } + } + LaunchedEffect(manager) { + manager?.dashPaySyncIsSyncing?.collect { syncing -> if (!syncing) pendingReadVersion++ } + } + fun accept(contactId: ByteArray) { val w = wallet ?: return val m = manager ?: return @@ -177,8 +197,8 @@ fun ContactRequestsScreen(identityIdHex: String, navController: NavHostControlle when (val outcome = ContactRequestSubmissionOutcome.of(e)) { ContactRequestSubmissionOutcome.MayHaveGoneThrough -> { // As the send screen treats an unconfirmed spend: not - // a failure to retry. The next sync settles it. - unconfirmedIds = unconfirmedIds + hex + // a failure to retry. The wallet keeps the reply + // pending, and the row reads that. kickDashPaySync(scope, m) } is ContactRequestSubmissionOutcome.Failed -> @@ -186,6 +206,7 @@ fun ContactRequestsScreen(identityIdHex: String, navController: NavHostControlle } } finally { inFlightIds = inFlightIds - hex + pendingReadVersion++ } } } @@ -227,6 +248,7 @@ fun ContactRequestsScreen(identityIdHex: String, navController: NavHostControlle manager?.let { m -> attachOrStartSync(m) wallet?.let { privateRefreshNotice = syncPrivateContactRequests(it, m) } + pendingReadVersion++ } isRefreshing = false } @@ -263,12 +285,11 @@ fun ContactRequestsScreen(identityIdHex: String, navController: NavHostControlle val hex = row.contactId.toHex() IncomingRequestRow( item = row, - isInFlight = inFlightIds.contains(hex) || unconfirmedIds.contains(hex), - errorMessage = if (unconfirmedIds.contains(hex)) { - ContactRequestSubmissionOutcome.MAY_HAVE_GONE_THROUGH_MESSAGE - } else { - rowErrors[hex] - }, + isInFlight = inFlightIds.contains(hex) || walletPendingIds.contains(hex), + errorMessage = ContactRequestSubmissionOutcome.rowMessage( + pending = walletPendingIds.contains(hex), + error = rowErrors[hex], + ), onAccept = { accept(row.contactId) }, onIgnore = { ignore(row.contactId) }, ) diff --git a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/DashPaySyncHelpers.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/DashPaySyncHelpers.kt index 1e48f973385..658fe499add 100644 --- a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/DashPaySyncHelpers.kt +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/DashPaySyncHelpers.kt @@ -6,6 +6,7 @@ import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import org.dashfoundation.dashsdk.wallet.ManagedPlatformWallet import org.dashfoundation.dashsdk.wallet.PlatformWalletManager +import org.dashfoundation.example.util.toHex /** * Shared DashPay sync helpers — port of the free functions in @@ -35,6 +36,27 @@ fun kickDashPaySync(scope: CoroutineScope, manager: PlatformWalletManager) { scope.launch { runCatching { manager.dashPaySyncNow() } } } +/** + * The hex ids of the contacts among [contactIds] that [ourIdentityId] has a + * private request to in flight, or paid and not yet read back, as the wallet + * records it (one snapshot). While one is, Rust refuses another private send + * or reply to it, so the screens show it as pending and offer neither; a + * private sync settles it. Empty when the read fails or the identity is not + * managed. + * ← Swift `privateRepliesPending` (`ContactsView.swift`). + */ +suspend fun privateRepliesPending( + wallet: ManagedPlatformWallet, + ourIdentityId: ByteArray, + contactIds: List, +): Set = try { + wallet.dashpay.privateRepliesPending(ourIdentityId, contactIds).map { it.toHex() }.toSet() +} catch (e: CancellationException) { + throw e +} catch (e: Exception) { + emptySet() +} + /** * Read DashPay's private contact request store (protocol version 15) for * [wallet], returning what the refresh should tell the user, or null. diff --git a/packages/kotlin-sdk/KotlinExampleApp/app/src/test/java/org/dashfoundation/example/ui/dashpay/ContactRequestOutcomesTest.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/test/java/org/dashfoundation/example/ui/dashpay/ContactRequestOutcomesTest.kt index e398f8ee37f..1a1457161d0 100644 --- a/packages/kotlin-sdk/KotlinExampleApp/app/src/test/java/org/dashfoundation/example/ui/dashpay/ContactRequestOutcomesTest.kt +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/test/java/org/dashfoundation/example/ui/dashpay/ContactRequestOutcomesTest.kt @@ -21,6 +21,28 @@ class ContactRequestOutcomesTest { ) } + @Test + fun shouldKeepAPendingPrivateRequestPending() { + val error = DashSdkError.PlatformWallet.PrivateContactRequestPending("not yet read back from the store") + assertEquals( + ContactRequestSubmissionOutcome.MayHaveGoneThrough, + ContactRequestSubmissionOutcome.of(error), + ) + } + + @Test + fun shouldShowAWalletPendingReplyOverAnEarlierError() { + assertEquals( + ContactRequestSubmissionOutcome.MAY_HAVE_GONE_THROUGH_MESSAGE, + ContactRequestSubmissionOutcome.rowMessage(pending = true, error = "Accept failed: boom"), + ) + assertEquals( + "Accept failed: boom", + ContactRequestSubmissionOutcome.rowMessage(pending = false, error = "Accept failed: boom"), + ) + assertNull(ContactRequestSubmissionOutcome.rowMessage(pending = false, error = null)) + } + @Test fun shouldOfferARetryForAnOrdinaryFailure() { val error = DashSdkError.PlatformWallet.ShieldedBroadcastFailed("rejected by the relay") From 2890ed8580079c4b7cf58b754c2d83c730f91446 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 9 Nov 2026 01:26:08 +0700 Subject: [PATCH 046/269] docs(kotlin-sdk): track the private contact and pending reads in the private contact parity entry `dashpay.private_contact_requests` lists `managed_identity_get_private_reply_pending`. Both hosts' reasons now say the private contact and pending reads are wrapped (Kotlin now bridges them), codes 68 to 71 are typed, and the example apps read the wallet's pending flag. The entry cites the FFI getter test and the Kotlin pending outcome test. Statuses are unchanged, so `PARITY_SUMMARY.md` regenerates identical. Co-Authored-By: Claude Opus 5.5 --- docs/sdk/sdk-parity-manifest.json | 26 ++++++++++++++++++++++---- 1 file changed, 22 insertions(+), 4 deletions(-) diff --git a/docs/sdk/sdk-parity-manifest.json b/docs/sdk/sdk-parity-manifest.json index e8126c7a963..a7a6cd1510f 100644 --- a/docs/sdk/sdk-parity-manifest.json +++ b/docs/sdk/sdk-parity-manifest.json @@ -31,6 +31,7 @@ "managed_identity_get_contested_dpns_names": "packages/rs-platform-wallet-ffi/src/dpns.rs", "managed_identity_get_dashpay_payments": "packages/rs-platform-wallet-ffi/src/dashpay_payment.rs", "managed_identity_get_private_contact": "packages/rs-platform-wallet-ffi/src/private_contact_request.rs", + "managed_identity_get_private_reply_pending": "packages/rs-platform-wallet-ffi/src/private_contact_request.rs", "on_load_shielded_viewing_keys_fn": "packages/rs-platform-wallet-ffi/src/persistence.rs", "on_load_shielded_viewing_keys_free_fn": "packages/rs-platform-wallet-ffi/src/persistence.rs", "on_persist_address_balances_fn": "packages/rs-platform-wallet-ffi/src/persistence.rs", @@ -777,14 +778,15 @@ }, { "id": "dashpay.private_contact_requests", - "title": "DashPay private contact requests: store sync, shielded send, accept replying in kind, private-reply accept, private contact read, code 68 routing", + "title": "DashPay private contact requests: store sync, shielded send, accept replying in kind, private-reply accept, private contact and pending reads, codes 68 to 71", "area": "correctness", "shared_apis": [ "platform_wallet_sync_private_contact_requests", "platform_wallet_manager_send_private_contact_request", "platform_wallet_manager_accept_contact_request_replying_in_kind", "platform_wallet_manager_accept_private_contact_request", - "managed_identity_get_private_contact" + "managed_identity_get_private_contact", + "managed_identity_get_private_reply_pending" ], "required_persistence_capabilities": [ "atomic_changesets", @@ -795,13 +797,13 @@ "sdk": "partial", "example_app": "partial", "restart": "required", - "reason": "PrivateContactRequests.swift wraps the store sync, the shielded send, the accept replying in kind (the wallet replies privately to a request that came privately, and with a document otherwise; a public or adopted reply needs no shielded support or payment mnemonic read, and a build without shielded support refuses only a private reply, with code 69), the explicit private-reply accept and PrivateContactInfo, all signed with the identity signer the public calls take and counted as native manager operations; codes 68 to 70 are typed and pinned in ErrorHandlingTests. The sync, send and accepts have no automated or recorded gate: a private reply needs a protocol version 15 network with shielded funds. The private marker (core height 0) round-trips through the persisted coreHeightCreatedAt, but no test covers a restart. The example app has the send-privately toggle, accepts with acceptContactRequestReplyingInKind, keeps an unconfirmed private send or reply pending, and reports private refresh failures (quiet before protocol version 15), with no recorded gate." + "reason": "PrivateContactRequests.swift wraps the store sync, the shielded send, the accept replying in kind (the wallet replies privately to a request that came privately, and with a document otherwise; a public or adopted reply needs no shielded support or payment mnemonic read, and a build without shielded support refuses only a private reply, with code 69), the explicit private-reply accept, PrivateContactInfo and the wallet's pending-reply read, all signed with the identity signer the public calls take and counted as native manager operations; codes 68 to 71 are typed and pinned in ErrorHandlingTests. The sync, send and accepts have no automated or recorded gate: a private reply needs a protocol version 15 network with shielded funds. The private marker (core height 0) round-trips through the persisted coreHeightCreatedAt, but no test covers a restart, and the wallet keeps a pending reply in memory only. The example app has the send-privately toggle, accepts with acceptContactRequestReplyingInKind, keeps a pending or unconfirmed private send or reply pending by reading the wallet's flag, and reports private refresh failures (quiet before protocol version 15), with no recorded gate." }, "kotlin": { "sdk": "partial", "example_app": "partial", "restart": "required", - "reason": "Dashpay.syncPrivateContactRequests and PlatformWalletManager.sendPrivateContactRequest / acceptContactRequestReplyingInKind / acceptPrivateContactRequest bridge the same FFI (signed with the manager's identity signer, behind the manager's teardown gate); the in-kind accept is exported in every build, and codes 68 to 70 map to typed PlatformWallet errors (DashSdkErrorTest). managed_identity_get_private_contact is not bridged: the Swift example app has no caller for it. JNI descriptors are covered only by the FfiSmokeTest symbol test; the flows need a protocol version 15 network with shielded funds and have no recorded gate, and no test covers a restart. The example app accepts with acceptContactRequestReplyingInKind, keeps an unconfirmed reply pending, and reports private refresh failures (quiet before protocol version 15); it has no send-privately toggle." + "reason": "Dashpay.syncPrivateContactRequests, privateContact and privateRepliesPending (fresh managed-identity snapshots) and PlatformWalletManager.sendPrivateContactRequest / acceptContactRequestReplyingInKind / acceptPrivateContactRequest bridge the same FFI (signed with the manager's identity signer, behind the manager's teardown gate); the in-kind accept is exported in every build, and codes 68 to 71 map to typed PlatformWallet errors (DashSdkErrorTest). JNI descriptors are covered only by the FfiSmokeTest symbol test; the flows need a protocol version 15 network with shielded funds and have no recorded gate, no test covers a restart, and the wallet keeps a pending reply in memory only. The example app accepts with acceptContactRequestReplyingInKind, keeps a pending or unconfirmed reply pending by reading the wallet's flag on its Requests rows and Add Contact, and reports private refresh failures (quiet before protocol version 15); it has no send-privately toggle." } }, "verification": [ @@ -829,6 +831,14 @@ "command": "cargo test -p platform-wallet-ffi --lib should_accept_publicly_without_shielded_support_or_a_mnemonic_read", "covers_restart": false }, + { + "host": "shared", + "kind": "unit", + "file": "packages/rs-platform-wallet-ffi/src/private_contact_request.rs", + "id": "should_read_whether_a_private_reply_is_pending", + "command": "cargo test -p platform-wallet-ffi --lib should_read_whether_a_private_reply_is_pending", + "covers_restart": false + }, { "host": "shared", "kind": "unit", @@ -869,6 +879,14 @@ "command": "cd packages/kotlin-sdk && ./gradlew :app:testDebugUnitTest --tests '*ContactRequestOutcomesTest*'", "covers_restart": false }, + { + "host": "kotlin", + "kind": "unit", + "file": "packages/kotlin-sdk/KotlinExampleApp/app/src/test/java/org/dashfoundation/example/ui/dashpay/ContactRequestOutcomesTest.kt", + "id": "shouldKeepAPendingPrivateRequestPending", + "command": "cd packages/kotlin-sdk && ./gradlew :app:testDebugUnitTest --tests '*ContactRequestOutcomesTest*'", + "covers_restart": false + }, { "host": "kotlin", "kind": "unit", From 9a4ca7f266e61c9d784374a493afe4761ab1a54e Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 9 Nov 2026 02:00:09 +0700 Subject: [PATCH 047/269] 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 048/269] 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 049/269] 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 050/269] 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 051/269] 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 052/269] 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 053/269] 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 e024423268253921002c167cdf1f4022e66932d9 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 9 Nov 2026 03:06:57 +0700 Subject: [PATCH 054/269] docs(platform-wallet): name the unread private store as a pending case of code 71 `ErrorPrivateContactRequestPending` (71) is now also returned when an 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 that a reply paid for in an earlier process is adopted rather than paid for again. Nothing was paid, and a later private sync or a retry settles it. The FFI enum doc, the registry row and Swift's `privateContactRequestPending` doc say so. Co-Authored-By: Claude Opus 5.5 --- .../rs-platform-wallet-ffi/ERROR_CODE_REGISTRY.md | 2 +- packages/rs-platform-wallet-ffi/src/error.rs | 12 ++++++++---- .../PlatformWallet/PlatformWalletResult.swift | 8 +++++--- 3 files changed, 14 insertions(+), 8 deletions(-) diff --git a/packages/rs-platform-wallet-ffi/ERROR_CODE_REGISTRY.md b/packages/rs-platform-wallet-ffi/ERROR_CODE_REGISTRY.md index 4b2c4501e83..e7b1c10ab1e 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. Nothing new was paid; a later private contact request sync 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`. 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 7455c6d30be..269ed8ee62b 100644 --- a/packages/rs-platform-wallet-ffi/src/error.rs +++ b/packages/rs-platform-wallet-ffi/src/error.rs @@ -652,10 +652,14 @@ pub enum PlatformWalletFFIResultCode { /// 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. - /// Nothing new was paid. Do not submit it again: a later private contact - /// request sync (`platform_wallet_sync_private_contact_requests`) - /// settles it. + /// 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. diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift index 7e205db7fee..6374d5e1d85 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift @@ -633,9 +633,11 @@ public enum PlatformWalletError: LocalizedError { case protocolVersionNotActive(String) /// A private contact request to that identity is in flight (another /// private send or reply reserved it), or was paid for and is not yet - /// read back from the store. Nothing new was paid; a later - /// `ManagedPlatformWallet.syncPrivateContactRequests` settles it. Pending, - /// not failed: do not submit it again. + /// 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 + /// launch. Nothing new was paid; a later + /// `ManagedPlatformWallet.syncPrivateContactRequests`, or a retry, settles + /// it. Pending, not failed: do not submit it again. case privateContactRequestPending(String) case walletAlreadyExists(String) /// Definitive shielded-broadcast failure: the shielded transition From 8a098b2fbcf6ad5d8763d3ace54b1174094dcd09 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 9 Nov 2026 03:06:57 +0700 Subject: [PATCH 055/269] fix(swift-example-app): keep "Send privately" through Add Contact's collision accept With "Send privately" on and a request already received from the recipient, the collision alert's accept called the in-kind accept. A publicly received request then got a public `contactRequest` reply, which publishes this user as its owner and the contact as `toUserId`, against the chosen option and without saying so. The accept now follows the toggle, through the pure `CollisionAccept` helper: - On, it replies privately with `acceptPrivateContactRequest`, paid from the shielded balance. The Rust private accept takes a publicly received request too. The alert's button reads "Accept privately" and its message says the reply is private and paid from the shielded balance. - Off, it is the in-kind accept, as before. The accept is refused while the wallet reports a private reply to the contact pending, as Send is. Outcomes are read as elsewhere: an unconfirmed spend or code 71 stays pending. `ContactRequestOutcomesTests` pins both toggle states. Co-Authored-By: Claude Opus 5.5 --- .../Views/DashPay/AddContactView.swift | 37 +++++++++++----- .../DashPay/ContactRequestOutcomes.swift | 42 +++++++++++++++++++ .../ContactRequestOutcomesTests.swift | 19 +++++++++ 3 files changed, 88 insertions(+), 10 deletions(-) diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/AddContactView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/AddContactView.swift index c5d35021ecf..b42aa0ee45e 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/AddContactView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/AddContactView.swift @@ -159,14 +159,14 @@ struct AddContactView: View { isPresented: $showCollisionAlert, presenting: collisionRecipient ) { recipient in - Button("Accept") { + Button(CollisionAccept.of(sendPrivately: sendPrivately).buttonTitle) { acceptIncoming(from: recipient) } Button("Continue anyway") { send(to: recipient) } } message: { _ in - Text("This person already sent you a request — accept it instead?") + Text(CollisionAccept.of(sendPrivately: sendPrivately).message) } .alert( "Submitted", @@ -550,6 +550,11 @@ struct AddContactView: View { /// `ContactRequest` and accept it — establishing the contact /// directly instead of sending a redundant request. private func acceptIncoming(from recipient: Identifier) { + // A private request to this contact is already pending in the wallet. + guard !recipientPending else { return } + // "Send privately" is the user's choice of a private request, so it + // holds for the accept too (`CollisionAccept`). + let accept = CollisionAccept.of(sendPrivately: sendPrivately) isSending = true errorMessage = nil Task { @MainActor in @@ -564,14 +569,26 @@ struct AddContactView: View { return } let signer = KeychainSigner(modelContainer: modelContext.container) - // The wallet replies the way the request came: privately - // (paid from the shielded balance) or with a document. - _ = try await walletManager.acceptContactRequestReplyingInKind( - walletId: wallet.walletId, - resolver: MnemonicResolver(), - request: request, - signer: signer - ) + switch accept { + case .privateReply: + // A private reply, paid from the shielded balance, even to + // a request that came publicly. + _ = try await walletManager.acceptPrivateContactRequest( + walletId: wallet.walletId, + resolver: MnemonicResolver(), + request: request, + signer: signer + ) + case .replyInKind: + // The wallet replies the way the request came: privately + // (paid from the shielded balance) or with a document. + _ = try await walletManager.acceptContactRequestReplyingInKind( + walletId: wallet.walletId, + resolver: MnemonicResolver(), + request: request, + signer: signer + ) + } kickDashPaySync(walletManager) dismiss() } catch { diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ContactRequestOutcomes.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ContactRequestOutcomes.swift index 70e1e28e28c..6e5244eb481 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ContactRequestOutcomes.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ContactRequestOutcomes.swift @@ -38,6 +38,48 @@ enum ContactRequestSubmissionOutcome: Equatable { } } +/// How Add Contact's collision alert accepts the request the recipient +/// already sent us. With "Send privately" on, the user chose a private +/// request, so the accept replies privately +/// (`PlatformWalletManager.acceptPrivateContactRequest`, paid from the +/// shielded balance as the private send is) even when their request came +/// publicly: a public reply would publish this user as its owner and the +/// contact as its recipient, against that choice. With it off, the wallet +/// replies the way the request came +/// (`PlatformWalletManager.acceptContactRequestReplyingInKind`). The toggle is +/// the user's explicit choice; which reply an in-kind accept takes stays the +/// wallet's. +enum CollisionAccept: Equatable { + /// Reply privately, paid from the shielded balance. + case privateReply + /// Reply the way the request came. + case replyInKind + + static func of(sendPrivately: Bool) -> CollisionAccept { + sendPrivately ? .privateReply : .replyInKind + } + + /// The alert's accept button. + var buttonTitle: String { + switch self { + case .privateReply: return "Accept privately" + case .replyInKind: return "Accept" + } + } + + /// The alert's message. + var message: String { + switch self { + case .privateReply: + return "This person already sent you a request. Accept it with a private reply " + + "instead? The reply is paid from this wallet's shielded balance, and no " + + "public request names you as its sender." + case .replyInKind: + return "This person already sent you a request. Accept it instead?" + } + } +} + /// What the request list's private-store refresh tells the user. A network /// that does not serve the store yet (before protocol version 15) is expected /// and stays quiet; an operational failure, or requests read but not saved, diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/ContactRequestOutcomesTests.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/ContactRequestOutcomesTests.swift index bc6a22619cb..6c4307860be 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/ContactRequestOutcomesTests.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/ContactRequestOutcomesTests.swift @@ -34,6 +34,25 @@ final class ContactRequestOutcomesTests: XCTestCase { XCTAssertNil(ContactRequestSubmissionOutcome.rowMessage(pending: false, error: nil)) } + func testShouldAcceptACollisionPrivatelyWhenSendPrivatelyIsOn() { + let accept = CollisionAccept.of(sendPrivately: true) + XCTAssertEqual(accept, .privateReply) + XCTAssertEqual(accept.buttonTitle, "Accept privately") + XCTAssertEqual( + accept.message, + "This person already sent you a request. Accept it with a private reply instead? " + + "The reply is paid from this wallet's shielded balance, and no public request " + + "names you as its sender." + ) + } + + func testShouldAcceptACollisionInKindWhenSendPrivatelyIsOff() { + let accept = CollisionAccept.of(sendPrivately: false) + XCTAssertEqual(accept, .replyInKind) + XCTAssertEqual(accept.buttonTitle, "Accept") + XCTAssertEqual(accept.message, "This person already sent you a request. Accept it instead?") + } + func testShouldOfferARetryForAnOrdinaryFailure() { let error = PlatformWalletError.shieldedBroadcastFailed("rejected by the relay") XCTAssertEqual( From f5dde309c4bfcf51c501a08a656bbb6b75552d0b Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 9 Nov 2026 03:09:45 +0700 Subject: [PATCH 056/269] 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 057/269] 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 058/269] 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 059/269] 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 060/269] 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 061/269] 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 062/269] 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 063/269] 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 064/269] 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 065/269] 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 066/269] 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 067/269] 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 068/269] 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 069/269] 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 070/269] 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 071/269] 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 072/269] 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 073/269] 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 074/269] 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 075/269] 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 076/269] 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 077/269] 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 078/269] 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 079/269] 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 080/269] 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 081/269] 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 082/269] 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 083/269] 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 084/269] 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 085/269] 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 086/269] 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 087/269] 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 088/269] 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 089/269] 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 090/269] 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 091/269] 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 092/269] 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 093/269] 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 094/269] 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 095/269] 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 096/269] 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 097/269] 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 9640e70971a52fa605228b20326ced69193ae4e9 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 9 Nov 2026 09:52:00 +0700 Subject: [PATCH 098/269] fix(platform-wallet-ffi): keep a wallet removed before a private payment at NotFound `map_spend_result` sent `WalletRemovedBeforeSend` through its catch-all to `ErrorWalletOperation` (6), while every other export gives it the lifecycle refusal code `NotFound` (98). It now passes through the blanket conversion beside `PrivateContactRequestPending`, so a host can tell a removed wallet from an ordinary failure without reading the message. Co-Authored-By: Claude Opus 5.5 --- .../src/shielded_send.rs | 20 +++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/packages/rs-platform-wallet-ffi/src/shielded_send.rs b/packages/rs-platform-wallet-ffi/src/shielded_send.rs index ceb21c93a5c..19b67d45942 100644 --- a/packages/rs-platform-wallet-ffi/src/shielded_send.rs +++ b/packages/rs-platform-wallet-ffi/src/shielded_send.rs @@ -622,8 +622,11 @@ 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. - Err(e @ PlatformWalletError::PrivateContactRequestPending(..)) => e.into(), + // 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 @@ -3020,6 +3023,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 df84f924e42233fdd6e67299e97c6d44114bac4a Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 9 Nov 2026 09:52:00 +0700 Subject: [PATCH 099/269] feat(platform-wallet-ffi,swift-sdk): persist the private contact request scan state The wallet keeps where each identity reads DashPay's private contact request store from next, and its private contacts' shielded addresses, behind `PRIVATE_CONTACT_REQUEST_SCAN_STATE` (bit 14). This carries them across the FFI and stores them in SwiftData, so a relaunch resumes the private sync instead of reading the store from its start. FFI: three size-gated `PersistenceCallbacksExtension` slots, appended after the asset-lock operation id pair. The persist slot receives a round's read positions, addresses, address removals and the identities the round removed (whose scan state the host clears), after the contacts callback. The load slot returns a wallet's positions and addresses during the wallet-list load; they are applied to the identities that load restored, and a host error or a missing buffer fails the load. The capability is attested only with all three slots, the wallet-list restore callbacks and the declaration, and nothing is read from a host that does not attest it. Swift: two new models, `PersistentPrivateContactRequestsPosition` and `PersistentPrivateShieldedAddress`, in a new live schema V5; V4 is bound to its published snapshot with a lightweight V4 to V5 stage. The handler writes them inside the changeset round, purges them with the wallet, and declares the capability. Co-Authored-By: Claude Opus 5.5 --- .../src/contact_persistence.rs | 40 + .../rs-platform-wallet-ffi/src/manager.rs | 91 +- .../rs-platform-wallet-ffi/src/persistence.rs | 792 +++++++++++++++++- packages/swift-sdk/SCHEMA_RELEASES.md | 3 +- .../Persistence/DashModelContainer.swift | 41 +- ...istentPrivateContactRequestsPosition.swift | 28 + .../PersistentPrivateShieldedAddress.swift | 26 + .../PlatformWalletManager.swift | 8 + .../PlatformWalletPersistenceHandler.swift | 268 ++++++ .../DashLegacySchemaMigrationTests.swift | 15 +- .../DashModelMigrationTests.swift | 14 +- .../InvitationPersistenceTests.swift | 4 + ...vateContactScanStatePersistenceTests.swift | 187 +++++ packages/swift-sdk/schema-models.json | 4 +- 14 files changed, 1483 insertions(+), 38 deletions(-) create mode 100644 packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentPrivateContactRequestsPosition.swift create mode 100644 packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentPrivateShieldedAddress.swift create mode 100644 packages/swift-sdk/SwiftTests/SwiftDashSDKTests/PrivateContactScanStatePersistenceTests.swift diff --git a/packages/rs-platform-wallet-ffi/src/contact_persistence.rs b/packages/rs-platform-wallet-ffi/src/contact_persistence.rs index 416e82870dc..eedd9996390 100644 --- a/packages/rs-platform-wallet-ffi/src/contact_persistence.rs +++ b/packages/rs-platform-wallet-ffi/src/contact_persistence.rs @@ -214,6 +214,46 @@ pub struct ContactIgnoredSenderFFI { pub is_ignored: bool, } +/// One identity's read position in DashPay's private contact request store +/// ([`ContactChangeSet::private_contact_requests_positions`]), for the +/// private contact request scan state callbacks on +/// [`PersistenceCallbacksExtension`](crate::persistence::PersistenceCallbacksExtension). +/// +/// Flat POD. The host stores one row per `(wallet_id, identity_id)`, +/// replacing the previous one, and hands it back unchanged at load: a lower +/// `position` than the stored one is a rewind, not a stale write. +/// +/// [`ContactChangeSet::private_contact_requests_positions`]: platform_wallet::changeset::ContactChangeSet::private_contact_requests_positions +#[repr(C)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct PrivateContactRequestsPositionFFI { + /// The wallet-owned identity reading the store. + pub identity_id: [u8; 32], + /// The store position the identity reads from next. + pub position: u64, + /// The fingerprint of the scanning keys `position` holds for. Opaque + /// to the host: store it and return it as it came. + pub key_fingerprint: [u8; 32], +} + +/// The shielded address `contact_id`'s tracked private request to +/// `owner_id` carries ([`ContactChangeSet::private_shielded_addresses`]). +/// +/// Flat POD. The host stores one row per `(wallet_id, owner_id, +/// contact_id)`, replacing the previous one. +/// +/// [`ContactChangeSet::private_shielded_addresses`]: platform_wallet::changeset::ContactChangeSet::private_shielded_addresses +#[repr(C)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct PrivateShieldedAddressFFI { + /// The wallet-owned identity the request is to. + pub owner_id: [u8; 32], + /// The contact who sent the request. + pub contact_id: [u8; 32], + /// The raw 43-byte Orchard payment address. + pub address: [u8; 43], +} + // Compile-time guards. Pin the expected layouts so any reshape on // the Rust side fails the cargo build before it can ship a dylib // the Swift side will mis-parse at runtime. diff --git a/packages/rs-platform-wallet-ffi/src/manager.rs b/packages/rs-platform-wallet-ffi/src/manager.rs index dd0675fca9f..222ebdc2422 100644 --- a/packages/rs-platform-wallet-ffi/src/manager.rs +++ b/packages/rs-platform-wallet-ffi/src/manager.rs @@ -8,12 +8,14 @@ use crate::event_handler::{ }; use crate::handle::*; use crate::persistence::{ - FFIPersister, FreeTrackedMasternodesFn, LoadAssetLockOperationIdFn, - LoadIdentityBalanceBlockTimeFn, LoadTrackedMasternodesFn, PersistAssetLockOperationIdFn, - PersistDpnsNameStatesFn, PersistIdentityBalanceBlockTimeFn, PersistTrackedMasternodesFn, - PersistWalletChangesetChainLockHeightFn, PersistWalletChangesetSweepsFn, - PersistWalletChangesetUtxoVerdictsFn, PersistWalletDashPayBackfillFn, PersistenceCallbacks, - PersistenceCallbacksExtension, PersistenceCapabilitiesFFI, PersistenceExtensionCallbacks, + FFIPersister, FreePrivateContactScanStateFn, FreeTrackedMasternodesFn, + LoadAssetLockOperationIdFn, LoadIdentityBalanceBlockTimeFn, LoadPrivateContactScanStateFn, + LoadTrackedMasternodesFn, PersistAssetLockOperationIdFn, PersistDpnsNameStatesFn, + PersistIdentityBalanceBlockTimeFn, PersistPrivateContactScanStateFn, + PersistTrackedMasternodesFn, PersistWalletChangesetChainLockHeightFn, + PersistWalletChangesetSweepsFn, PersistWalletChangesetUtxoVerdictsFn, + PersistWalletDashPayBackfillFn, PersistenceCallbacks, PersistenceCallbacksExtension, + PersistenceCapabilitiesFFI, PersistenceExtensionCallbacks, PLATFORM_WALLET_PERSISTENCE_CALLBACKS_EXTENSION_VERSION, }; use crate::runtime::runtime; @@ -290,6 +292,18 @@ unsafe fn persistence_extension_callbacks( on_load_asset_lock_operation_id_fn, LoadAssetLockOperationIdFn ), + persist_private_contact_scan_state: slot!( + on_persist_private_contact_scan_state_fn, + PersistPrivateContactScanStateFn + ), + load_private_contact_scan_state: slot!( + on_load_private_contact_scan_state_fn, + LoadPrivateContactScanStateFn + ), + load_private_contact_scan_state_free: slot!( + on_load_private_contact_scan_state_free_fn, + FreePrivateContactScanStateFn + ), } } @@ -867,6 +881,9 @@ pub unsafe extern "C" fn platform_wallet_manager_remove_wallet( #[cfg(test)] mod tests { use super::*; + use crate::contact_persistence::{ + ContactRequestRemovalFFI, PrivateContactRequestsPositionFFI, PrivateShieldedAddressFFI, + }; unsafe extern "C" fn begin_changeset(_context: *mut c_void, _wallet_id: *const u8) -> i32 { 0 @@ -1372,6 +1389,68 @@ mod tests { assert!(parsed.load_identity_balance_block_time.is_none()); } + #[test] + fn should_size_gate_private_contact_scan_state_slots_independently() { + #[allow(clippy::too_many_arguments)] + unsafe extern "C" fn persist( + _: *mut c_void, + _: *const u8, + _: *const PrivateContactRequestsPositionFFI, + _: usize, + _: *const PrivateShieldedAddressFFI, + _: usize, + _: *const ContactRequestRemovalFFI, + _: usize, + _: *const [u8; 32], + _: usize, + ) -> i32 { + 0 + } + unsafe extern "C" fn load( + _: *mut c_void, + _: *const u8, + _: *mut *const PrivateContactRequestsPositionFFI, + _: *mut usize, + _: *mut *const PrivateShieldedAddressFFI, + _: *mut usize, + ) -> i32 { + 0 + } + unsafe extern "C" fn free( + _: *mut c_void, + _: *const PrivateContactRequestsPositionFFI, + _: usize, + _: *const PrivateShieldedAddressFFI, + _: usize, + ) { + } + let mut ext = PersistenceCallbacksExtension { + on_persist_private_contact_scan_state_fn: Some(persist), + on_load_private_contact_scan_state_fn: Some(load), + on_load_private_contact_scan_state_free_fn: Some(free), + ..Default::default() + }; + let parsed = unsafe { persistence_extension_callbacks(&ext) }; + assert!(parsed.persist_private_contact_scan_state.is_some()); + assert!(parsed.load_private_contact_scan_state.is_some()); + assert!(parsed.load_private_contact_scan_state_free.is_some()); + ext.struct_size = std::mem::offset_of!( + PersistenceCallbacksExtension, + on_load_private_contact_scan_state_free_fn + ); + let parsed = unsafe { persistence_extension_callbacks(&ext) }; + assert!(parsed.load_private_contact_scan_state.is_some()); + assert!(parsed.load_private_contact_scan_state_free.is_none()); + ext.struct_size = std::mem::offset_of!( + PersistenceCallbacksExtension, + on_persist_private_contact_scan_state_fn + ); + let parsed = unsafe { persistence_extension_callbacks(&ext) }; + assert!(parsed.persist_private_contact_scan_state.is_none()); + assert!(parsed.load_private_contact_scan_state.is_none()); + assert!(parsed.load_private_contact_scan_state_free.is_none()); + } + #[test] fn should_size_gate_asset_lock_operation_id_slots_independently() { unsafe extern "C" fn persist( diff --git a/packages/rs-platform-wallet-ffi/src/persistence.rs b/packages/rs-platform-wallet-ffi/src/persistence.rs index eb5865fc361..f1b6636fce0 100644 --- a/packages/rs-platform-wallet-ffi/src/persistence.rs +++ b/packages/rs-platform-wallet-ffi/src/persistence.rs @@ -32,9 +32,10 @@ use platform_wallet::changeset::provider_key_account::{ }; use platform_wallet::changeset::{ AccountAddressPoolEntry, AccountRegistrationEntry, ClientStartState, ClientWalletStartState, - ListedCoreTxid, PersistenceCapabilities, PersistenceError, PersistenceErrorKind, - PlatformWalletChangeSet, PlatformWalletPersistence, ProviderKeyAccountEntry, - ProviderKeyExtendedPubKey, PERSISTENCE_CAPABILITIES_VERSION, + ContactChangeSet, ListedCoreTxid, PersistenceCapabilities, PersistenceError, + PersistenceErrorKind, PlatformWalletChangeSet, PlatformWalletPersistence, + PrivateContactRequestsPosition, ProviderKeyAccountEntry, ProviderKeyExtendedPubKey, + PERSISTENCE_CAPABILITIES_VERSION, }; use platform_wallet::wallet::asset_lock::restored_record::held_output_details; use platform_wallet::wallet::core::stored_coin::{reconcile_with_parent, StoredParentState}; @@ -53,7 +54,8 @@ use crate::asset_lock_persistence::{ build_asset_lock_entries, outpoint_to_bytes, AssetLockEntryFFI, }; use crate::contact_persistence::{ - free_contact_requests_ffi, ContactIgnoredSenderFFI, ContactRequestFFI, ContactRequestRemovalFFI, + free_contact_requests_ffi, ContactIgnoredSenderFFI, ContactRequestFFI, + ContactRequestRemovalFFI, PrivateContactRequestsPositionFFI, PrivateShieldedAddressFFI, }; use crate::core_address_types::{AddressPoolTypeTagFFI, CoreAddressEntryFFI, KeyTypeTagFFI}; use crate::core_wallet_types::{ @@ -161,6 +163,14 @@ pub const PLATFORM_WALLET_PERSISTENCE_CAPABILITY_TRACKED_MASTERNODES: u64 = 1 << pub const PLATFORM_WALLET_PERSISTENCE_CAPABILITY_CORE_SWEEP_REMOVAL: u64 = 1 << 11; pub const PLATFORM_WALLET_PERSISTENCE_CAPABILITY_DASHPAY_PAYMENTS: u64 = 1 << 12; pub const PLATFORM_WALLET_PERSISTENCE_CAPABILITY_ASSET_LOCK_OPERATION_IDS: u64 = 1 << 13; +/// The private contact request sync's scan state (each identity's read +/// position and its private contacts' shielded addresses) is persisted AND +/// restored across restarts. Requires the extension trio +/// `on_persist_private_contact_scan_state_fn` + +/// `on_load_private_contact_scan_state_fn` + +/// `on_load_private_contact_scan_state_free_fn`, the wallet-list restore +/// callbacks, and the host declaring the bit. +pub const PLATFORM_WALLET_PERSISTENCE_CAPABILITY_PRIVATE_CONTACT_REQUEST_SCAN_STATE: u64 = 1 << 14; /// Version of [`PersistenceCallbacksExtension`]. The extension is deliberately /// separate from [`PersistenceCallbacks`]: existing hosts pass the latter by @@ -323,6 +333,57 @@ pub type LoadAssetLockOperationIdFn = unsafe extern "C" fn( out_operation_id: *mut u8, ) -> i32; +/// Store a round's private contact request scan state for `wallet_id` +/// (see [`PLATFORM_WALLET_PERSISTENCE_CAPABILITY_PRIVATE_CONTACT_REQUEST_SCAN_STATE`]), +/// inside the round's begin/end bracket, after the contacts callback: +/// +/// - `positions`: replace the identity's stored read position. +/// - `addresses`: replace the stored address for `(owner_id, contact_id)`. +/// - `removed_addresses`: delete the stored address for `(owner_id, +/// contact_id)` (`ContactRequestRemovalFFI::contact_id` names the contact). +/// - `cleared_identities`: 32-byte ids of identities the round removed from +/// the wallet. Delete each one's position and every address it owns, so +/// an identity added back later reads the store from its start. +/// +/// Every pointer is null when its count is 0 and valid only for the +/// duration of the call. A host without the slot never has the state +/// written, and each launch reads the store from its start. +pub type PersistPrivateContactScanStateFn = unsafe extern "C" fn( + context: *mut c_void, + wallet_id: *const u8, + positions: *const PrivateContactRequestsPositionFFI, + positions_count: usize, + addresses: *const PrivateShieldedAddressFFI, + addresses_count: usize, + removed_addresses: *const ContactRequestRemovalFFI, + removed_addresses_count: usize, + cleared_identities: *const [u8; 32], + cleared_identities_count: usize, +) -> i32; + +/// Return every stored private contact request read position and shielded +/// address for `wallet_id`. The host allocates both arrays and keeps them +/// valid until Rust hands them back through +/// [`FreePrivateContactScanStateFn`]; a null array with a zero count is an +/// empty one. An unreadable store must return an error, not empty arrays. +pub type LoadPrivateContactScanStateFn = unsafe extern "C" fn( + context: *mut c_void, + wallet_id: *const u8, + out_positions: *mut *const PrivateContactRequestsPositionFFI, + out_positions_count: *mut usize, + out_addresses: *mut *const PrivateShieldedAddressFFI, + out_addresses_count: *mut usize, +) -> i32; + +/// Release the arrays a [`LoadPrivateContactScanStateFn`] returned. +pub type FreePrivateContactScanStateFn = unsafe extern "C" fn( + context: *mut c_void, + positions: *const PrivateContactRequestsPositionFFI, + positions_count: usize, + addresses: *const PrivateShieldedAddressFFI, + addresses_count: usize, +); + /// Size- and version-tagged additive persistence callbacks. /// /// `context` is the context in the accompanying [`PersistenceCallbacks`] @@ -489,6 +550,48 @@ pub struct PersistenceCallbacksExtension { out_operation_id: *mut u8, ) -> i32, >, + /// The private contact request scan state a round writes (see + /// [`PersistPrivateContactScanStateFn`]). Appended under the same + /// version for the same reason as the slots above. Wired together with + /// the load + free pair below: `PRIVATE_CONTACT_REQUEST_SCAN_STATE` is + /// attested only when all three are present (and declared). + #[allow(clippy::type_complexity)] + pub on_persist_private_contact_scan_state_fn: Option< + unsafe extern "C" fn( + context: *mut c_void, + wallet_id: *const u8, + positions: *const PrivateContactRequestsPositionFFI, + positions_count: usize, + addresses: *const PrivateShieldedAddressFFI, + addresses_count: usize, + removed_addresses: *const ContactRequestRemovalFFI, + removed_addresses_count: usize, + cleared_identities: *const [u8; 32], + cleared_identities_count: usize, + ) -> i32, + >, + /// Return the stored scan state for a wallet (see + /// [`LoadPrivateContactScanStateFn`]). + pub on_load_private_contact_scan_state_fn: Option< + unsafe extern "C" fn( + context: *mut c_void, + wallet_id: *const u8, + out_positions: *mut *const PrivateContactRequestsPositionFFI, + out_positions_count: *mut usize, + out_addresses: *mut *const PrivateShieldedAddressFFI, + out_addresses_count: *mut usize, + ) -> i32, + >, + /// Release the arrays `on_load_private_contact_scan_state_fn` returned. + pub on_load_private_contact_scan_state_free_fn: Option< + unsafe extern "C" fn( + context: *mut c_void, + positions: *const PrivateContactRequestsPositionFFI, + positions_count: usize, + addresses: *const PrivateShieldedAddressFFI, + addresses_count: usize, + ), + >, } impl Default for PersistenceCallbacksExtension { @@ -509,6 +612,9 @@ impl Default for PersistenceCallbacksExtension { on_persist_wallet_dashpay_backfill_fn: None, on_persist_asset_lock_operation_id_fn: None, on_load_asset_lock_operation_id_fn: None, + on_persist_private_contact_scan_state_fn: None, + on_load_private_contact_scan_state_fn: None, + on_load_private_contact_scan_state_free_fn: None, } } } @@ -530,6 +636,9 @@ pub struct PersistenceExtensionCallbacks { pub wallet_dashpay_backfill: Option, pub persist_asset_lock_operation_id: Option, pub load_asset_lock_operation_id: Option, + pub persist_private_contact_scan_state: Option, + pub load_private_contact_scan_state: Option, + pub load_private_contact_scan_state_free: Option, } /// Return value by which a persistence callback reports a **retryable** @@ -1685,6 +1794,25 @@ impl FFIPersister { if self.callbacks.on_persist_token_balances_fn.is_some() { capabilities = capabilities.union(PersistenceCapabilities::UNSIGNED_TOKEN_STORAGE); } + // The scan state is restored per wallet during the wallet-list load, + // so that load is required alongside the extension trio. + if wallet_restore + && self + .tracked_masternodes_callbacks + .persist_private_contact_scan_state + .is_some() + && self + .tracked_masternodes_callbacks + .load_private_contact_scan_state + .is_some() + && self + .tracked_masternodes_callbacks + .load_private_contact_scan_state_free + .is_some() + { + capabilities = + capabilities.union(PersistenceCapabilities::PRIVATE_CONTACT_REQUEST_SCAN_STATE); + } // The dashpay-payments slot is what the sweep's Failed flip rides // (`dashpay_payments_overlay` on the store round). A host that // never wired it — Android deliberately keeps payment recording @@ -2942,6 +3070,28 @@ impl PlatformWalletPersistence for FFIPersister { } } + // The private contact request scan state, after the contacts it was + // read with. Identities the round removed take theirs with them. + if let Some(cb) = self + .tracked_masternodes_callbacks + .persist_private_contact_scan_state + { + let rows = PrivateContactScanStateRows::new( + changeset.contacts.as_ref(), + changeset.identities.as_ref().map(|id_cs| &id_cs.removed), + ); + if !rows.is_empty() { + let rc = unsafe { rows.persist(cb, self.callbacks.context, &wallet_id) }; + if rc != 0 { + eprintln!( + "Private contact request scan state persistence callback returned error code {}", + rc + ); + outcome.record(rc); + } + } + } + // Send sync state updates. if let Some(ref addr_cs) = changeset.platform_addresses { if let Some(cb) = self.callbacks.on_persist_sync_state_fn { @@ -3480,6 +3630,27 @@ impl PlatformWalletPersistence for FFIPersister { &mut wallet_state.unused_asset_locks, )?; } + if let (Some(load), Some(free)) = ( + self.tracked_masternodes_callbacks + .load_private_contact_scan_state, + self.tracked_masternodes_callbacks + .load_private_contact_scan_state_free, + ) { + // Read only for a host that attests the state: the manager + // drops any other host's copy at load. + if self + .persistence_capabilities() + .contains(PersistenceCapabilities::PRIVATE_CONTACT_REQUEST_SCAN_STATE) + { + load_private_contact_scan_state( + load, + free, + self.callbacks.context, + &entry.wallet_id, + &mut wallet_state.identity_manager, + )?; + } + } if let Some(cb) = self.load_identity_balance_block_time_callback { for identities in wallet_state.identity_manager.wallet_identities.values_mut() { for managed in identities.values_mut() { @@ -6243,6 +6414,202 @@ fn load_asset_lock_operation_ids( Ok(()) } +/// One round's private contact request scan state in its FFI shape (see +/// [`PersistPrivateContactScanStateFn`]). +struct PrivateContactScanStateRows { + positions: Vec, + addresses: Vec, + removed_addresses: Vec, + cleared_identities: Vec<[u8; 32]>, +} + +impl PrivateContactScanStateRows { + fn new( + contacts: Option<&ContactChangeSet>, + removed_identities: Option<&BTreeSet>, + ) -> Self { + let mut rows = Self { + positions: Vec::new(), + addresses: Vec::new(), + removed_addresses: Vec::new(), + cleared_identities: removed_identities + .into_iter() + .flatten() + .map(|id| id.to_buffer()) + .collect(), + }; + if let Some(contacts) = contacts { + rows.positions = contacts + .private_contact_requests_positions + .iter() + .map( + |(identity_id, position)| PrivateContactRequestsPositionFFI { + identity_id: identity_id.to_buffer(), + position: position.position, + key_fingerprint: position.key_fingerprint, + }, + ) + .collect(); + rows.addresses = contacts + .private_shielded_addresses + .iter() + .map( + |((owner_id, contact_id), address)| PrivateShieldedAddressFFI { + owner_id: owner_id.to_buffer(), + contact_id: contact_id.to_buffer(), + address: *address, + }, + ) + .collect(); + rows.removed_addresses = contacts + .removed_private_shielded_addresses + .iter() + .map(|(owner_id, contact_id)| ContactRequestRemovalFFI { + owner_id: owner_id.to_buffer(), + contact_id: contact_id.to_buffer(), + }) + .collect(); + } + rows + } + + fn is_empty(&self) -> bool { + self.positions.is_empty() + && self.addresses.is_empty() + && self.removed_addresses.is_empty() + && self.cleared_identities.is_empty() + } + + /// # Safety + /// `callback` must be a valid [`PersistPrivateContactScanStateFn`] for + /// `context`. + unsafe fn persist( + &self, + callback: PersistPrivateContactScanStateFn, + context: *mut c_void, + wallet_id: &WalletId, + ) -> i32 { + fn ptr(rows: &[T]) -> *const T { + if rows.is_empty() { + std::ptr::null() + } else { + rows.as_ptr() + } + } + callback( + context, + wallet_id.as_ptr(), + ptr(&self.positions), + self.positions.len(), + ptr(&self.addresses), + self.addresses.len(), + ptr(&self.removed_addresses), + self.removed_addresses.len(), + ptr(&self.cleared_identities), + self.cleared_identities.len(), + ) + } +} + +/// Restore a wallet's private contact request scan state onto the identities +/// its load restored. Rows for an identity the load did not restore are +/// passed over. An error from the host, or a missing buffer, fails the load. +fn load_private_contact_scan_state( + load: LoadPrivateContactScanStateFn, + free: FreePrivateContactScanStateFn, + context: *mut c_void, + wallet_id: &WalletId, + identity_manager: &mut IdentityManagerStartState, +) -> Result<(), PersistenceError> { + struct Guard { + free: FreePrivateContactScanStateFn, + context: *mut c_void, + positions: *const PrivateContactRequestsPositionFFI, + positions_count: usize, + addresses: *const PrivateShieldedAddressFFI, + addresses_count: usize, + } + impl Drop for Guard { + fn drop(&mut self) { + if self.positions.is_null() && self.addresses.is_null() { + return; + } + unsafe { + (self.free)( + self.context, + self.positions, + self.positions_count, + self.addresses, + self.addresses_count, + ) + }; + } + } + + let mut guard = Guard { + free, + context, + positions: std::ptr::null(), + positions_count: 0, + addresses: std::ptr::null(), + addresses_count: 0, + }; + let rc = unsafe { + load( + context, + wallet_id.as_ptr(), + &mut guard.positions, + &mut guard.positions_count, + &mut guard.addresses, + &mut guard.addresses_count, + ) + }; + if rc != 0 { + return Err(persist_callback_error( + rc, + format!("on_load_private_contact_scan_state_fn returned error code {rc}"), + )); + } + if (guard.positions.is_null() && guard.positions_count != 0) + || (guard.addresses.is_null() && guard.addresses_count != 0) + { + return Err(PersistenceError::backend( + "private contact request scan state: missing row buffer", + )); + } + let positions: &[PrivateContactRequestsPositionFFI] = if guard.positions_count == 0 { + &[] + } else { + unsafe { slice::from_raw_parts(guard.positions, guard.positions_count) } + }; + let addresses: &[PrivateShieldedAddressFFI] = if guard.addresses_count == 0 { + &[] + } else { + unsafe { slice::from_raw_parts(guard.addresses, guard.addresses_count) } + }; + + let mut identities: BTreeMap = identity_manager + .wallet_identities + .values_mut() + .flat_map(BTreeMap::values_mut) + .map(|managed| (managed.id(), managed)) + .collect(); + for row in positions { + if let Some(managed) = identities.get_mut(&Identifier::new(row.identity_id)) { + managed.apply_private_contact_requests_position(PrivateContactRequestsPosition { + position: row.position, + key_fingerprint: row.key_fingerprint, + }); + } + } + for row in addresses { + if let Some(managed) = identities.get_mut(&Identifier::new(row.owner_id)) { + managed.apply_private_shielded_address(Identifier::new(row.contact_id), row.address); + } + } + Ok(()) +} + /// Rebuild tracked locks from the host's frozen `AssetLockEntryFFI` rows. /// Transactions use consensus encoding, proofs use DPP bincode, and outpoints /// are raw txid bytes followed by a little-endian vout. Malformed or duplicate @@ -7678,6 +8045,389 @@ mod tests { ); } + /// What a scan-state host received, by callback. + #[derive(Default)] + struct ScanStateHost { + positions: std::sync::Mutex>, + addresses: std::sync::Mutex>, + removed: std::sync::Mutex>, + cleared: std::sync::Mutex>, + /// What `load_scan_state` answers: 0 rows as stored, 1 an error, + /// 2 a count without a buffer. + load_mode: u8, + loads: std::sync::atomic::AtomicUsize, + frees: std::sync::atomic::AtomicUsize, + } + + #[allow(clippy::too_many_arguments)] + unsafe extern "C" fn persist_scan_state( + ctx: *mut c_void, + wallet: *const u8, + positions: *const PrivateContactRequestsPositionFFI, + positions_count: usize, + addresses: *const PrivateShieldedAddressFFI, + addresses_count: usize, + removed: *const ContactRequestRemovalFFI, + removed_count: usize, + cleared: *const [u8; 32], + cleared_count: usize, + ) -> i32 { + unsafe fn rows<'a, T>(ptr: *const T, count: usize) -> &'a [T] { + if count == 0 { + assert!(ptr.is_null(), "an empty array crosses as null"); + &[] + } else { + std::slice::from_raw_parts(ptr, count) + } + } + assert_eq!(std::slice::from_raw_parts(wallet, 32), &[42; 32]); + let host = &*(ctx as *const ScanStateHost); + host.positions + .lock() + .unwrap() + .extend_from_slice(rows(positions, positions_count)); + host.addresses + .lock() + .unwrap() + .extend_from_slice(rows(addresses, addresses_count)); + host.removed.lock().unwrap().extend( + rows(removed, removed_count) + .iter() + .map(|row| (row.owner_id, row.contact_id)), + ); + host.cleared + .lock() + .unwrap() + .extend_from_slice(rows(cleared, cleared_count)); + 0 + } + + unsafe extern "C" fn load_scan_state( + ctx: *mut c_void, + wallet: *const u8, + out_positions: *mut *const PrivateContactRequestsPositionFFI, + out_positions_count: *mut usize, + out_addresses: *mut *const PrivateShieldedAddressFFI, + out_addresses_count: *mut usize, + ) -> i32 { + assert_eq!(std::slice::from_raw_parts(wallet, 32), &[42; 32]); + let host = &*(ctx as *const ScanStateHost); + host.loads.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + match host.load_mode { + 1 => return -1, + 2 => { + *out_positions = std::ptr::null(); + *out_positions_count = 1; + return 0; + } + _ => {} + } + let positions = host.positions.lock().unwrap().clone(); + let addresses = host.addresses.lock().unwrap().clone(); + *out_positions_count = positions.len(); + *out_positions = Box::into_raw(positions.into_boxed_slice()).cast(); + *out_addresses_count = addresses.len(); + *out_addresses = Box::into_raw(addresses.into_boxed_slice()).cast(); + 0 + } + + unsafe extern "C" fn free_scan_state( + ctx: *mut c_void, + positions: *const PrivateContactRequestsPositionFFI, + positions_count: usize, + addresses: *const PrivateShieldedAddressFFI, + addresses_count: usize, + ) { + let host = &*(ctx as *const ScanStateHost); + host.frees.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + if !positions.is_null() { + drop(Box::from_raw(std::ptr::slice_from_raw_parts_mut( + positions.cast_mut(), + positions_count, + ))); + } + if !addresses.is_null() { + drop(Box::from_raw(std::ptr::slice_from_raw_parts_mut( + addresses.cast_mut(), + addresses_count, + ))); + } + } + + unsafe extern "C" fn load_one_identity( + _: *mut c_void, + entries: *mut *const WalletRestoreEntryFFI, + count: *mut usize, + ) -> i32 { + let mut identity: IdentityRestoreEntryFFI = std::mem::zeroed(); + identity.identity_id = [7; 32]; + *entries = Box::into_raw(Box::new(WalletRestoreEntryFFI { + wallet_id: [42; 32], + identities: Box::into_raw(Box::new(identity)), + identities_count: 1, + ..Default::default() + })); + *count = 1; + 0 + } + + unsafe extern "C" fn free_one_identity( + _: *mut c_void, + entries: *const WalletRestoreEntryFFI, + _: usize, + ) { + let row = Box::from_raw(entries.cast_mut()); + drop(Box::from_raw(row.identities.cast_mut())); + } + + fn scan_state_persister( + host: &ScanStateHost, + declared: PersistenceCapabilities, + extensions: PersistenceExtensionCallbacks, + ) -> FFIPersister { + FFIPersister::new_with_persistence_capabilities_and_extensions( + PersistenceCallbacks { + context: (host as *const ScanStateHost).cast_mut().cast(), + on_changeset_begin_fn: Some(noop_begin), + on_changeset_end_fn: Some(noop_end), + on_load_wallet_list_fn: Some(load_one_identity), + on_load_wallet_list_free_fn: Some(free_one_identity), + ..Default::default() + }, + declared, + extensions, + ) + } + + fn scan_state_trio() -> PersistenceExtensionCallbacks { + PersistenceExtensionCallbacks { + persist_private_contact_scan_state: Some(persist_scan_state), + load_private_contact_scan_state: Some(load_scan_state), + load_private_contact_scan_state_free: Some(free_scan_state), + ..Default::default() + } + } + + /// A round carrying a read position, an address, an address removal and + /// a removed identity reaches the host as those four arrays. + #[test] + fn should_persist_the_private_contact_request_scan_state_of_a_round() { + use platform_wallet::changeset::IdentityChangeSet; + + let host = ScanStateHost::default(); + let persister = scan_state_persister( + &host, + PersistenceCapabilities::ATOMIC_CHANGESETS + .union(PersistenceCapabilities::PRIVATE_CONTACT_REQUEST_SCAN_STATE), + scan_state_trio(), + ); + assert!(persister + .persistence_capabilities() + .contains(PersistenceCapabilities::PRIVATE_CONTACT_REQUEST_SCAN_STATE)); + + let owner = Identifier::from([7; 32]); + let mut contacts = ContactChangeSet::default(); + contacts.private_contact_requests_positions.insert( + owner, + PrivateContactRequestsPosition { + position: 12, + key_fingerprint: [3; 32], + }, + ); + contacts + .private_shielded_addresses + .insert((owner, Identifier::from([5; 32])), [9; 43]); + contacts + .removed_private_shielded_addresses + .insert((owner, Identifier::from([6; 32]))); + let identities = IdentityChangeSet { + removed: BTreeSet::from([Identifier::from([8; 32])]), + ..Default::default() + }; + persister + .store( + [42; 32], + PlatformWalletChangeSet { + contacts: Some(contacts), + identities: Some(identities), + ..Default::default() + }, + ) + .expect("store the scan state"); + + assert_eq!( + *host.positions.lock().unwrap(), + vec![PrivateContactRequestsPositionFFI { + identity_id: [7; 32], + position: 12, + key_fingerprint: [3; 32], + }] + ); + assert_eq!( + *host.addresses.lock().unwrap(), + vec![PrivateShieldedAddressFFI { + owner_id: [7; 32], + contact_id: [5; 32], + address: [9; 43], + }] + ); + assert_eq!(*host.removed.lock().unwrap(), vec![([7; 32], [6; 32])]); + assert_eq!(*host.cleared.lock().unwrap(), vec![[8; 32]]); + } + + /// A load hands the stored position and address back to the identity + /// they belong to, passes over rows of an identity it did not restore, + /// returns the host's arrays, and fails on a host error or a count + /// without a buffer. + #[test] + fn should_restore_the_private_contact_request_scan_state_onto_its_identity() { + use platform_wallet::wallet::identity::ContactRequest; + + let declared = PersistenceCapabilities::PRIVATE_CONTACT_REQUEST_SCAN_STATE; + let host = ScanStateHost::default(); + host.positions.lock().unwrap().extend([ + PrivateContactRequestsPositionFFI { + identity_id: [7; 32], + position: 12, + key_fingerprint: [3; 32], + }, + PrivateContactRequestsPositionFFI { + identity_id: [8; 32], + position: 99, + key_fingerprint: [4; 32], + }, + ]); + host.addresses.lock().unwrap().extend([ + PrivateShieldedAddressFFI { + owner_id: [7; 32], + contact_id: [5; 32], + address: [9; 43], + }, + PrivateShieldedAddressFFI { + owner_id: [8; 32], + contact_id: [5; 32], + address: [1; 43], + }, + ]); + let mut restored = scan_state_persister(&host, declared, scan_state_trio()) + .load() + .expect("restore the scan state"); + assert_eq!(host.loads.load(std::sync::atomic::Ordering::SeqCst), 1); + assert_eq!(host.frees.load(std::sync::atomic::Ordering::SeqCst), 1); + let wallet = restored.wallets.get_mut(&[42; 32]).expect("wallet"); + let identities = &mut wallet.identity_manager.wallet_identities; + assert_eq!(identities[&[42; 32]].len(), 1, "no identity is invented"); + let managed = identities + .get_mut(&[42; 32]) + .and_then(|identities| identities.get_mut(&0)) + .expect("identity"); + assert_eq!(managed.dashpay().private_contact_requests_position(), 12); + // The address shows once the contact's request to us came privately + // (a private request carries no core height). + let owner = Identifier::from([7; 32]); + let contact = Identifier::from([5; 32]); + managed.apply_incoming_contact_request(ContactRequest::new( + contact, + owner, + 0, + 0, + 0, + vec![0; 96], + 0, + 0, + )); + assert_eq!( + managed + .dashpay() + .private_contact(&contact) + .and_then(|private| private.shielded_address), + Some([9; 43]) + ); + + for (mode, what) in [(1, "a host error"), (2, "a count without a buffer")] { + let failing = ScanStateHost { + load_mode: mode, + ..Default::default() + }; + assert!( + scan_state_persister(&failing, declared, scan_state_trio()) + .load() + .is_err(), + "{what} fails the load" + ); + } + } + + /// A host that does not declare the capability, or wires only part of + /// the trio, is not attested, has nothing read at load, and still + /// stores and loads everything else. A host without the slots stores a + /// round carrying scan state without error. + #[test] + fn should_leave_hosts_without_the_scan_state_capability_unchanged() { + let host = ScanStateHost::default(); + host.positions + .lock() + .unwrap() + .push(PrivateContactRequestsPositionFFI { + identity_id: [7; 32], + position: 12, + key_fingerprint: [3; 32], + }); + let undeclared = + scan_state_persister(&host, PersistenceCapabilities::NONE, scan_state_trio()); + let half_wired = scan_state_persister( + &host, + PersistenceCapabilities::PRIVATE_CONTACT_REQUEST_SCAN_STATE, + PersistenceExtensionCallbacks { + persist_private_contact_scan_state: Some(persist_scan_state), + load_private_contact_scan_state: Some(load_scan_state), + ..Default::default() + }, + ); + let unwired = scan_state_persister( + &host, + PersistenceCapabilities::PRIVATE_CONTACT_REQUEST_SCAN_STATE, + PersistenceExtensionCallbacks::default(), + ); + for persister in [&undeclared, &half_wired, &unwired] { + assert!(!persister + .persistence_capabilities() + .contains(PersistenceCapabilities::PRIVATE_CONTACT_REQUEST_SCAN_STATE)); + let restored = persister.load().expect("the rest still restores"); + assert_eq!( + restored.wallets[&[42; 32]] + .identity_manager + .wallet_identities[&[42; 32]][&0] + .dashpay() + .private_contact_requests_position(), + 0 + ); + } + assert_eq!( + host.loads.load(std::sync::atomic::Ordering::SeqCst), + 0, + "nothing is read from a host that does not attest the state" + ); + + let mut contacts = ContactChangeSet::default(); + contacts.private_contact_requests_positions.insert( + Identifier::from([7; 32]), + PrivateContactRequestsPosition { + position: 1, + key_fingerprint: [0; 32], + }, + ); + unwired + .store( + [42; 32], + PlatformWalletChangeSet { + contacts: Some(contacts), + ..Default::default() + }, + ) + .expect("a host without the slots stores the round"); + } + #[test] fn should_persist_balance_watermarks_only_for_the_owning_wallet() { use platform_wallet::changeset::{IdentityChangeSet, IdentityEntry}; @@ -10268,6 +11018,36 @@ mod tests { PersistenceCallbacksExtension, on_load_asset_lock_operation_id_fn ) + std::mem::size_of::>(), + std::mem::offset_of!( + PersistenceCallbacksExtension, + on_persist_private_contact_scan_state_fn + ) + ); + assert_eq!( + std::mem::offset_of!( + PersistenceCallbacksExtension, + on_persist_private_contact_scan_state_fn + ) + std::mem::size_of::>(), + std::mem::offset_of!( + PersistenceCallbacksExtension, + on_load_private_contact_scan_state_fn + ) + ); + assert_eq!( + std::mem::offset_of!( + PersistenceCallbacksExtension, + on_load_private_contact_scan_state_fn + ) + std::mem::size_of::>(), + std::mem::offset_of!( + PersistenceCallbacksExtension, + on_load_private_contact_scan_state_free_fn + ) + ); + assert_eq!( + std::mem::offset_of!( + PersistenceCallbacksExtension, + on_load_private_contact_scan_state_free_fn + ) + std::mem::size_of::>(), std::mem::size_of::() ); assert_eq!( @@ -10330,6 +11110,10 @@ mod tests { PLATFORM_WALLET_PERSISTENCE_CAPABILITY_ASSET_LOCK_OPERATION_IDS, PersistenceCapabilities::ASSET_LOCK_OPERATION_IDS.bits() ); + assert_eq!( + PLATFORM_WALLET_PERSISTENCE_CAPABILITY_PRIVATE_CONTACT_REQUEST_SCAN_STATE, + PersistenceCapabilities::PRIVATE_CONTACT_REQUEST_SCAN_STATE.bits() + ); assert_eq!( PLATFORM_WALLET_PERSISTENCE_CAPABILITY_ACCOUNT_ADDRESS_POOLS, PLATFORM_WALLET_PERSISTENCE_CAPABILITY_ASSET_LOCK_FUNDING_INDICES diff --git a/packages/swift-sdk/SCHEMA_RELEASES.md b/packages/swift-sdk/SCHEMA_RELEASES.md index d9b6170b7c7..bc9c7f29453 100644 --- a/packages/swift-sdk/SCHEMA_RELEASES.md +++ b/packages/swift-sdk/SCHEMA_RELEASES.md @@ -30,7 +30,8 @@ discovering them afterwards as failed opens. The route decision is logged as `store_migration_route` with the validated `source_version`, `source_checksum` and one of `new-store`, `accepted-v1-to-v3`, `historical-v2-to-v3`, `previous-live-v2-current-shape`, -`unsupported-v2`, `labelled-current-v3`, `ordinary-current-plan` or, from the +`unsupported-v2`, `published-v3-to-v4`, `published-v4-to-v5`, +`labelled-current-v5`, `ordinary-current-plan` or, from the bridge, `legacy-v1-bridge-to-v3`. Resolving a frozen schema's identity builds a temporary store, so it is memoized per process and consulted only where a label is undecidable without it (`1.0.0`, `2.0.0`); a `3.0.0` label takes the diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashModelContainer.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashModelContainer.swift index ba455874d35..b95aa685274 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashModelContainer.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/DashModelContainer.swift @@ -94,13 +94,15 @@ public enum DashModelContainer { PersistentInvitation.self, PersistentMasternode.self, PersistentTrackedMasternode.self, - PersistentIdentityBalanceMetadata.self + PersistentIdentityBalanceMetadata.self, + PersistentPrivateContactRequestsPosition.self, + PersistentPrivateShieldedAddress.self ] } /// Create the schema for all Dash Platform models public static var schema: Schema { - Schema(versionedSchema: DashSchemaV4.self) + Schema(versionedSchema: DashSchemaV5.self) } /// Create a persistent model container for storing data. @@ -172,7 +174,7 @@ public enum DashModelContainer { bridgeLegacyStore: Bool = true ) throws -> ModelContainer { SDKLogger.event("store_open_started", category: .persistence, - fields: ["target_version": .publicText("4.0.0")]) + fields: ["target_version": .publicText("5.0.0")]) do { let container: ModelContainer if bridgeLegacyStore { @@ -185,7 +187,7 @@ public enum DashModelContainer { configurations: [configuration]) } SDKLogger.event("store_open_succeeded", category: .persistence, - fields: ["target_version": .publicText("4.0.0")]) + fields: ["target_version": .publicText("5.0.0")]) return container } catch { logMigrationFailure(error) @@ -201,7 +203,7 @@ public enum DashModelContainer { let domain = systemDomains.contains(nsError.domain) ? nsError.domain : String(reflecting: type(of: error)) SDKLogger.event("store_open_failed", category: .persistence, severity: .error, fields: ["error_domain": .publicText(domain), "error_code": .integer(Int64(nsError.code)), - "target_version": .publicText("4.0.0")]) + "target_version": .publicText("5.0.0")]) } /// Select by the complete stored model identity, after journal recovery. @@ -218,7 +220,7 @@ public enum DashModelContainer { guard ObjectIdentifier(defaultPlan) == ObjectIdentifier(DashMigrationPlan.self) else { return defaultPlan } guard FileManager.default.fileExists(atPath: url.path) else { SDKLogger.event("store_migration_route", category: .persistence, fields: [ - "route": .publicText("new-store"), "target_version": .publicText("4.0.0")]) + "route": .publicText("new-store"), "target_version": .publicText("5.0.0")]) return defaultPlan } let metadata = try NSPersistentStoreCoordinator.metadataForPersistentStore(type: .sqlite, at: url) @@ -236,7 +238,7 @@ public enum DashModelContainer { func logRoute(_ route: String) { SDKLogger.event("store_migration_route", category: .persistence, fields: [ "source_version": .publicText(safeVersions), "source_checksum": .publicText(safeChecksum), - "route": .publicText(route), "target_version": .publicText("4.0.0")]) + "route": .publicText(route), "target_version": .publicText("5.0.0")]) } func matches(_ type: any VersionedSchema.Type) throws -> Bool { let expected = try identity(type) @@ -268,7 +270,9 @@ public enum DashModelContainer { // `source_checksum` above already identifies the exact graph. logRoute("published-v3-to-v4") } else if versions == ["4.0.0"] { - logRoute("labelled-current-v4") + logRoute("published-v4-to-v5") + } else if versions == ["5.0.0"] { + logRoute("labelled-current-v5") } else { logRoute("ordinary-current-plan") } @@ -289,13 +293,14 @@ public enum DashModelContainer { /// SwiftData migration plan for Dash Platform model updates public enum DashMigrationPlan: SchemaMigrationPlan { public static var schemas: [any VersionedSchema.Type] { - [DashSchemaV2.self, DashSchemaV3.self, DashSchemaV4.self] + [DashSchemaV2.self, DashSchemaV3.self, DashSchemaV4.self, DashSchemaV5.self] } public static var stages: [MigrationStage] { [ .lightweight(fromVersion: DashSchemaV2.self, toVersion: DashSchemaV3.self), - .lightweight(fromVersion: DashSchemaV3.self, toVersion: DashSchemaV4.self) + .lightweight(fromVersion: DashSchemaV3.self, toVersion: DashSchemaV4.self), + .lightweight(fromVersion: DashSchemaV4.self, toVersion: DashSchemaV5.self) ] } } @@ -303,10 +308,13 @@ public enum DashMigrationPlan: SchemaMigrationPlan { /// Separate compatibility route: V1 has fields missing from historical V2. /// Never insert V2 between this baseline and the current schema. enum DashAcceptedV1MigrationPlan: SchemaMigrationPlan { - static var schemas: [any VersionedSchema.Type] { [DashSchemaV1.self, DashSchemaV3.self, DashSchemaV4.self] } + static var schemas: [any VersionedSchema.Type] { + [DashSchemaV1.self, DashSchemaV3.self, DashSchemaV4.self, DashSchemaV5.self] + } static var stages: [MigrationStage] { [.lightweight(fromVersion: DashSchemaV1.self, toVersion: DashSchemaV3.self), - .lightweight(fromVersion: DashSchemaV3.self, toVersion: DashSchemaV4.self)] + .lightweight(fromVersion: DashSchemaV3.self, toVersion: DashSchemaV4.self), + .lightweight(fromVersion: DashSchemaV4.self, toVersion: DashSchemaV5.self)] } } @@ -382,8 +390,15 @@ public enum DashSchemaV3: VersionedSchema { public static var models: [any PersistentModel.Type] { DashSchemaSnapshotV3.models } } -/// Current schema: optional asset-lock operation IDs correlate drain recovery. +/// Published V4 (optional asset-lock operation IDs). Its complete model graph is frozen. public enum DashSchemaV4: VersionedSchema { public static var versionIdentifier: Schema.Version { Schema.Version(4, 0, 0) } + public static var models: [any PersistentModel.Type] { DashSchemaSnapshotV4.models } +} + +/// Current schema: the private contact request sync's scan state (each +/// identity's read position and its private contacts' shielded addresses). +public enum DashSchemaV5: VersionedSchema { + public static var versionIdentifier: Schema.Version { Schema.Version(5, 0, 0) } public static var models: [any PersistentModel.Type] { DashModelContainer.modelTypes } } diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentPrivateContactRequestsPosition.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentPrivateContactRequestsPosition.swift new file mode 100644 index 00000000000..9227c0c9d7b --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentPrivateContactRequestsPosition.swift @@ -0,0 +1,28 @@ +import Foundation +import SwiftData + +/// Where an identity reads DashPay's private contact request store from +/// next, so a relaunch resumes the private contact request sync instead of +/// reading the store from its start. Written in the same changeset round as +/// the contact requests read before it. +@Model +public final class PersistentPrivateContactRequestsPosition { + #Unique([\.networkRaw, \.walletId, \.identityId]) + public var networkRaw: UInt32 + public var walletId: Data + public var identityId: Data + // Bit pattern preserves the full unsigned FFI domain in a SQLite integer. + public var position: Int64 + /// The fingerprint of the scanning keys `position` holds for; opaque + /// here, handed back to the native wallet as it came. + public var keyFingerprint: Data + + public init(networkRaw: UInt32, walletId: Data, identityId: Data, + position: UInt64, keyFingerprint: Data) { + self.networkRaw = networkRaw + self.walletId = walletId + self.identityId = identityId + self.position = Int64(bitPattern: position) + self.keyFingerprint = keyFingerprint + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentPrivateShieldedAddress.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentPrivateShieldedAddress.swift new file mode 100644 index 00000000000..6468811ed80 --- /dev/null +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentPrivateShieldedAddress.swift @@ -0,0 +1,26 @@ +import Foundation +import SwiftData + +/// The shielded address a private contact's tracked request to one of the +/// wallet's identities carries, kept so a relaunch has it without reading +/// the private contact request store again. +@Model +public final class PersistentPrivateShieldedAddress { + #Unique([\.networkRaw, \.walletId, \.ownerId, \.contactId]) + public var networkRaw: UInt32 + public var walletId: Data + /// The wallet-owned identity the request is to. + public var ownerId: Data + /// The contact who sent the request. + public var contactId: Data + /// The raw 43-byte Orchard payment address. + public var address: Data + + public init(networkRaw: UInt32, walletId: Data, ownerId: Data, contactId: Data, address: Data) { + self.networkRaw = networkRaw + self.walletId = walletId + self.ownerId = ownerId + self.contactId = contactId + self.address = address + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift index 63b20689d2c..cf24ecf0968 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift @@ -109,6 +109,14 @@ public struct PlatformWalletPersistenceCapabilities: Equatable, Sendable { public static let dashpayPayments: UInt64 = 1 << 12 /// Durable operation UUIDs, including consumed asset-lock tombstones. public static let assetLockOperationIds: UInt64 = 1 << 13 + /// The private contact request sync's scan state (each identity's read + /// position in DashPay's private contact request store, and its private + /// contacts' shielded addresses) is stored on its round and restored at + /// load, so a relaunch resumes the sync where it stopped. Mirrors + /// `PersistenceCapabilities::PRIVATE_CONTACT_REQUEST_SCAN_STATE`; Rust + /// only honours the declaration when the extension carries the three + /// scan-state callbacks. + public static let privateContactRequestScanState: UInt64 = 1 << 14 public let version: UInt32 public let bits: UInt64 diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift index f1d499682d7..d5c0519d897 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift @@ -3257,6 +3257,7 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { | PlatformWalletPersistenceCapabilities.coreSweepRemoval | PlatformWalletPersistenceCapabilities.dashpayPayments | PlatformWalletPersistenceCapabilities.assetLockOperationIds + | PlatformWalletPersistenceCapabilities.privateContactRequestScanState ) } @@ -3296,6 +3297,10 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { extensionCallbacks.on_load_identity_balance_block_time_fn = loadIdentityBalanceBlockTimeCallback extensionCallbacks.on_persist_asset_lock_operation_id_fn = persistAssetLockOperationIdCallback extensionCallbacks.on_load_asset_lock_operation_id_fn = loadAssetLockOperationIdCallback + extensionCallbacks.on_persist_private_contact_scan_state_fn = persistPrivateContactScanStateCallback + extensionCallbacks.on_load_private_contact_scan_state_fn = loadPrivateContactScanStateCallback + extensionCallbacks.on_load_private_contact_scan_state_free_fn = + loadPrivateContactScanStateFreeCallback return extensionCallbacks } @@ -5114,6 +5119,133 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { } } + // MARK: - Private contact request scan state (additive persistence extension) + + /// One identity's read position in DashPay's private contact request store. + struct PrivateContactRequestsPositionRow: Equatable { + let identityId: Data + let position: UInt64 + /// Opaque to this side: stored and handed back as it came. + let keyFingerprint: Data + } + + /// The shielded address `contactId`'s private request to `ownerId` carries. + struct PrivateShieldedAddressRow: Equatable { + let ownerId: Data + let contactId: Data + let address: Data + } + + /// The `(owner, contact)` pair of a shielded address the round removed. + struct PrivateShieldedAddressKey: Equatable { + let ownerId: Data + let contactId: Data + } + + /// The network the scan state of `walletId` is stored under, as for the + /// identity rows it belongs with. + private func scanStateNetworkRaw(walletId: Data) -> UInt32 { + (self.network ?? walletNetwork(walletId: walletId) ?? .testnet).rawValue + } + + /// Apply a round's scan state. Identities the round removed lose theirs + /// first, so a new position or address in the same round stands. + func persistPrivateContactScanState( + walletId: Data, + positions: [PrivateContactRequestsPositionRow], + addresses: [PrivateShieldedAddressRow], + removedAddresses: [PrivateShieldedAddressKey], + clearedIdentities: [Data] + ) throws { + try onQueue { + guard inChangeset else { + throw PlatformWalletError.walletOperation( + "Private contact request scan state requires a changeset") + } + let networkRaw = scanStateNetworkRaw(walletId: walletId) + for identityId in clearedIdentities { + for row in try backgroundContext.fetch(FetchDescriptor( + predicate: #Predicate { + $0.networkRaw == networkRaw && $0.walletId == walletId && $0.identityId == identityId + })) { + backgroundContext.delete(row) + } + for row in try backgroundContext.fetch(FetchDescriptor( + predicate: #Predicate { + $0.networkRaw == networkRaw && $0.walletId == walletId && $0.ownerId == identityId + })) { + backgroundContext.delete(row) + } + } + for row in positions { + let identityId = row.identityId + let existing = try backgroundContext.fetch(FetchDescriptor( + predicate: #Predicate { + $0.networkRaw == networkRaw && $0.walletId == walletId && $0.identityId == identityId + })).first + if let existing { + existing.position = Int64(bitPattern: row.position) + existing.keyFingerprint = row.keyFingerprint + } else { + backgroundContext.insert(PersistentPrivateContactRequestsPosition( + networkRaw: networkRaw, walletId: walletId, identityId: identityId, + position: row.position, keyFingerprint: row.keyFingerprint)) + } + } + for row in addresses { + let ownerId = row.ownerId + let contactId = row.contactId + let existing = try backgroundContext.fetch(FetchDescriptor( + predicate: #Predicate { + $0.networkRaw == networkRaw && $0.walletId == walletId + && $0.ownerId == ownerId && $0.contactId == contactId + })).first + if let existing { + existing.address = row.address + } else { + backgroundContext.insert(PersistentPrivateShieldedAddress( + networkRaw: networkRaw, walletId: walletId, ownerId: ownerId, + contactId: contactId, address: row.address)) + } + } + for key in removedAddresses { + let ownerId = key.ownerId + let contactId = key.contactId + for row in try backgroundContext.fetch(FetchDescriptor( + predicate: #Predicate { + $0.networkRaw == networkRaw && $0.walletId == walletId + && $0.ownerId == ownerId && $0.contactId == contactId + })) { + backgroundContext.delete(row) + } + } + // endChangeset performs the atomic save with the round's contacts. + } + } + + /// The stored scan state of `walletId`. Throws when the store cannot be + /// read, so the load fails rather than reading the store from its start. + func loadPrivateContactScanState(walletId: Data) throws + -> (positions: [PrivateContactRequestsPositionRow], addresses: [PrivateShieldedAddressRow]) { + try onQueue { + let networkRaw = scanStateNetworkRaw(walletId: walletId) + let positions = try backgroundContext.fetch(FetchDescriptor( + predicate: #Predicate { $0.networkRaw == networkRaw && $0.walletId == walletId })) + let addresses = try backgroundContext.fetch(FetchDescriptor( + predicate: #Predicate { $0.networkRaw == networkRaw && $0.walletId == walletId })) + return ( + positions.map { + PrivateContactRequestsPositionRow( + identityId: $0.identityId, position: UInt64(bitPattern: $0.position), + keyFingerprint: $0.keyFingerprint) + }, + addresses.map { + PrivateShieldedAddressRow(ownerId: $0.ownerId, contactId: $0.contactId, address: $0.address) + } + ) + } + } + // MARK: - Identity snapshot structs /// Swift-side snapshot of the Rust `IdentityEntryFFI` with C @@ -6653,6 +6785,29 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { where !claimedNetworks.contains(row.networkRaw) { backgroundContext.delete(row) } + // The private contact request scan state carries the same + // explicit network key, so it is purged by the same rule. + let positions: FetchDescriptor + let addresses: FetchDescriptor + if let raw = metadataNetwork?.rawValue { + positions = FetchDescriptor(predicate: #Predicate { + $0.walletId == walletId && $0.networkRaw == raw + }) + addresses = FetchDescriptor(predicate: #Predicate { + $0.walletId == walletId && $0.networkRaw == raw + }) + } else { + positions = FetchDescriptor(predicate: #Predicate { $0.walletId == walletId }) + addresses = FetchDescriptor(predicate: #Predicate { $0.walletId == walletId }) + } + for row in try backgroundContext.fetch(positions) + where !claimedNetworks.contains(row.networkRaw) { + backgroundContext.delete(row) + } + for row in try backgroundContext.fetch(addresses) + where !claimedNetworks.contains(row.networkRaw) { + backgroundContext.delete(row) + } if let walletRow = walletRow { // Wallet → identities is `.nullify`; this delete @@ -11912,6 +12067,119 @@ private func persistIdentityBalanceBlockTimeCallback( } catch { return -1 } } +/// C shim for `on_persist_private_contact_scan_state_fn`. Copies every row +/// before calling the handler, so Rust can drop its arrays on return. +private func persistPrivateContactScanStateCallback( + context: UnsafeMutableRawPointer?, walletId: UnsafePointer?, + positions: UnsafePointer?, positionsCount: UInt, + addresses: UnsafePointer?, addressesCount: UInt, + removedAddresses: UnsafePointer?, removedAddressesCount: UInt, + clearedIdentities: UnsafePointer?, clearedIdentitiesCount: UInt +) -> Int32 { + guard let context, let walletId else { return -1 } + func rows(_ pointer: UnsafePointer?, _ count: UInt) -> UnsafeBufferPointer? { + guard count > 0 else { return UnsafeBufferPointer(start: nil, count: 0) } + guard let pointer else { return nil } + return UnsafeBufferPointer(start: pointer, count: Int(count)) + } + guard let positionRows = rows(positions, positionsCount), + let addressRows = rows(addresses, addressesCount), + let removedRows = rows(removedAddresses, removedAddressesCount), + let clearedRows = rows(clearedIdentities, clearedIdentitiesCount) else { return -1 } + let handler = Unmanaged.fromOpaque(context).takeUnretainedValue() + do { + try handler.persistPrivateContactScanState( + walletId: Data(bytes: walletId, count: 32), + positions: positionRows.map { + .init(identityId: dataFromTuple32($0.identity_id), position: $0.position, + keyFingerprint: dataFromTuple32($0.key_fingerprint)) + }, + addresses: addressRows.map { + var address = $0.address + return .init(ownerId: dataFromTuple32($0.owner_id), contactId: dataFromTuple32($0.contact_id), + address: Swift.withUnsafeBytes(of: &address) { Data($0) }) + }, + removedAddresses: removedRows.map { + .init(ownerId: dataFromTuple32($0.owner_id), contactId: dataFromTuple32($0.contact_id)) + }, + clearedIdentities: clearedRows.map { dataFromTuple32($0) }) + return 0 + } catch { return -1 } +} + +/// C shim for `on_load_private_contact_scan_state_fn`. Hands Rust two +/// arrays of flat rows, released by the free shim below. A row whose +/// stored bytes have the wrong length is skipped rather than handed back +/// on zeros. +private func loadPrivateContactScanStateCallback( + context: UnsafeMutableRawPointer?, walletId: UnsafePointer?, + outPositions: UnsafeMutablePointer?>?, + outPositionsCount: UnsafeMutablePointer?, + outAddresses: UnsafeMutablePointer?>?, + outAddressesCount: UnsafeMutablePointer? +) -> Int32 { + guard let context, let walletId, let outPositions, let outPositionsCount, + let outAddresses, let outAddressesCount else { return -1 } + outPositions.pointee = nil + outPositionsCount.pointee = 0 + outAddresses.pointee = nil + outAddressesCount.pointee = 0 + let handler = Unmanaged.fromOpaque(context).takeUnretainedValue() + let stored: (positions: [PlatformWalletPersistenceHandler.PrivateContactRequestsPositionRow], + addresses: [PlatformWalletPersistenceHandler.PrivateShieldedAddressRow]) + do { + stored = try handler.loadPrivateContactScanState(walletId: Data(bytes: walletId, count: 32)) + } catch { return -1 } + let positions = stored.positions.filter { $0.identityId.count == 32 && $0.keyFingerprint.count == 32 } + let addresses = stored.addresses.filter { + $0.ownerId.count == 32 && $0.contactId.count == 32 && $0.address.count == 43 + } + if !positions.isEmpty { + let buffer = UnsafeMutablePointer.allocate(capacity: positions.count) + for (index, row) in positions.enumerated() { + var entry = PrivateContactRequestsPositionFFI() + Swift.withUnsafeMutableBytes(of: &entry.identity_id) { _ = row.identityId.copyBytes(to: $0) } + entry.position = row.position + Swift.withUnsafeMutableBytes(of: &entry.key_fingerprint) { _ = row.keyFingerprint.copyBytes(to: $0) } + (buffer + index).initialize(to: entry) + } + outPositions.pointee = UnsafePointer(buffer) + outPositionsCount.pointee = UInt(positions.count) + } + if !addresses.isEmpty { + let buffer = UnsafeMutablePointer.allocate(capacity: addresses.count) + for (index, row) in addresses.enumerated() { + var entry = PrivateShieldedAddressFFI() + Swift.withUnsafeMutableBytes(of: &entry.owner_id) { _ = row.ownerId.copyBytes(to: $0) } + Swift.withUnsafeMutableBytes(of: &entry.contact_id) { _ = row.contactId.copyBytes(to: $0) } + Swift.withUnsafeMutableBytes(of: &entry.address) { _ = row.address.copyBytes(to: $0) } + (buffer + index).initialize(to: entry) + } + outAddresses.pointee = UnsafePointer(buffer) + outAddressesCount.pointee = UInt(addresses.count) + } + return 0 +} + +/// C shim for `on_load_private_contact_scan_state_free_fn`: the rows are +/// flat, so releasing the two arrays is all there is to it. +private func loadPrivateContactScanStateFreeCallback( + context: UnsafeMutableRawPointer?, + positions: UnsafePointer?, positionsCount: UInt, + addresses: UnsafePointer?, addressesCount: UInt +) { + if let positions { + let buffer = UnsafeMutablePointer(mutating: positions) + buffer.deinitialize(count: Int(positionsCount)) + buffer.deallocate() + } + if let addresses { + let buffer = UnsafeMutablePointer(mutating: addresses) + buffer.deinitialize(count: Int(addressesCount)) + buffer.deallocate() + } +} + private func loadIdentityBalanceBlockTimeCallback( context: UnsafeMutableRawPointer?, walletId: UnsafePointer?, identityId: UnsafePointer?, outFound: UnsafeMutablePointer?, outBlockTime: UnsafeMutablePointer? diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashLegacySchemaMigrationTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashLegacySchemaMigrationTests.swift index d3c07c9f73f..b07ad5a84f2 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashLegacySchemaMigrationTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashLegacySchemaMigrationTests.swift @@ -11,16 +11,19 @@ private final class BridgeFutureMarker { var value: String = "future" init() {} } -private enum BridgeFutureV5: VersionedSchema { - static var versionIdentifier: Schema.Version { Schema.Version(5, 0, 0) } - static var models: [any PersistentModel.Type] { DashSchemaV4.models + [BridgeFutureMarker.self] } +private enum BridgeFutureV6: VersionedSchema { + static var versionIdentifier: Schema.Version { Schema.Version(6, 0, 0) } + static var models: [any PersistentModel.Type] { DashSchemaV5.models + [BridgeFutureMarker.self] } } private enum BridgeFuturePlan: SchemaMigrationPlan { - static var schemas: [any VersionedSchema.Type] { [DashSchemaV1.self, DashSchemaV3.self, DashSchemaV4.self, BridgeFutureV5.self] } + static var schemas: [any VersionedSchema.Type] { + [DashSchemaV1.self, DashSchemaV3.self, DashSchemaV4.self, DashSchemaV5.self, BridgeFutureV6.self] + } static var stages: [MigrationStage] { [.lightweight(fromVersion: DashSchemaV1.self, toVersion: DashSchemaV3.self), .lightweight(fromVersion: DashSchemaV3.self, toVersion: DashSchemaV4.self), - .custom(fromVersion: DashSchemaV4.self, toVersion: BridgeFutureV5.self, + .lightweight(fromVersion: DashSchemaV4.self, toVersion: DashSchemaV5.self), + .custom(fromVersion: DashSchemaV5.self, toVersion: BridgeFutureV6.self, willMigrate: { context in for wallet in try context.fetch(FetchDescriptor()) { wallet.name = "explicit future transformation" @@ -771,7 +774,7 @@ final class DashLegacySchemaMigrationTests: XCTestCase { func testSkippingV3UsesFixedBridgeThenRegisteredCustomFutureStage() throws { try withStore { url in - let schema = Schema(versionedSchema: BridgeFutureV5.self) + let schema = Schema(versionedSchema: BridgeFutureV6.self) var checkedV3 = false let configuration = ModelConfiguration(schema: schema, url: url, cloudKitDatabase: .none) XCTAssertThrowsError(try DashLegacySchemaBridge.open( diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashModelMigrationTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashModelMigrationTests.swift index e2ea9489f0a..94ee9120cf3 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashModelMigrationTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashModelMigrationTests.swift @@ -78,8 +78,8 @@ final class DashModelMigrationTests: XCTestCase { XCTAssertTrue(log.contains("route=\"historical-v2-to-v3\"")) XCTAssertTrue(log.contains("source_version=\"2.0.0\"")) XCTAssertTrue(log.contains("source_checksum=\"\(sourceChecksum)\"")) - XCTAssertTrue(log.contains("target_version=\"4.0.0\"")) - XCTAssertTrue(log.contains("route=\"labelled-current-v4\"")) + XCTAssertTrue(log.contains("target_version=\"5.0.0\"")) + XCTAssertTrue(log.contains("route=\"labelled-current-v5\"")) XCTAssertEqual(log.components(separatedBy: "event=store_open_succeeded").count - 1, 2) XCTAssertFalse(log.contains("event=store_open_failed")) XCTAssertFalse(log.contains(directory.path)) @@ -95,7 +95,7 @@ final class DashModelMigrationTests: XCTestCase { let updatedLog = try String(contentsOf: session.appendingPathComponent("swift/run.log"), encoding: .utf8) XCTAssertTrue(updatedLog.contains("route=\"new-store\"")) let failure = try XCTUnwrap(updatedLog.split(separator: "\n").first { $0.contains("event=store_open_failed") }) - XCTAssertTrue(failure.contains("target_version=\"4.0.0\"")) + XCTAssertTrue(failure.contains("target_version=\"5.0.0\"")) XCTAssertTrue(failure.contains("error_code=")) XCTAssertFalse(updatedLog.contains(directory.path)) XCTAssertFalse(updatedLog.contains("private-invalid-store-content")) @@ -106,7 +106,7 @@ final class DashModelMigrationTests: XCTestCase { /// either way. Labels whose route is undecidable without that probe /// (accepted V1 versus the bridge, historical V2 versus a beta layout) /// keep failing closed, leaving the store untouched. - func testCurrentV4RouteNeverDependsOnTheSchemaIdentityProbe() throws { + func testCurrentV5RouteNeverDependsOnTheSchemaIdentityProbe() throws { struct ProbeUnavailable: Error {} let failingProbe: (any VersionedSchema.Type) throws -> DashLegacySchemaBridge.Identity = { _ in throw ProbeUnavailable() @@ -117,7 +117,7 @@ final class DashModelMigrationTests: XCTestCase { defer { try? FileManager.default.removeItem(at: directory) } let current = directory.appendingPathComponent("current.store") try autoreleasepool { _ = try DashModelContainer.create(url: current) } - XCTAssertEqual(try DashLegacySchemaBridge.identity(at: current).versions, ["4.0.0"]) + XCTAssertEqual(try DashLegacySchemaBridge.identity(at: current).versions, ["5.0.0"]) let plan = try DashModelContainer.migrationPlan( at: current, defaultPlan: DashMigrationPlan.self, identity: failingProbe) XCTAssertTrue(ObjectIdentifier(plan) == ObjectIdentifier(DashMigrationPlan.self)) @@ -513,7 +513,7 @@ final class DashModelMigrationTests: XCTestCase { try v1Container?.mainContext.save() v1Container = nil - let v2Schema = Schema(versionedSchema: DashSchemaV4.self) + let v2Schema = Schema(versionedSchema: DashSchemaV5.self) let v2Configuration = ModelConfiguration( "DashKeyLimitsMigrationTest", schema: v2Schema, @@ -590,7 +590,7 @@ final class DashModelMigrationTests: XCTestCase { try v1Container?.mainContext.save() v1Container = nil - let v2Schema = Schema(versionedSchema: DashSchemaV4.self) + let v2Schema = Schema(versionedSchema: DashSchemaV5.self) let v2Configuration = ModelConfiguration( "DashContractBoundsKindMigrationTest", schema: v2Schema, diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/InvitationPersistenceTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/InvitationPersistenceTests.swift index 7b51fce43d9..bf2d9eade05 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/InvitationPersistenceTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/InvitationPersistenceTests.swift @@ -68,6 +68,10 @@ final class InvitationPersistenceTests: XCTestCase { // may ride this store's rounds — genuinely attested. | PlatformWalletPersistenceCapabilities.dashpayPayments | PlatformWalletPersistenceCapabilities.assetLockOperationIds + // Private contact request scan state: the handler wires the + // persist/load/free trio onto `PersistentPrivateContactRequestsPosition` + // and `PersistentPrivateShieldedAddress`. + | PlatformWalletPersistenceCapabilities.privateContactRequestScanState XCTAssertEqual( capabilities.version, diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/PrivateContactScanStatePersistenceTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/PrivateContactScanStatePersistenceTests.swift new file mode 100644 index 00000000000..0e001e2bd19 --- /dev/null +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/PrivateContactScanStatePersistenceTests.swift @@ -0,0 +1,187 @@ +import XCTest +import SwiftData +import DashSDKFFI +@testable import SwiftDashSDK + +/// The private contact request scan state (read positions and private +/// contacts' shielded addresses) round-trips through SwiftData and the +/// persistence extension's C shims. +final class PrivateContactScanStatePersistenceTests: XCTestCase { + private let walletId = Data(repeating: 42, count: 32) + private let owner = Data(repeating: 7, count: 32) + private let contact = Data(repeating: 5, count: 32) + private let otherContact = Data(repeating: 6, count: 32) + + private typealias Position = PlatformWalletPersistenceHandler.PrivateContactRequestsPositionRow + private typealias Address = PlatformWalletPersistenceHandler.PrivateShieldedAddressRow + private typealias AddressKey = PlatformWalletPersistenceHandler.PrivateShieldedAddressKey + + private func makeHandler() throws -> (ModelContainer, PlatformWalletPersistenceHandler) { + let container = try DashModelContainer.createInMemory() + let context = ModelContext(container) + context.insert(PersistentWallet(walletId: walletId, network: .testnet)) + try context.save() + return (container, PlatformWalletPersistenceHandler(modelContainer: container, network: .testnet)) + } + + private func round( + _ handler: PlatformWalletPersistenceHandler, + positions: [Position] = [], + addresses: [Address] = [], + removed: [AddressKey] = [], + cleared: [Data] = [], + success: Bool = true + ) throws { + handler.beginChangeset(walletId: walletId) + try handler.persistPrivateContactScanState( + walletId: walletId, positions: positions, addresses: addresses, + removedAddresses: removed, clearedIdentities: cleared) + XCTAssertEqual(handler.endChangeset(walletId: walletId, success: success), success) + } + + func testShouldStoreReplaceAndRemoveTheScanStateWithItsRound() throws { + let (_, handler) = try makeHandler() + let fingerprint = Data(repeating: 3, count: 32) + try round( + handler, + positions: [.init(identityId: owner, position: 12, keyFingerprint: fingerprint)], + addresses: [ + .init(ownerId: owner, contactId: contact, address: Data(repeating: 9, count: 43)), + .init(ownerId: owner, contactId: otherContact, address: Data(repeating: 8, count: 43)), + ]) + var stored = try handler.loadPrivateContactScanState(walletId: walletId) + XCTAssertEqual(stored.positions, [.init(identityId: owner, position: 12, keyFingerprint: fingerprint)]) + XCTAssertEqual(Set(stored.addresses.map(\.contactId)), [contact, otherContact]) + + // A rewind writes the lower position; a tombstone removes one address. + try round( + handler, + positions: [.init(identityId: owner, position: 0, keyFingerprint: fingerprint)], + removed: [.init(ownerId: owner, contactId: otherContact)]) + stored = try handler.loadPrivateContactScanState(walletId: walletId) + XCTAssertEqual(stored.positions.map(\.position), [0]) + XCTAssertEqual(stored.addresses, [ + .init(ownerId: owner, contactId: contact, address: Data(repeating: 9, count: 43)), + ]) + + // A rolled-back round leaves the stored state as it was. + try round( + handler, + positions: [.init(identityId: owner, position: 40, keyFingerprint: fingerprint)], + success: false) + XCTAssertEqual(try handler.loadPrivateContactScanState(walletId: walletId).positions.map(\.position), [0]) + + // A full-width position survives the signed SQLite column. + try round(handler, positions: [.init(identityId: owner, position: .max, keyFingerprint: fingerprint)]) + XCTAssertEqual(try handler.loadPrivateContactScanState(walletId: walletId).positions.map(\.position), [.max]) + } + + func testShouldClearTheScanStateOfARemovedIdentity() throws { + let (_, handler) = try makeHandler() + let survivor = Data(repeating: 1, count: 32) + try round( + handler, + positions: [ + .init(identityId: owner, position: 12, keyFingerprint: Data(repeating: 3, count: 32)), + .init(identityId: survivor, position: 4, keyFingerprint: Data(repeating: 2, count: 32)), + ], + addresses: [ + .init(ownerId: owner, contactId: contact, address: Data(repeating: 9, count: 43)), + .init(ownerId: survivor, contactId: contact, address: Data(repeating: 1, count: 43)), + ]) + try round(handler, cleared: [owner]) + let stored = try handler.loadPrivateContactScanState(walletId: walletId) + XCTAssertEqual(stored.positions.map(\.identityId), [survivor]) + XCTAssertEqual(stored.addresses.map(\.ownerId), [survivor]) + } + + func testShouldScopeTheScanStateByWalletAndNetworkAndPurgeItWithTheWallet() throws { + let (container, handler) = try makeHandler() + try round( + handler, + positions: [.init(identityId: owner, position: 12, keyFingerprint: Data(repeating: 3, count: 32))], + addresses: [.init(ownerId: owner, contactId: contact, address: Data(repeating: 9, count: 43))]) + XCTAssertTrue(try handler.loadPrivateContactScanState(walletId: Data(repeating: 43, count: 32)) + .positions.isEmpty) + let mainnet = PlatformWalletPersistenceHandler(modelContainer: container, network: .mainnet) + XCTAssertTrue(try mainnet.loadPrivateContactScanState(walletId: walletId).positions.isEmpty) + + try handler.deleteWalletData(walletId: walletId) + let context = ModelContext(container) + XCTAssertEqual(try context.fetchCount(FetchDescriptor()), 0) + XCTAssertEqual(try context.fetchCount(FetchDescriptor()), 0) + } + + func testShouldRefuseAScanStateWriteOutsideAChangeset() throws { + let (_, handler) = try makeHandler() + XCTAssertThrowsError(try handler.persistPrivateContactScanState( + walletId: walletId, + positions: [.init(identityId: owner, position: 1, keyFingerprint: Data(repeating: 3, count: 32))], + addresses: [], removedAddresses: [], clearedIdentities: [])) + } + + /// The C shims Rust calls: a persist through the extension's slot is + /// read back through its load slot, and the free slot returns the + /// arrays. The capability is declared alongside them. + func testShouldDeclareTheCapabilityAndRoundTripThroughTheCallbackShims() throws { + let (_, handler) = try makeHandler() + XCTAssertNotEqual( + handler.makePersistenceCapabilities().bits + & PlatformWalletPersistenceCapabilities.privateContactRequestScanState, 0) + XCTAssertEqual(PlatformWalletPersistenceCapabilities.privateContactRequestScanState, 1 << 14) + let ext = handler.makePersistenceCallbacksExtension() + let persist = try XCTUnwrap(ext.on_persist_private_contact_scan_state_fn) + let load = try XCTUnwrap(ext.on_load_private_contact_scan_state_fn) + let free = try XCTUnwrap(ext.on_load_private_contact_scan_state_free_fn) + let context = Unmanaged.passUnretained(handler).toOpaque() + + var position = PrivateContactRequestsPositionFFI() + Swift.withUnsafeMutableBytes(of: &position.identity_id) { _ = owner.copyBytes(to: $0) } + position.position = 77 + Swift.withUnsafeMutableBytes(of: &position.key_fingerprint) { + _ = Data(repeating: 3, count: 32).copyBytes(to: $0) + } + var address = PrivateShieldedAddressFFI() + Swift.withUnsafeMutableBytes(of: &address.owner_id) { _ = owner.copyBytes(to: $0) } + Swift.withUnsafeMutableBytes(of: &address.contact_id) { _ = contact.copyBytes(to: $0) } + Swift.withUnsafeMutableBytes(of: &address.address) { _ = Data(repeating: 9, count: 43).copyBytes(to: $0) } + + handler.beginChangeset(walletId: walletId) + let rc = walletId.withUnsafeBytes { wallet in + persist(context, wallet.bindMemory(to: UInt8.self).baseAddress, + &position, 1, &address, 1, nil, 0, nil, 0) + } + XCTAssertEqual(rc, 0) + XCTAssertTrue(handler.endChangeset(walletId: walletId, success: true)) + + var positions: UnsafePointer? + var positionsCount: UInt = 0 + var addresses: UnsafePointer? + var addressesCount: UInt = 0 + let loadRc = walletId.withUnsafeBytes { wallet in + load(context, wallet.bindMemory(to: UInt8.self).baseAddress, + &positions, &positionsCount, &addresses, &addressesCount) + } + XCTAssertEqual(loadRc, 0) + XCTAssertEqual(positionsCount, 1) + XCTAssertEqual(addressesCount, 1) + let loadedPosition = try XCTUnwrap(positions).pointee + XCTAssertEqual(loadedPosition.position, 77) + var loadedOwner = loadedPosition.identity_id + XCTAssertEqual(Swift.withUnsafeBytes(of: &loadedOwner) { Data($0) }, owner) + var loadedAddress = try XCTUnwrap(addresses).pointee.address + XCTAssertEqual(Swift.withUnsafeBytes(of: &loadedAddress) { Data($0) }, Data(repeating: 9, count: 43)) + free(context, positions, positionsCount, addresses, addressesCount) + + // A wallet with nothing stored hands back two empty arrays. + let empty = Data(repeating: 43, count: 32) + let emptyRc = empty.withUnsafeBytes { wallet in + load(context, wallet.bindMemory(to: UInt8.self).baseAddress, + &positions, &positionsCount, &addresses, &addressesCount) + } + XCTAssertEqual(emptyRc, 0) + XCTAssertNil(positions) + XCTAssertNil(addresses) + XCTAssertEqual(positionsCount + addressesCount, 0) + } +} diff --git a/packages/swift-sdk/schema-models.json b/packages/swift-sdk/schema-models.json index 439a181f8e3..b7714b9657d 100644 --- a/packages/swift-sdk/schema-models.json +++ b/packages/swift-sdk/schema-models.json @@ -36,7 +36,9 @@ "PersistentInvitation": "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentInvitation.swift", "PersistentMasternode": "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentMasternode.swift", "PersistentTrackedMasternode": "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentTrackedMasternode.swift", - "PersistentIdentityBalanceMetadata": "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentIdentityBalanceMetadata.swift" + "PersistentIdentityBalanceMetadata": "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentIdentityBalanceMetadata.swift", + "PersistentPrivateContactRequestsPosition": "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentPrivateContactRequestsPosition.swift", + "PersistentPrivateShieldedAddress": "packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentPrivateShieldedAddress.swift" }, "value_types": [ { From 62169d368d1101123ad4a77b46fffd175826766e Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 9 Nov 2026 09:52:00 +0700 Subject: [PATCH 100/269] fix(swift-example-app): keep a failed pending read apart from nothing pending `privateRepliesPending` read a failed wallet read as "nothing pending", so the request list brought back Accept and Ignore, and Add Contact enabled Send, with nothing showing the pending request had cleared. The read now returns nil on a failure, and `PrivateRepliesPendingSnapshot` keeps the last successful snapshot and leaves those controls off for every contact until a read covering it succeeds. An unmanaged identity still reads as nothing pending. A pending private request (code 71), which the first public send or accept since load can now meet, gets its own outcome: nothing was sent, so the view shows it as pending and Add Contact offers "Sync private requests", after which Send comes back for the user to try again. Co-Authored-By: Claude Opus 5.5 --- .../Views/DashPay/AddContactView.swift | 83 +++++++++++++++---- .../DashPay/ContactRequestOutcomes.swift | 74 +++++++++++++++-- .../Views/DashPay/ContactRequestsView.swift | 29 ++++--- .../Views/DashPay/ContactsView.swift | 34 ++++++-- .../ContactRequestOutcomesTests.swift | 50 ++++++++++- 5 files changed, 224 insertions(+), 46 deletions(-) diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/AddContactView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/AddContactView.swift index b42aa0ee45e..7067207135d 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/AddContactView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/AddContactView.swift @@ -64,9 +64,13 @@ struct AddContactView: View { /// Whether the wallet has a private request to the resolved recipient in /// flight, or paid and not yet read back (`privateRepliesPending`). While - /// it does, Send is off; a private sync settles it. Read from the wallet, - /// so reopening this sheet keeps it. - @State private var recipientPending = false + /// it does, Send is off and the footer offers a private sync, which + /// settles it. Read from the wallet, so reopening this sheet keeps it. A + /// failed read keeps Send off until a read succeeds. + @State private var recipientPending = PrivateRepliesPendingSnapshot() + + /// A private sync the footer started is running. + @State private var isSyncingPrivate = false /// Send-collision flow. @State private var showCollisionAlert = false @@ -106,7 +110,14 @@ struct AddContactView: View { && resolvedRecipient != identity.identityId && !isSending && unconfirmedNotice == nil - && !recipientPending + && resolvedRecipient.map(recipientPending.allowsSubmission(to:)) == true + } + + /// Whether the footer shows the recipient's pending state: a private + /// request to them is pending, or the wallet could not be read. + private var recipientBlocked: Bool { + guard let recipient = resolvedRecipient else { return false } + return recipientPending.isPending(recipient) || recipientPending.isUnavailable } var body: some View { @@ -413,9 +424,26 @@ struct AddContactView: View { .disabled(!canSend) .accessibilityIdentifier("dashpay.addContact.send") } footer: { - if recipientPending { - Text(ContactRequestSubmissionOutcome.mayHaveGoneThroughMessage) + if let recipient = resolvedRecipient, recipientBlocked { + VStack(alignment: .leading, spacing: 6) { + Text(ContactRequestSubmissionOutcome.rowMessage( + pending: recipientPending.isPending(recipient), + unavailable: recipientPending.isUnavailable, + error: nil + ) ?? "") .accessibilityIdentifier("dashpay.addContact.pending") + Button { + syncPrivateRequests() + } label: { + if isSyncingPrivate { + ProgressView() + } else { + Text("Sync private requests") + } + } + .disabled(isSyncingPrivate) + .accessibilityIdentifier("dashpay.addContact.syncPrivate") + } } } } @@ -423,15 +451,31 @@ struct AddContactView: View { /// Re-read from the wallet whether a private request to the resolved /// recipient is pending. private func reloadRecipientPending() { - guard let recipient = resolvedRecipient, let wallet = try? requireWallet() else { - recipientPending = false - return + let contactIds = resolvedRecipient.map { [$0] } ?? [] + recipientPending.record( + privateRepliesPending( + wallet: try? requireWallet(), + identityId: identity.identityId, + contactIds: contactIds + ), + for: contactIds + ) + } + + /// Read the private store, which settles a pending private request, then + /// re-read the recipient's pending state: Send comes back once nothing + /// is pending, for the user to try again. + private func syncPrivateRequests() { + isSyncingPrivate = true + Task { @MainActor in + defer { isSyncingPrivate = false } + if let wallet = try? requireWallet() { + if let notice = await syncPrivateContactRequests(wallet) { + errorMessage = notice + } + } + reloadRecipientPending() } - recipientPending = privateRepliesPending( - wallet: wallet, - identityId: identity.identityId, - contactIds: [recipient] - ).contains(recipient) } private func previewDisplayName( @@ -538,6 +582,10 @@ struct AddContactView: View { onSent(recipient, mode == .dpns ? selectedResult?.fullName : nil) kickDashPaySync(walletManager) unconfirmedNotice = ContactRequestSubmissionOutcome.mayHaveGoneThroughMessage + case .privateRequestPending: + // Nothing was sent: the footer shows the pending request + // and offers the private sync that settles it. + break case .failed(let message): errorMessage = message } @@ -550,8 +598,8 @@ struct AddContactView: View { /// `ContactRequest` and accept it — establishing the contact /// directly instead of sending a redundant request. private func acceptIncoming(from recipient: Identifier) { - // A private request to this contact is already pending in the wallet. - guard !recipientPending else { return } + // A private request to this contact may be pending in the wallet. + guard recipientPending.allowsSubmission(to: recipient) else { return } // "Send privately" is the user's choice of a private request, so it // holds for the accept too (`CollisionAccept`). let accept = CollisionAccept.of(sendPrivately: sendPrivately) @@ -596,6 +644,9 @@ struct AddContactView: View { case .mayHaveGoneThrough: kickDashPaySync(walletManager) unconfirmedNotice = ContactRequestSubmissionOutcome.mayHaveGoneThroughMessage + case .privateRequestPending: + // Nothing was sent: the footer offers the private sync. + break case .failed(let message): errorMessage = "Accept failed: \(message)" } diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ContactRequestOutcomes.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ContactRequestOutcomes.swift index 6e5244eb481..c37f2c28909 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ContactRequestOutcomes.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ContactRequestOutcomes.swift @@ -6,30 +6,43 @@ import SwiftDashSDK /// confirmed may already be on chain (Rust keeps its notes reserved), so the /// view keeps it pending and never offers a fresh submission; the next sync /// settles it. A private request Rust reports as pending (one to the same -/// contact in flight, or paid and not yet read back) is the same outcome: -/// nothing new was paid, and the next private sync settles it. Every other -/// error is an ordinary failure the user may retry. +/// contact in flight, or paid and not yet read back), which a public send or +/// accept can also meet, sent nothing new: the view shows it as pending and +/// offers a private sync, which settles it, after which the user may try +/// again. Every other error is an ordinary failure the user may retry. enum ContactRequestSubmissionOutcome: Equatable { /// The private send or reply may have gone through: do not submit again. case mayHaveGoneThrough + /// A private request to the contact is pending in the wallet; nothing + /// was sent. A private sync settles it. + case privateRequestPending /// It failed; the message is the error's. case failed(String) static let mayHaveGoneThroughMessage = "This may have gone through. It shows up " + "after the next sync confirms it; do not submit it again." + static let privateRequestPendingMessage = "A private request to this contact is " + + "pending. Sync private requests to settle it, then try again if it did not " + + "go through." + /// The message a request row shows: the pending notice while the wallet - /// has a private reply to the contact pending, whatever error an earlier - /// attempt left, and that error otherwise. - static func rowMessage(pending: Bool, error: String?) -> String? { - pending ? mayHaveGoneThroughMessage : error + /// has a private request to the contact pending, the unavailable notice + /// while the wallet could not be read, whatever error an earlier attempt + /// left, and that error otherwise. + static func rowMessage(pending: Bool, unavailable: Bool = false, error: String?) -> String? { + if pending { return privateRequestPendingMessage } + if unavailable { return PrivateRepliesPendingSnapshot.unavailableMessage } + return error } static func of(_ error: Error) -> ContactRequestSubmissionOutcome { if let walletError = error as? PlatformWalletError { switch walletError { - case .shieldedSpendUnconfirmed, .privateContactRequestPending: + case .shieldedSpendUnconfirmed: return .mayHaveGoneThrough + case .privateContactRequestPending: + return .privateRequestPending default: break } @@ -38,6 +51,51 @@ enum ContactRequestSubmissionOutcome: Equatable { } } +/// Which contacts have a private request pending in the wallet, as the views +/// last read it (`privateRepliesPending`). While one is, Rust refuses another +/// send or reply to that contact, so the views turn off what would submit one +/// (Accept, Ignore, Send). A failed read is not "nothing pending": the last +/// successful snapshot stays, and those controls stay off for every contact +/// until a read covering it succeeds. +struct PrivateRepliesPendingSnapshot: Equatable { + static let unavailableMessage = "Could not read whether a private request to " + + "this contact is pending. Sync private requests to check again." + + /// The pending contacts the last successful read found. + private(set) var pending: Set = [] + /// The contacts the last successful read covered. + private(set) var covered: Set = [] + /// False after a failed read, until a read succeeds. + private(set) var available = true + + /// Record a read of `contactIds`: the pending ones, or nil when the read + /// failed. + mutating func record(_ read: Set?, for contactIds: [Data]) { + guard let read else { + available = false + return + } + pending = read + covered = Set(contactIds) + available = true + } + + /// Whether the last successful read found a private request to + /// `contactId` pending. + func isPending(_ contactId: Data) -> Bool { + pending.contains(contactId) + } + + /// Whether the last read failed. + var isUnavailable: Bool { !available } + + /// Whether a send or reply to `contactId` may be offered: a successful + /// read covered it and found nothing pending. + func allowsSubmission(to contactId: Data) -> Bool { + available && covered.contains(contactId) && !pending.contains(contactId) + } +} + /// How Add Contact's collision alert accepts the request the recipient /// already sent us. With "Send privately" on, the user chose a private /// request, so the accept replies privately diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ContactRequestsView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ContactRequestsView.swift index 2ccaa3ab012..a9b835e369c 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ContactRequestsView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ContactRequestsView.swift @@ -45,8 +45,10 @@ struct ContactRequestsView: View { /// back, as the wallet records it (`privateRepliesPending`): the row stays /// pending, without its buttons, until a private sync settles it. Read /// from the wallet on appear, after an accept and after each sync pass, - /// so leaving and reopening the screen keeps it. - @State private var walletPendingIds: Set = [] + /// so leaving and reopening the screen keeps it. A failed read keeps the + /// last snapshot and leaves every row without its buttons until a read + /// succeeds. + @State private var walletPending = PrivateRepliesPendingSnapshot() /// What the last private-store refresh has to tell the user, if anything. @State private var privateRefreshNotice: String? @@ -139,9 +141,10 @@ struct ContactRequestsView: View { avatarUrl: nil, createdAtMillis: row.createdAtMillis, isInFlight: inFlightIds.contains(row.contactIdentityId) - || walletPendingIds.contains(row.contactIdentityId), + || !walletPending.allowsSubmission(to: row.contactIdentityId), errorMessage: ContactRequestSubmissionOutcome.rowMessage( - pending: walletPendingIds.contains(row.contactIdentityId), + pending: walletPending.isPending(row.contactIdentityId), + unavailable: walletPending.isUnavailable, error: rowErrors[row.contactIdentityId] ), onAccept: { accept(contactId: row.contactIdentityId) }, @@ -220,11 +223,14 @@ struct ContactRequestsView: View { /// Re-read which incoming rows have a private reply pending, from the /// wallet. private func reloadPendingReplies() { - guard let wallet = try? requireWallet() else { return } - walletPendingIds = privateRepliesPending( - wallet: wallet, - identityId: identity.identityId, - contactIds: incomingPending.map(\.contactIdentityId) + let contactIds = incomingPending.map(\.contactIdentityId) + walletPending.record( + privateRepliesPending( + wallet: try? requireWallet(), + identityId: identity.identityId, + contactIds: contactIds + ), + for: contactIds ) } @@ -286,10 +292,11 @@ struct ContactRequestsView: View { kickDashPaySync(walletManager) } catch { switch ContactRequestSubmissionOutcome.of(error) { - case .mayHaveGoneThrough: + case .mayHaveGoneThrough, .privateRequestPending: // As `SendViewModel` treats an unconfirmed spend: not a // failure to retry. The wallet keeps the reply pending, - // and the row reads that; the next sync settles it. + // and the row reads that; a sync (pull to refresh reads + // the private store) settles it. kickDashPaySync(walletManager) case .failed(let message): rowErrors[contactId] = "Accept failed: \(message)" diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ContactsView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ContactsView.swift index 3d7d9109f00..6a16d4e1c18 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ContactsView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ContactsView.swift @@ -229,20 +229,36 @@ func kickDashPaySync(_ walletManager: PlatformWalletManager) { /// The contacts among `contactIds` that `identityId` has a private request /// to in flight, or paid and not yet read back, as the wallet records it /// (`ManagedIdentity.privateReplyPending`, one snapshot). While one is, Rust -/// refuses another private send or reply to it, so the views show it as -/// pending and offer neither; a private sync settles it. Empty when the -/// identity is not managed. +/// refuses another send or reply to it, so the views show it as pending and +/// offer neither; a private sync settles it. Empty when the wallet does not +/// manage the identity, as it then holds nothing for it. Nil when the wallet +/// could not be read (no loaded wallet, or a read failed), which the views +/// keep apart from "nothing pending" (`PrivateRepliesPendingSnapshot`). @MainActor func privateRepliesPending( - wallet: ManagedPlatformWallet, + wallet: ManagedPlatformWallet?, identityId: Data, contactIds: [Data] -) -> Set { - guard !contactIds.isEmpty, - let managed = try? wallet.managedIdentity(identityId: identityId) else { - return [] +) -> Set? { + guard !contactIds.isEmpty else { return [] } + guard let wallet else { return nil } + do { + let managed: ManagedIdentity + do { + managed = try wallet.managedIdentity(identityId: identityId) + } catch PlatformWalletError.notFound(_) { + return [] + } + var pending = Set() + for contactId in contactIds { + if try managed.privateReplyPending(contactId: contactId) { + pending.insert(contactId) + } + } + return pending + } catch { + return nil } - return Set(contactIds.filter { (try? managed.privateReplyPending(contactId: $0)) == true }) } /// Read DashPay's private contact request store (protocol version 15) for diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/ContactRequestOutcomesTests.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/ContactRequestOutcomesTests.swift index 6c4307860be..f414cf4f010 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/ContactRequestOutcomesTests.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/ContactRequestOutcomesTests.swift @@ -15,25 +15,71 @@ final class ContactRequestOutcomesTests: XCTestCase { XCTAssertEqual(outcome, .mayHaveGoneThrough) } + /// A pending private request (code 71), which a public send or accept + /// can meet too, sent nothing: it is shown as pending, with a private + /// sync to settle it, rather than as a submission that may have gone + /// through. func testShouldKeepAPendingPrivateRequestPending() { let outcome = ContactRequestSubmissionOutcome.of( PlatformWalletError.privateContactRequestPending("not yet read back from the store") ) - XCTAssertEqual(outcome, .mayHaveGoneThrough) + XCTAssertEqual(outcome, .privateRequestPending) } func testShouldShowAWalletPendingReplyOverAnEarlierError() { XCTAssertEqual( ContactRequestSubmissionOutcome.rowMessage(pending: true, error: "Accept failed: boom"), - ContactRequestSubmissionOutcome.mayHaveGoneThroughMessage + ContactRequestSubmissionOutcome.privateRequestPendingMessage ) XCTAssertEqual( ContactRequestSubmissionOutcome.rowMessage(pending: false, error: "Accept failed: boom"), "Accept failed: boom" ) + XCTAssertEqual( + ContactRequestSubmissionOutcome.rowMessage( + pending: false, unavailable: true, error: "Accept failed: boom"), + PrivateRepliesPendingSnapshot.unavailableMessage + ) XCTAssertNil(ContactRequestSubmissionOutcome.rowMessage(pending: false, error: nil)) } + /// A read that found a private request pending, followed by a failed + /// read: the contact stays pending and nothing is offered for any + /// contact, until a read succeeds again. + func testShouldKeepThePendingSnapshotAndOfferNothingAfterAFailedRead() { + let pendingContact = Data(repeating: 1, count: 32) + let otherContact = Data(repeating: 2, count: 32) + var snapshot = PrivateRepliesPendingSnapshot() + snapshot.record([pendingContact], for: [pendingContact, otherContact]) + XCTAssertTrue(snapshot.isPending(pendingContact)) + XCTAssertFalse(snapshot.allowsSubmission(to: pendingContact)) + XCTAssertTrue(snapshot.allowsSubmission(to: otherContact)) + + snapshot.record(nil, for: [pendingContact, otherContact]) + XCTAssertTrue(snapshot.isUnavailable) + XCTAssertTrue(snapshot.isPending(pendingContact), "the last snapshot stays") + XCTAssertFalse(snapshot.allowsSubmission(to: pendingContact)) + XCTAssertFalse(snapshot.allowsSubmission(to: otherContact)) + + snapshot.record([], for: [pendingContact, otherContact]) + XCTAssertFalse(snapshot.isUnavailable) + XCTAssertTrue(snapshot.allowsSubmission(to: pendingContact)) + } + + /// A successful read that found nothing pending offers every contact it + /// covered, and nothing it did not. + func testShouldOfferEveryContactASuccessfulEmptyReadCovered() { + let contact = Data(repeating: 1, count: 32) + var snapshot = PrivateRepliesPendingSnapshot() + snapshot.record([], for: [contact]) + XCTAssertFalse(snapshot.isUnavailable) + XCTAssertFalse(snapshot.isPending(contact)) + XCTAssertTrue(snapshot.allowsSubmission(to: contact)) + XCTAssertFalse( + snapshot.allowsSubmission(to: Data(repeating: 2, count: 32)), + "a contact the read did not cover is not offered yet") + } + func testShouldAcceptACollisionPrivatelyWhenSendPrivatelyIsOn() { let accept = CollisionAccept.of(sendPrivately: true) XCTAssertEqual(accept, .privateReply) From aa341f9c3b8cd0456a046ee9019a78e6f4a4b874 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 9 Nov 2026 09:57:07 +0700 Subject: [PATCH 101/269] feat(kotlin-sdk,unified-sdk-jni): persist the private contact request scan state The Android half of `PRIVATE_CONTACT_REQUEST_SCAN_STATE` (bit 14): each identity's read position in DashPay's private contact request store, and its private contacts' shielded addresses, survive a relaunch, so the private sync resumes where it stopped. JNI: the three scan-state extension slots, wired only for a bridge that overrides both `onPersistPrivateContactScanState` and `onLoadPrivateContactScanState`. Rows cross as flat packed `byte[]`s, as the backfill cover set does; a load whose arrays are not whole rows, or that throws, fails the load. Kotlin: Room v16 adds `private_contact_requests_positions` and `private_shielded_addresses` (migration 15 to 16). The handler stages the writes into the round, clears the state of identities the round removed, purges it with the wallet, and declares the capability. Co-Authored-By: Claude Opus 5.5 --- .../persistence/DashDatabaseMigrationTest.kt | 55 ++- .../dashsdk/ffi/NativePersistenceBridge.kt | 55 +++ .../dashsdk/persistence/DashDatabase.kt | 38 ++- .../PlatformWalletPersistenceHandler.kt | 119 ++++++- .../dao/PrivateContactScanStateDao.kt | 44 +++ .../PrivateContactScanStateEntities.kt | 46 +++ .../dashsdk/wallet/PlatformWalletManager.kt | 9 + .../PlatformWalletPersistenceHandlerTest.kt | 5 +- .../PrivateContactScanStatePersistenceTest.kt | 167 +++++++++ .../rs-unified-sdk-jni/src/persistence.rs | 322 +++++++++++++++++- 10 files changed, 850 insertions(+), 10 deletions(-) create mode 100644 packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/dao/PrivateContactScanStateDao.kt create mode 100644 packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/entities/PrivateContactScanStateEntities.kt create mode 100644 packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/persistence/PrivateContactScanStatePersistenceTest.kt diff --git a/packages/kotlin-sdk/sdk/src/androidTest/kotlin/org/dashfoundation/dashsdk/persistence/DashDatabaseMigrationTest.kt b/packages/kotlin-sdk/sdk/src/androidTest/kotlin/org/dashfoundation/dashsdk/persistence/DashDatabaseMigrationTest.kt index 9a98201510b..7fb787bbf26 100644 --- a/packages/kotlin-sdk/sdk/src/androidTest/kotlin/org/dashfoundation/dashsdk/persistence/DashDatabaseMigrationTest.kt +++ b/packages/kotlin-sdk/sdk/src/androidTest/kotlin/org/dashfoundation/dashsdk/persistence/DashDatabaseMigrationTest.kt @@ -690,13 +690,60 @@ class DashDatabaseMigrationTest { db.close() } + /** + * v15 -> v16 creates the two private contact request scan state tables + * empty (the first sync reads the store from its start) and leaves the + * existing rows alone; rows written afterwards keep every column, + * a full-width position included. + */ + @Test + fun migrate15To16AddsPrivateContactScanStateTables() { + helper.createDatabase(dbName, 15).apply { + execSQL( + "INSERT INTO wallets (walletId, walletGroupId, networkRaw, name, birthHeight, " + + "syncedHeight, lastSynced, isImported, createdAt, lastUpdated) " + + "VALUES (x'01', x'02', 1, 'w', 0, 2309809, 0, 0, 0, 0)", + ) + close() + } + + val db = helper.runMigrationsAndValidate(dbName, 16, true, DashDatabase.MIGRATION_15_16) + + db.query("SELECT COUNT(*) FROM private_contact_requests_positions").use { c -> + assertTrue(c.moveToFirst()) + assertEquals(0, c.getInt(0)) + } + db.query("SELECT syncedHeight FROM wallets WHERE walletId = x'01'").use { c -> + assertTrue(c.moveToFirst()) + assertEquals(2_309_809, c.getInt(0)) + } + db.execSQL( + "INSERT INTO private_contact_requests_positions (walletId, identityId, position, " + + "keyFingerprint) VALUES (x'01', x'0A', -1, x'03')", + ) + db.execSQL( + "INSERT INTO private_shielded_addresses (walletId, ownerId, contactId, address) " + + "VALUES (x'01', x'0A', x'0B', x'09')", + ) + db.query("SELECT position, keyFingerprint FROM private_contact_requests_positions").use { c -> + assertTrue(c.moveToFirst()) + assertEquals("the u64 bit pattern survives", -1L, c.getLong(0)) + assertTrue(byteArrayOf(3).contentEquals(c.getBlob(1))) + } + db.query("SELECT address FROM private_shielded_addresses WHERE contactId = x'0B'").use { c -> + assertTrue(c.moveToFirst()) + assertTrue(byteArrayOf(9).contentEquals(c.getBlob(0))) + } + db.close() + } + /** The requested contiguous path from the pre-u64 v4 schema to latest. */ @Test fun migrate4ToLatest() { helper.createDatabase(dbName, 4).close() helper.runMigrationsAndValidate( dbName, - 15, + 16, true, DashDatabase.MIGRATION_4_5, DashDatabase.MIGRATION_5_6, @@ -709,16 +756,17 @@ class DashDatabaseMigrationTest { DashDatabase.MIGRATION_12_13, DashDatabase.MIGRATION_13_14, DashDatabase.MIGRATION_14_15, + DashDatabase.MIGRATION_15_16, ).close() } - /** The full chain from v1 must also land on a valid v15 schema. */ + /** The full chain from v1 must also land on a valid v16 schema. */ @Test fun migrateAllTheWayFrom1() { helper.createDatabase(dbName, 1).close() helper.runMigrationsAndValidate( dbName, - 15, + 16, true, DashDatabase.MIGRATION_1_2, DashDatabase.MIGRATION_2_3, @@ -734,6 +782,7 @@ class DashDatabaseMigrationTest { DashDatabase.MIGRATION_12_13, DashDatabase.MIGRATION_13_14, DashDatabase.MIGRATION_14_15, + DashDatabase.MIGRATION_15_16, ).close() } } diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/NativePersistenceBridge.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/NativePersistenceBridge.kt index 7d6ba3275c5..f55707de2b9 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/NativePersistenceBridge.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/NativePersistenceBridge.kt @@ -99,6 +99,27 @@ abstract class NativePersistenceBridge { * it, so the declaration and the mirror can never drift apart. */ const val CAPABILITY_CORE_SWEEP_REMOVAL: Long = 0x800 + + /** + * `PersistenceCapabilities::PRIVATE_CONTACT_REQUEST_SCAN_STATE` + * (bit 14, `0x4000`): the private contact request sync's scan state + * is stored on its round and restored at load. The one Kotlin home + * of this bit, aliased by the public diagnostic mirror. + */ + const val CAPABILITY_PRIVATE_CONTACT_REQUEST_SCAN_STATE: Long = 1L shl 14 + + /** + * Packed row widths of [onPersistPrivateContactScanState] and + * [onLoadPrivateContactScanState]: a read position is the identity + * id (32), the position (u64, little-endian) and the key + * fingerprint (32); a shielded address the owner id (32), the + * contact id (32) and the address (43); an address removal the + * owner and contact ids; a cleared identity its id. + */ + const val PRIVATE_POSITION_ENTRY_SIZE: Int = 32 + 8 + 32 + const val PRIVATE_ADDRESS_ENTRY_SIZE: Int = 32 + 32 + 43 + const val PRIVATE_ADDRESS_REMOVAL_ENTRY_SIZE: Int = 32 + 32 + const val PRIVATE_CLEARED_IDENTITY_ENTRY_SIZE: Int = 32 } /** @@ -471,6 +492,30 @@ abstract class NativePersistenceBridge { coveredCount: Int, ): Int = 0 + /** + * The private contact request sync's scan state a round writes, inside + * the round after its contacts. Descriptor `([B[B[B[B[B)I`. Each array + * packs whole rows (see [PRIVATE_POSITION_ENTRY_SIZE] and its siblings): + * + * - [positions]: replace the identity's stored read position. + * - [addresses]: replace the stored address for `(owner, contact)`. + * - [removedAddresses]: delete the stored address for `(owner, contact)`. + * - [clearedIdentities]: identities the round removed from the wallet; + * delete each one's position and every address it owns. + * + * Delivered only to a bridge that overrides both this and + * [onLoadPrivateContactScanState]: an inherited no-op would acknowledge + * a read position it never kept. Without them each launch reads the + * private store from its start. + */ + open fun onPersistPrivateContactScanState( + walletId: ByteArray, + positions: ByteArray, + addresses: ByteArray, + removedAddresses: ByteArray, + clearedIdentities: ByteArray, + ): Int = 0 + // ── Identities ──────────────────────────────────────────────────── /** @@ -907,6 +952,16 @@ abstract class NativePersistenceBridge { */ open fun onLoadShieldedViewingKeys(): Array = emptyArray() + /** + * `on_load_private_contact_scan_state_fn`: the stored scan state of + * [walletId] as two packed arrays, `[positions, addresses]`, in the + * layout [onPersistPrivateContactScanState] stores. Descriptor + * `([B)[[B`. Throw on a store that cannot be read: an empty answer + * would read the store from its start without saying why. + */ + open fun onLoadPrivateContactScanState(walletId: ByteArray): Array = + arrayOf(ByteArray(0), ByteArray(0)) + /** * `on_get_core_tx_record_fn`. Returns the record for `txid` or `null` * if no row exists. Descriptor diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/DashDatabase.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/DashDatabase.kt index 0283ac2904f..5ef6dfb6a7c 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/DashDatabase.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/DashDatabase.kt @@ -18,6 +18,7 @@ import org.dashfoundation.dashsdk.persistence.dao.DpnsNameDao import org.dashfoundation.dashsdk.persistence.dao.IdentityDao import org.dashfoundation.dashsdk.persistence.dao.InvitationDao import org.dashfoundation.dashsdk.persistence.dao.PlatformAddressDao +import org.dashfoundation.dashsdk.persistence.dao.PrivateContactScanStateDao import org.dashfoundation.dashsdk.persistence.dao.PublicKeyDao import org.dashfoundation.dashsdk.persistence.dao.ShieldedDao import org.dashfoundation.dashsdk.persistence.dao.StorageCountsDao @@ -45,6 +46,8 @@ import org.dashfoundation.dashsdk.persistence.entities.KeywordEntity import org.dashfoundation.dashsdk.persistence.entities.PendingInputEntity import org.dashfoundation.dashsdk.persistence.entities.PlatformAddressEntity import org.dashfoundation.dashsdk.persistence.entities.PlatformAddressesSyncStateEntity +import org.dashfoundation.dashsdk.persistence.entities.PrivateContactRequestsPositionEntity +import org.dashfoundation.dashsdk.persistence.entities.PrivateShieldedAddressEntity import org.dashfoundation.dashsdk.persistence.entities.PropertyEntity import org.dashfoundation.dashsdk.persistence.entities.PublicKeyEntity import org.dashfoundation.dashsdk.persistence.entities.ShieldedActivityEntity @@ -179,9 +182,17 @@ import org.dashfoundation.dashsdk.persistence.entities.WalletManagerMetadataEnti * treated the account as rotated, tore it down and rebuilt it — and that * rebuild was the per-launch cursor rewind. NULL for pre-migration rows: * native rebuilds once, stamps it, and never again. + * + * Version 16 (private contact request scan state): adds the + * `private_contact_requests_positions` and `private_shielded_addresses` + * tables, the scan state native writes through + * `onPersistPrivateContactScanState` and reads back through + * `onLoadPrivateContactScanState`, so a relaunch resumes the private contact + * request sync where it stopped. Empty after the migration: the first sync + * reads the store from its start, as every sync did before. */ @Database( - version = 15, + version = 16, exportSchema = true, entities = [ WalletEntity::class, @@ -218,6 +229,8 @@ import org.dashfoundation.dashsdk.persistence.entities.WalletManagerMetadataEnti ShieldedSyncStateEntity::class, ShieldedViewingKeyEntity::class, WalletManagerMetadataEntity::class, + PrivateContactRequestsPositionEntity::class, + PrivateShieldedAddressEntity::class, ], ) @TypeConverters(Converters::class) @@ -241,6 +254,7 @@ abstract class DashDatabase : RoomDatabase() { abstract fun shieldedDao(): ShieldedDao abstract fun walletManagerMetadataDao(): WalletManagerMetadataDao abstract fun storageCountsDao(): StorageCountsDao + abstract fun privateContactScanStateDao(): PrivateContactScanStateDao companion object { const val DATABASE_NAME: String = "dash-sdk.db" @@ -716,6 +730,27 @@ abstract class DashDatabase : RoomDatabase() { } } + /** + * v15 -> v16: the two private contact request scan state tables, see + * the version-16 class doc above. Created empty. + */ + val MIGRATION_15_16: Migration = object : Migration(15, 16) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL( + "CREATE TABLE IF NOT EXISTS `private_contact_requests_positions` (" + + "`walletId` BLOB NOT NULL, `identityId` BLOB NOT NULL, " + + "`position` INTEGER NOT NULL, `keyFingerprint` BLOB NOT NULL, " + + "PRIMARY KEY(`walletId`, `identityId`))", + ) + db.execSQL( + "CREATE TABLE IF NOT EXISTS `private_shielded_addresses` (" + + "`walletId` BLOB NOT NULL, `ownerId` BLOB NOT NULL, " + + "`contactId` BLOB NOT NULL, `address` BLOB NOT NULL, " + + "PRIMARY KEY(`walletId`, `ownerId`, `contactId`))", + ) + } + } + /** * Build the on-disk database. WAL is Room's default journal mode on * API 16+; writes go through the persistence handler inside @@ -746,6 +781,7 @@ abstract class DashDatabase : RoomDatabase() { MIGRATION_12_13, MIGRATION_13_14, MIGRATION_14_15, + MIGRATION_15_16, ) .build() diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandler.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandler.kt index 4a5d469053d..912fa65111d 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandler.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandler.kt @@ -47,6 +47,8 @@ import org.dashfoundation.dashsdk.persistence.entities.IdentityEntity import org.dashfoundation.dashsdk.persistence.entities.InvitationEntity import org.dashfoundation.dashsdk.persistence.entities.PlatformAddressEntity import org.dashfoundation.dashsdk.persistence.entities.PlatformAddressesSyncStateEntity +import org.dashfoundation.dashsdk.persistence.entities.PrivateContactRequestsPositionEntity +import org.dashfoundation.dashsdk.persistence.entities.PrivateShieldedAddressEntity import org.dashfoundation.dashsdk.persistence.entities.PublicKeyEntity import org.dashfoundation.dashsdk.persistence.entities.ShieldedActivityEntity import org.dashfoundation.dashsdk.persistence.entities.ShieldedNoteEntity @@ -154,7 +156,8 @@ class PlatformWalletPersistenceHandler( CAPABILITY_WALLET_RESTORE or CAPABILITY_DPNS_NAME_STATES or CAPABILITY_TRACKED_ASSET_LOCKS or - CAPABILITY_CORE_SWEEP_REMOVAL + CAPABILITY_CORE_SWEEP_REMOVAL or + CAPABILITY_PRIVATE_CONTACT_REQUEST_SCAN_STATE /** * The single-thread executor created when no [dispatcher] is injected. @@ -808,6 +811,54 @@ class PlatformWalletPersistenceHandler( 0 } + /** + * Port of `PlatformWalletPersistenceHandler.swift`'s + * `persistPrivateContactScanState`: staged into the round, so the read + * position commits with, or rolls back with, the contact requests read + * before it. Identities the round removed lose theirs first, so a new + * position or address in the same round stands. + */ + override fun onPersistPrivateContactScanState( + walletId: ByteArray, + positions: ByteArray, + addresses: ByteArray, + removedAddresses: ByteArray, + clearedIdentities: ByteArray, + ): Int = guarded { + val positionRows = unpackPrivatePositions(walletId, positions) + val addressRows = unpackPrivateAddresses(walletId, addresses) + val removed = chunks(removedAddresses, NativePersistenceBridge.PRIVATE_ADDRESS_REMOVAL_ENTRY_SIZE) + .map { it.copyOfRange(0, 32) to it.copyOfRange(32, 64) } + val cleared = chunks(clearedIdentities, NativePersistenceBridge.PRIVATE_CLEARED_IDENTITY_ENTRY_SIZE) + stage(walletId) { db -> + // Drop stale post-deletion callbacks (can't resurrect a wallet). + db.walletDao().getByWalletId(walletId) ?: return@stage + val dao = db.privateContactScanStateDao() + for (identityId in cleared) { + dao.deletePositionsOfIdentity(walletId, identityId) + dao.deleteAddressesOfIdentity(walletId, identityId) + } + positionRows.forEach { dao.upsertPosition(it) } + addressRows.forEach { dao.upsertAddress(it) } + for ((ownerId, contactId) in removed) { + dao.deleteAddress(walletId, ownerId, contactId) + } + } + 0 + } + + /** The stored scan state of [walletId], packed as it was stored. */ + override fun onLoadPrivateContactScanState(walletId: ByteArray): Array = + loadOrThrow { + runBlockingResult { + val dao = database.privateContactScanStateDao() + arrayOf( + packPrivatePositions(dao.positionsByWallet(walletId)), + packPrivateAddresses(dao.addressesByWallet(walletId)), + ) + } + } + override fun onWalletChangesetChainLockHeight(walletId: ByteArray, height: Int): Int = guarded { val round = openRound(walletId) round?.finalityAdvanced = true @@ -3650,6 +3701,8 @@ class PlatformWalletPersistenceHandler( database.shieldedDao().deleteActivityByWallet(walletId) database.shieldedDao().deleteSyncStatesByWallet(walletId) database.shieldedDao().deleteViewingKeysByWallet(walletId) + database.privateContactScanStateDao().deletePositionsByWallet(walletId) + database.privateContactScanStateDao().deleteAddressesByWallet(walletId) // The wallet row itself — cascades accounts → core / platform // addresses. @@ -4135,6 +4188,9 @@ class PlatformWalletPersistenceHandler( internal const val CAPABILITY_CORE_SWEEP_REMOVAL: Long = NativePersistenceBridge.CAPABILITY_CORE_SWEEP_REMOVAL + internal const val CAPABILITY_PRIVATE_CONTACT_REQUEST_SCAN_STATE: Long = + NativePersistenceBridge.CAPABILITY_PRIVATE_CONTACT_REQUEST_SCAN_STATE + private const val TAG = "DashPersistence" /** @@ -4553,6 +4609,67 @@ internal fun contractBoundsJsonToId(blob: ByteArray): ByteArray? { private fun now(): java.util.Date = java.util.Date() private fun nowSeconds(): Long = System.currentTimeMillis() / 1000 +/** [packed] split into its rows of [size] bytes; a partial row is refused. */ +internal fun chunks(packed: ByteArray, size: Int): List { + require(packed.size % size == 0) { "packed rows must be $size bytes each" } + return (0 until packed.size / size).map { packed.copyOfRange(it * size, (it + 1) * size) } +} + +/** Rows of [NativePersistenceBridge.onPersistPrivateContactScanState]'s `positions`. */ +internal fun unpackPrivatePositions( + walletId: ByteArray, + packed: ByteArray, +): List = + chunks(packed, NativePersistenceBridge.PRIVATE_POSITION_ENTRY_SIZE).map { row -> + PrivateContactRequestsPositionEntity( + walletId = walletId.copyOf(), + identityId = row.copyOfRange(0, 32), + position = java.nio.ByteBuffer.wrap(row, 32, 8) + .order(java.nio.ByteOrder.LITTLE_ENDIAN).long, + keyFingerprint = row.copyOfRange(40, 72), + ) + } + +/** Rows of [NativePersistenceBridge.onPersistPrivateContactScanState]'s `addresses`. */ +internal fun unpackPrivateAddresses( + walletId: ByteArray, + packed: ByteArray, +): List = + chunks(packed, NativePersistenceBridge.PRIVATE_ADDRESS_ENTRY_SIZE).map { row -> + PrivateShieldedAddressEntity( + walletId = walletId.copyOf(), + ownerId = row.copyOfRange(0, 32), + contactId = row.copyOfRange(32, 64), + address = row.copyOfRange(64, 107), + ) + } + +/** + * [rows] packed as [NativePersistenceBridge.onLoadPrivateContactScanState] + * hands them back. A row whose stored bytes have the wrong length is + * skipped rather than handed back misaligned. + */ +internal fun packPrivatePositions(rows: List): ByteArray { + val kept = rows.filter { it.identityId.size == 32 && it.keyFingerprint.size == 32 } + val buffer = java.nio.ByteBuffer + .allocate(kept.size * NativePersistenceBridge.PRIVATE_POSITION_ENTRY_SIZE) + .order(java.nio.ByteOrder.LITTLE_ENDIAN) + for (row in kept) { + buffer.put(row.identityId).putLong(row.position).put(row.keyFingerprint) + } + return buffer.array() +} + +/** See [packPrivatePositions]. */ +internal fun packPrivateAddresses(rows: List): ByteArray { + val kept = rows.filter { it.ownerId.size == 32 && it.contactId.size == 32 && it.address.size == 43 } + val buffer = java.nio.ByteBuffer.allocate(kept.size * NativePersistenceBridge.PRIVATE_ADDRESS_ENTRY_SIZE) + for (row in kept) { + buffer.put(row.ownerId).put(row.contactId).put(row.address) + } + return buffer.array() +} + /** * Split a DIP-0018 bech32m platform address into `(addressType, 20-byte * hash)` — mirror of Swift `platformAddressComponents(fromBech32m:)`. diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/dao/PrivateContactScanStateDao.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/dao/PrivateContactScanStateDao.kt new file mode 100644 index 00000000000..3e452f5f3c5 --- /dev/null +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/dao/PrivateContactScanStateDao.kt @@ -0,0 +1,44 @@ +package org.dashfoundation.dashsdk.persistence.dao + +import androidx.room.Dao +import androidx.room.Query +import androidx.room.Upsert +import org.dashfoundation.dashsdk.persistence.entities.PrivateContactRequestsPositionEntity +import org.dashfoundation.dashsdk.persistence.entities.PrivateShieldedAddressEntity + +/** The private contact request sync's scan state, per wallet. */ +@Dao +interface PrivateContactScanStateDao { + @Upsert + suspend fun upsertPosition(row: PrivateContactRequestsPositionEntity) + + @Upsert + suspend fun upsertAddress(row: PrivateShieldedAddressEntity) + + @Query( + "DELETE FROM private_shielded_addresses " + + "WHERE walletId = :walletId AND ownerId = :ownerId AND contactId = :contactId", + ) + suspend fun deleteAddress(walletId: ByteArray, ownerId: ByteArray, contactId: ByteArray) + + @Query( + "DELETE FROM private_contact_requests_positions " + + "WHERE walletId = :walletId AND identityId = :identityId", + ) + suspend fun deletePositionsOfIdentity(walletId: ByteArray, identityId: ByteArray) + + @Query("DELETE FROM private_shielded_addresses WHERE walletId = :walletId AND ownerId = :ownerId") + suspend fun deleteAddressesOfIdentity(walletId: ByteArray, ownerId: ByteArray) + + @Query("SELECT * FROM private_contact_requests_positions WHERE walletId = :walletId") + suspend fun positionsByWallet(walletId: ByteArray): List + + @Query("SELECT * FROM private_shielded_addresses WHERE walletId = :walletId") + suspend fun addressesByWallet(walletId: ByteArray): List + + @Query("DELETE FROM private_contact_requests_positions WHERE walletId = :walletId") + suspend fun deletePositionsByWallet(walletId: ByteArray) + + @Query("DELETE FROM private_shielded_addresses WHERE walletId = :walletId") + suspend fun deleteAddressesByWallet(walletId: ByteArray) +} diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/entities/PrivateContactScanStateEntities.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/entities/PrivateContactScanStateEntities.kt new file mode 100644 index 00000000000..c2cabddcd23 --- /dev/null +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/entities/PrivateContactScanStateEntities.kt @@ -0,0 +1,46 @@ +package org.dashfoundation.dashsdk.persistence.entities + +import androidx.room.Entity + +/** + * Port of `PersistentPrivateContactRequestsPosition.swift`: where one of the + * wallet's identities reads DashPay's private contact request store from + * next, so a relaunch resumes the private contact request sync instead of + * reading the store from its start. Written in the same changeset round as + * the contact requests read before it. + * + * Keyed by the network-scoped wallet id; no foreign key, so the wallet + * deletion purges it explicitly (`deleteWalletDataLocked`), and the round + * that removes the identity clears it (`clearedIdentities`). + */ +@Entity( + tableName = "private_contact_requests_positions", + primaryKeys = ["walletId", "identityId"], +) +data class PrivateContactRequestsPositionEntity( + val walletId: ByteArray, + val identityId: ByteArray, + /** The store position read from next; a `u64` bit pattern. */ + val position: Long, + /** Fingerprint of the scanning keys [position] holds for; opaque here. */ + val keyFingerprint: ByteArray, +) + +/** + * Port of `PersistentPrivateShieldedAddress.swift`: the shielded address a + * private contact's tracked request to one of the wallet's identities + * carries, kept so a relaunch has it without reading the private store again. + */ +@Entity( + tableName = "private_shielded_addresses", + primaryKeys = ["walletId", "ownerId", "contactId"], +) +data class PrivateShieldedAddressEntity( + val walletId: ByteArray, + /** The wallet-owned identity the request is to. */ + val ownerId: ByteArray, + /** The contact who sent the request. */ + val contactId: ByteArray, + /** The raw 43-byte Orchard payment address. */ + val address: ByteArray, +) diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/PlatformWalletManager.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/PlatformWalletManager.kt index 35c333012e0..c60751e8d7c 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/PlatformWalletManager.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/PlatformWalletManager.kt @@ -79,6 +79,15 @@ data class PlatformWalletPersistenceCapabilities( * is deliberately absent: Android never attests it). */ const val CORE_SWEEP_REMOVAL: Long = NativePersistenceBridge.CAPABILITY_CORE_SWEEP_REMOVAL + /** + * The private contact request sync's scan state (each identity's + * read position in DashPay's private contact request store, and its + * private contacts' shielded addresses) is stored on its round and + * restored at load. Mirrors + * `PersistenceCapabilities::PRIVATE_CONTACT_REQUEST_SCAN_STATE`. + */ + const val PRIVATE_CONTACT_REQUEST_SCAN_STATE: Long = + NativePersistenceBridge.CAPABILITY_PRIVATE_CONTACT_REQUEST_SCAN_STATE } } diff --git a/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandlerTest.kt b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandlerTest.kt index 978b0c43e09..48f32164cbb 100644 --- a/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandlerTest.kt +++ b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandlerTest.kt @@ -220,7 +220,7 @@ class PlatformWalletPersistenceHandlerTest { assertEquals(0L, noOpBridge.persistenceCapabilitiesBits()) assertEquals(1, handler.persistenceCapabilitiesVersion()) - assertEquals(0xbbfL, handler.persistenceCapabilitiesBits()) + assertEquals(0x4bbfL, handler.persistenceCapabilitiesBits()) // Android has no pending-contact-crypto callback, so it must not // attest that semantic contract. assertEquals(0L, handler.persistenceCapabilitiesBits() and 0x40L) @@ -234,6 +234,9 @@ class PlatformWalletPersistenceHandlerTest { assertTrue(diagnostic.contains(PlatformWalletPersistenceCapabilities.DPNS_NAME_STATES)) assertTrue(diagnostic.contains(PlatformWalletPersistenceCapabilities.TRACKED_ASSET_LOCKS)) assertTrue(diagnostic.contains(PlatformWalletPersistenceCapabilities.CORE_SWEEP_REMOVAL)) + assertTrue( + diagnostic.contains(PlatformWalletPersistenceCapabilities.PRIVATE_CONTACT_REQUEST_SCAN_STATE), + ) } @Test diff --git a/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/persistence/PrivateContactScanStatePersistenceTest.kt b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/persistence/PrivateContactScanStatePersistenceTest.kt new file mode 100644 index 00000000000..013d1971d9b --- /dev/null +++ b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/persistence/PrivateContactScanStatePersistenceTest.kt @@ -0,0 +1,167 @@ +package org.dashfoundation.dashsdk.persistence + +import androidx.test.core.app.ApplicationProvider +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.test.runTest +import org.dashfoundation.dashsdk.ffi.NativePersistenceBridge +import org.junit.After +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +/** + * The private contact request scan state (read positions and private + * contacts' shielded addresses) round-trips through Room in the packed rows + * the JNI trampolines exchange. Port of + * `PrivateContactScanStatePersistenceTests.swift`. + */ +@RunWith(RobolectricTestRunner::class) +class PrivateContactScanStatePersistenceTest { + + private lateinit var db: DashDatabase + private lateinit var handler: PlatformWalletPersistenceHandler + + private val walletId = ByteArray(32) { 42 } + private val owner = ByteArray(32) { 7 } + private val contact = ByteArray(32) { 5 } + private val otherContact = ByteArray(32) { 6 } + private val fingerprint = ByteArray(32) { 3 } + + @Before + fun setUp() { + db = DashDatabase.createInMemory(ApplicationProvider.getApplicationContext()) + handler = PlatformWalletPersistenceHandler(db, Dispatchers.Unconfined) + assertEquals(0, handler.onPersistWalletMetadata(walletId, 1, ByteArray(32) { 2 }, 0)) + } + + @After + fun tearDown() { + db.close() + } + + private fun position(identityId: ByteArray, position: Long): ByteArray = + java.nio.ByteBuffer.allocate(NativePersistenceBridge.PRIVATE_POSITION_ENTRY_SIZE) + .order(java.nio.ByteOrder.LITTLE_ENDIAN) + .put(identityId).putLong(position).put(fingerprint) + .array() + + private fun address(ownerId: ByteArray, contactId: ByteArray, fill: Int): ByteArray = + ownerId + contactId + ByteArray(43) { fill.toByte() } + + private fun round( + positions: ByteArray = ByteArray(0), + addresses: ByteArray = ByteArray(0), + removed: ByteArray = ByteArray(0), + cleared: ByteArray = ByteArray(0), + success: Boolean = true, + ) { + assertEquals(0, handler.onChangesetBegin(walletId)) + assertEquals( + 0, + handler.onPersistPrivateContactScanState(walletId, positions, addresses, removed, cleared), + ) + assertEquals(0, handler.onChangesetEnd(walletId, success)) + } + + private fun loaded(): Pair>, List>> { + val (positions, addresses) = handler.onLoadPrivateContactScanState(walletId) + val positionRows = unpackPrivatePositions(walletId, positions) + .map { it.identityId.toHex() to it.position } + val addressRows = unpackPrivateAddresses(walletId, addresses) + .map { it.contactId.toHex() to it.address[0].toInt() } + return positionRows to addressRows + } + + @Test + fun shouldStoreReplaceAndRemoveTheScanStateWithItsRound() { + round( + positions = position(owner, 12), + addresses = address(owner, contact, 9) + address(owner, otherContact, 8), + ) + var (positions, addresses) = loaded() + assertEquals(listOf(owner.toHex() to 12L), positions) + assertEquals(setOf(contact.toHex() to 9, otherContact.toHex() to 8), addresses.toSet()) + + // A rewind writes the lower position; a tombstone removes one address. + round(positions = position(owner, 0), removed = owner + otherContact) + loaded().let { (p, a) -> positions = p; addresses = a } + assertEquals(listOf(owner.toHex() to 0L), positions) + assertEquals(listOf(contact.toHex() to 9), addresses) + + // A rolled-back round leaves the stored state as it was. + round(positions = position(owner, 40), success = false) + assertEquals(listOf(owner.toHex() to 0L), loaded().first) + + // A full-width position survives the signed column. + round(positions = position(owner, -1L)) + assertEquals(listOf(owner.toHex() to -1L), loaded().first) + val (packed, _) = handler.onLoadPrivateContactScanState(walletId) + assertArrayEquals(position(owner, -1L), packed) + } + + @Test + fun shouldClearTheScanStateOfARemovedIdentity() { + val survivor = ByteArray(32) { 1 } + round( + positions = position(owner, 12) + position(survivor, 4), + addresses = address(owner, contact, 9) + address(survivor, contact, 1), + ) + round(cleared = owner) + val (positions, addresses) = loaded() + assertEquals(listOf(survivor.toHex() to 4L), positions) + assertEquals(listOf(contact.toHex() to 1), addresses) + } + + @Test + fun shouldScopeTheScanStateByWalletAndPurgeItWithTheWallet() = runTest { + round(positions = position(owner, 12), addresses = address(owner, contact, 9)) + val (otherPositions, otherAddresses) = + handler.onLoadPrivateContactScanState(ByteArray(32) { 43 }) + assertEquals(0, otherPositions.size) + assertEquals(0, otherAddresses.size) + + handler.deleteWalletData(walletId) + val (positions, addresses) = handler.onLoadPrivateContactScanState(walletId) + assertEquals(0, positions.size) + assertEquals(0, addresses.size) + } + + /** A partial row fails the callback instead of storing a misread row. */ + @Test + fun shouldRefuseARoundWithAPartialRow() { + assertEquals(0, handler.onChangesetBegin(walletId)) + assertNotEquals( + 0, + handler.onPersistPrivateContactScanState( + walletId, ByteArray(NativePersistenceBridge.PRIVATE_POSITION_ENTRY_SIZE - 1), + ByteArray(0), ByteArray(0), ByteArray(0), + ), + ) + assertEquals(0, handler.onChangesetEnd(walletId, false)) + assertTrue(loaded().first.isEmpty()) + assertThrows(IllegalArgumentException::class.java) { + unpackPrivateAddresses(walletId, ByteArray(NativePersistenceBridge.PRIVATE_ADDRESS_ENTRY_SIZE + 1)) + } + } + + /** The inherited bridge stores nothing and loads two empty arrays. */ + @Test + fun aBridgeWithoutTheScanStateStoresNothingAndLoadsEmpty() { + val noOp = object : NativePersistenceBridge() {} + assertEquals(0L, noOp.persistenceCapabilitiesBits()) + assertEquals( + 0, + noOp.onPersistPrivateContactScanState( + walletId, position(owner, 1), ByteArray(0), ByteArray(0), ByteArray(0), + ), + ) + val (positions, addresses) = noOp.onLoadPrivateContactScanState(walletId) + assertEquals(0, positions.size + addresses.size) + } +} diff --git a/packages/rs-unified-sdk-jni/src/persistence.rs b/packages/rs-unified-sdk-jni/src/persistence.rs index b3b3b55d40d..cc469d508aa 100644 --- a/packages/rs-unified-sdk-jni/src/persistence.rs +++ b/packages/rs-unified-sdk-jni/src/persistence.rs @@ -58,10 +58,10 @@ use platform_wallet_ffi::{ DpnsNameStateFFI, IdentityEntryFFI, IdentityKeyEntryFFI, IdentityKeyRemovalFFI, IdentityKeyRestoreFFI, IdentityRestoreEntryFFI, InvitationEntryFFI, OutPointFFI, PaymentRestoreEntryFFI, PersistenceCallbacks, PersistenceCallbacksExtension, - PlatformAddressFFI, ProviderSpecialTxRestoreEntryFFI, SpentOutPointFFI, SweepBatchFFI, - TokenBalanceRemovalFFI, TokenBalanceUpsertFFI, TransactionRecordFFI, - UnresolvedAssetLockTxRecordFFI, UtxoEntryFFI, UtxoRestoreEntryFFI, WalletChangeSetFFI, - WalletRestoreEntryFFI, + PlatformAddressFFI, PrivateContactRequestsPositionFFI, PrivateShieldedAddressFFI, + ProviderSpecialTxRestoreEntryFFI, SpentOutPointFFI, SweepBatchFFI, TokenBalanceRemovalFFI, + TokenBalanceUpsertFFI, TransactionRecordFFI, UnresolvedAssetLockTxRecordFFI, UtxoEntryFFI, + UtxoRestoreEntryFFI, WalletChangeSetFFI, WalletRestoreEntryFFI, }; use std::ffi::{c_void, CStr, CString}; use std::os::raw::c_char; @@ -244,6 +244,17 @@ pub(crate) fn build_extension(env: &mut JNIEnv, bridge: &JObject) -> Persistence "onWalletChangesetDashPayBackfill", WALLET_CHANGESET_DASHPAY_BACKFILL_DESCRIPTOR, ), + private_contact_scan_state: bridge_overrides( + env, + bridge, + "onPersistPrivateContactScanState", + PERSIST_PRIVATE_CONTACT_SCAN_STATE_DESCRIPTOR, + ) && bridge_overrides( + env, + bridge, + "onLoadPrivateContactScanState", + LOAD_PRIVATE_CONTACT_SCAN_STATE_DESCRIPTOR, + ), }) } @@ -300,6 +311,10 @@ struct BridgeOverrides { header: bool, /// `onWalletChangesetDashPayBackfill`, which stores the record. backfill: bool, + /// Both `onPersistPrivateContactScanState` and + /// `onLoadPrivateContactScanState`: an inherited no-op store would + /// acknowledge a read position it never kept. + private_contact_scan_state: bool, } /// The extension slots wired for a bridge with `overrides` (see @@ -320,6 +335,15 @@ fn extension_for(overrides: BridgeOverrides) -> PersistenceCallbacksExtension { } else { None }, + on_persist_private_contact_scan_state_fn: overrides + .private_contact_scan_state + .then_some(tramp_persist_private_contact_scan_state), + on_load_private_contact_scan_state_fn: overrides + .private_contact_scan_state + .then_some(tramp_load_private_contact_scan_state), + on_load_private_contact_scan_state_free_fn: overrides + .private_contact_scan_state + .then_some(tramp_load_private_contact_scan_state_free), ..Default::default() } } @@ -1084,6 +1108,216 @@ unsafe extern "C" fn tramp_persist_wallet_dashpay_backfill( }) } +/// Packed `byte[]` row widths of the private contact request scan state, the +/// same flat packing the backfill cover set uses: a read position is +/// `identityId ‖ position (u64 LE) ‖ keyFingerprint`, a shielded address +/// `ownerId ‖ contactId ‖ address`, an address removal `ownerId ‖ +/// contactId`, a cleared identity its id. +pub(crate) const PRIVATE_POSITION_ENTRY_LEN: usize = 32 + 8 + 32; +pub(crate) const PRIVATE_ADDRESS_ENTRY_LEN: usize = 32 + 32 + 43; +pub(crate) const PRIVATE_ADDRESS_REMOVAL_ENTRY_LEN: usize = 32 + 32; + +/// Descriptor of `NativePersistenceBridge.onPersistPrivateContactScanState`: +/// `(walletId, positions, addresses, removedAddresses, clearedIdentities)`. +const PERSIST_PRIVATE_CONTACT_SCAN_STATE_DESCRIPTOR: &str = "([B[B[B[B[B)I"; + +/// Descriptor of `NativePersistenceBridge.onLoadPrivateContactScanState`: +/// `(walletId) -> [positions, addresses]`. +const LOAD_PRIVATE_CONTACT_SCAN_STATE_DESCRIPTOR: &str = "([B)[[B"; + +fn pack_private_positions(rows: &[PrivateContactRequestsPositionFFI]) -> Vec { + let mut packed = Vec::with_capacity(rows.len() * PRIVATE_POSITION_ENTRY_LEN); + for row in rows { + packed.extend_from_slice(&row.identity_id); + packed.extend_from_slice(&row.position.to_le_bytes()); + packed.extend_from_slice(&row.key_fingerprint); + } + packed +} + +fn pack_private_addresses(rows: &[PrivateShieldedAddressFFI]) -> Vec { + let mut packed = Vec::with_capacity(rows.len() * PRIVATE_ADDRESS_ENTRY_LEN); + for row in rows { + packed.extend_from_slice(&row.owner_id); + packed.extend_from_slice(&row.contact_id); + packed.extend_from_slice(&row.address); + } + packed +} + +/// The rows packed in `packed`, or `None` when its length is not a whole +/// number of rows. +fn unpack_private_positions(packed: &[u8]) -> Option> { + let (rows, rest) = packed.as_chunks::(); + if !rest.is_empty() { + return None; + } + rows.iter() + .map(|row| { + Some(PrivateContactRequestsPositionFFI { + identity_id: row[..32].try_into().ok()?, + position: u64::from_le_bytes(row[32..40].try_into().ok()?), + key_fingerprint: row[40..].try_into().ok()?, + }) + }) + .collect() +} + +/// The rows packed in `packed`, or `None` when its length is not a whole +/// number of rows. +fn unpack_private_addresses(packed: &[u8]) -> Option> { + let (rows, rest) = packed.as_chunks::(); + if !rest.is_empty() { + return None; + } + rows.iter() + .map(|row| { + Some(PrivateShieldedAddressFFI { + owner_id: row[..32].try_into().ok()?, + contact_id: row[32..64].try_into().ok()?, + address: row[64..].try_into().ok()?, + }) + }) + .collect() +} + +/// Deliver a round's private contact request scan state (see +/// `PersistPrivateContactScanStateFn`) as four packed arrays. +#[allow(clippy::too_many_arguments)] +unsafe extern "C" fn tramp_persist_private_contact_scan_state( + context: *mut c_void, + wallet_id: *const u8, + positions: *const PrivateContactRequestsPositionFFI, + positions_count: usize, + addresses: *const PrivateShieldedAddressFFI, + addresses_count: usize, + removed_addresses: *const ContactRequestRemovalFFI, + removed_addresses_count: usize, + cleared_identities: *const [u8; 32], + cleared_identities_count: usize, +) -> i32 { + with_bridge(context, |env, bridge| { + let wid = id32(env, wallet_id)?; + let positions = env.byte_array_from_slice(&pack_private_positions(slice_or_empty( + positions, + positions_count, + )))?; + let addresses = env.byte_array_from_slice(&pack_private_addresses(slice_or_empty( + addresses, + addresses_count, + )))?; + let removed_rows = slice_or_empty(removed_addresses, removed_addresses_count); + let mut removed = + Vec::with_capacity(removed_rows.len() * PRIVATE_ADDRESS_REMOVAL_ENTRY_LEN); + for row in removed_rows { + removed.extend_from_slice(&row.owner_id); + removed.extend_from_slice(&row.contact_id); + } + let removed = env.byte_array_from_slice(&removed)?; + let cleared: Vec = slice_or_empty(cleared_identities, cleared_identities_count) + .iter() + .flatten() + .copied() + .collect(); + let cleared = env.byte_array_from_slice(&cleared)?; + env.call_method( + bridge, + "onPersistPrivateContactScanState", + PERSIST_PRIVATE_CONTACT_SCAN_STATE_DESCRIPTOR, + &[ + (&wid).into(), + (&positions).into(), + (&addresses).into(), + (&removed).into(), + (&cleared).into(), + ], + )? + .i() + }) +} + +/// Hand Rust a wallet's stored scan state (see +/// `LoadPrivateContactScanStateFn`). The rows are staged in owned buffers +/// and minted as raw arrays only once the whole read succeeded; a failed +/// read, an exception or a malformed array returns `ERR_JNI`, which fails +/// the load rather than reading the store from its start. +unsafe extern "C" fn tramp_load_private_contact_scan_state( + context: *mut c_void, + wallet_id: *const u8, + out_positions: *mut *const PrivateContactRequestsPositionFFI, + out_positions_count: *mut usize, + out_addresses: *mut *const PrivateShieldedAddressFFI, + out_addresses_count: *mut usize, +) -> i32 { + *out_positions = ptr::null(); + *out_positions_count = 0; + *out_addresses = ptr::null(); + *out_addresses_count = 0; + let loaded = with_bridge_load(context, |env, bridge| { + let wid = id32(env, wallet_id)?; + let arrays: JObjectArray = env + .call_method( + bridge, + "onLoadPrivateContactScanState", + LOAD_PRIVATE_CONTACT_SCAN_STATE_DESCRIPTOR, + &[(&wid).into()], + )? + .l()? + .into(); + if env.get_array_length(&arrays)? != 2 { + return Err(jni::errors::Error::JniCall( + jni::errors::JniError::InvalidArguments, + )); + } + let positions: JByteArray = env.get_object_array_element(&arrays, 0)?.into(); + let addresses: JByteArray = env.get_object_array_element(&arrays, 1)?.into(); + Ok(( + env.convert_byte_array(&positions)?, + env.convert_byte_array(&addresses)?, + )) + }); + let Some((positions, addresses)) = loaded else { + return ERR_JNI; + }; + let (Some(positions), Some(addresses)) = ( + unpack_private_positions(&positions), + unpack_private_addresses(&addresses), + ) else { + return ERR_JNI; + }; + if !positions.is_empty() { + *out_positions_count = positions.len(); + *out_positions = Box::into_raw(positions.into_boxed_slice()) as *const _; + } + if !addresses.is_empty() { + *out_addresses_count = addresses.len(); + *out_addresses = Box::into_raw(addresses.into_boxed_slice()) as *const _; + } + 0 +} + +/// Release the arrays [`tramp_load_private_contact_scan_state`] minted. +unsafe extern "C" fn tramp_load_private_contact_scan_state_free( + _context: *mut c_void, + positions: *const PrivateContactRequestsPositionFFI, + positions_count: usize, + addresses: *const PrivateShieldedAddressFFI, + addresses_count: usize, +) { + if !positions.is_null() { + drop(Box::from_raw(ptr::slice_from_raw_parts_mut( + positions.cast_mut(), + positions_count, + ))); + } + if !addresses.is_null() { + drop(Box::from_raw(ptr::slice_from_raw_parts_mut( + addresses.cast_mut(), + addresses_count, + ))); + } +} + unsafe fn persist_changeset_account( env: &mut JNIEnv, bridge: &JObject, @@ -5136,6 +5370,7 @@ mod tests { sweeps: false, header, backfill, + private_contact_scan_state: false, }); assert_eq!( extension.on_persist_wallet_dashpay_backfill_fn.is_some(), @@ -5147,11 +5382,90 @@ mod tests { sweeps: true, header: false, backfill: false, + private_contact_scan_state: false, }); assert!(sweeps_only.on_persist_wallet_changeset_sweeps_fn.is_some()); assert!(sweeps_only.on_persist_wallet_dashpay_backfill_fn.is_none()); } + /// The scan-state trio is wired only for a bridge that implements both + /// its store and its load; one that inherits either gets none of it, so + /// `FFIPersister` does not attest the capability and each launch reads + /// the private store from its start. + #[test] + fn the_scan_state_slots_are_wired_only_for_a_bridge_that_stores_and_loads_it() { + for wired in [false, true] { + let extension = extension_for(BridgeOverrides { + sweeps: false, + header: false, + backfill: false, + private_contact_scan_state: wired, + }); + assert_eq!( + extension.on_persist_private_contact_scan_state_fn.is_some(), + wired + ); + assert_eq!( + extension.on_load_private_contact_scan_state_fn.is_some(), + wired + ); + assert_eq!( + extension + .on_load_private_contact_scan_state_free_fn + .is_some(), + wired + ); + } + let names = |names: &[&str]| names.iter().map(|n| n.to_string()).collect::>(); + assert_eq!( + method_descriptor(&names(&["[B", "[B", "[B", "[B", "[B"]), "int"), + PERSIST_PRIVATE_CONTACT_SCAN_STATE_DESCRIPTOR + ); + assert_eq!( + method_descriptor(&names(&["[B"]), "[[B"), + LOAD_PRIVATE_CONTACT_SCAN_STATE_DESCRIPTOR + ); + } + + /// A read position and a shielded address survive the packed arrays the + /// bridge stores and hands back, a full-width position included; an + /// array that is not a whole number of rows is refused. + #[test] + fn the_scan_state_rows_round_trip_through_their_packed_arrays() { + let positions = [ + PrivateContactRequestsPositionFFI { + identity_id: [7; 32], + position: u64::MAX, + key_fingerprint: [3; 32], + }, + PrivateContactRequestsPositionFFI { + identity_id: [8; 32], + position: 12, + key_fingerprint: [4; 32], + }, + ]; + let addresses = [PrivateShieldedAddressFFI { + owner_id: [7; 32], + contact_id: [5; 32], + address: [9; 43], + }]; + let packed = pack_private_positions(&positions); + assert_eq!(packed.len(), 2 * PRIVATE_POSITION_ENTRY_LEN); + assert_eq!( + unpack_private_positions(&packed).as_deref(), + Some(&positions[..]) + ); + let packed = pack_private_addresses(&addresses); + assert_eq!(packed.len(), PRIVATE_ADDRESS_ENTRY_LEN); + assert_eq!( + unpack_private_addresses(&packed).as_deref(), + Some(&addresses[..]) + ); + assert_eq!(unpack_private_positions(&[]).as_deref(), Some(&[][..])); + assert!(unpack_private_positions(&[0; PRIVATE_POSITION_ENTRY_LEN + 1]).is_none()); + assert!(unpack_private_addresses(&[0; PRIVATE_ADDRESS_ENTRY_LEN - 1]).is_none()); + } + /// A bridge inheriting the no-op account writers gets neither account /// slot, so `FFIPersister` cannot derive `ACCOUNT_ADDRESS_POOLS` and does /// not keep outbound-account markers on disk; one implementing both keeps From bb6b8eca070739a9bed24f581ce4e6fb530b80b1 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 9 Nov 2026 09:57:08 +0700 Subject: [PATCH 102/269] docs(kotlin-sdk): attach the in-kind accept KDoc to its declaration The block documenting `acceptContactRequestReplyingInKind` sat above `managedIdentityPrivateContact`; it now sits directly above the accept's `@Suppress`. Co-Authored-By: Claude Opus 5.5 --- .../dashfoundation/dashsdk/ffi/DashpayNative.kt | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/DashpayNative.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/DashpayNative.kt index 3d1a97ce53f..90583e0f03f 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/DashpayNative.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/DashpayNative.kt @@ -328,14 +328,6 @@ internal object DashpayNative { signerHandle: Long, ): Long - /** - * Accept the incoming contact request [requestHandle] replying the way - * it came: privately, paid from [account]'s shielded notes, when it came - * privately and no reply exists yet; otherwise with a document, or by - * adopting the reply already sent. The wallet decides. Arguments and - * result as for [acceptPrivateContactRequest]. - * ← Swift `PlatformWalletManager.acceptContactRequestReplyingInKind`. - */ /** * What a managed identity's private contact requests with [contactId] * carry: a JSON object (`receivedPrivately`, `sentPrivately`, @@ -353,6 +345,14 @@ internal object DashpayNative { */ external fun managedIdentityPrivateReplyPending(identityHandle: Long, contactId: ByteArray): Boolean + /** + * Accept the incoming contact request [requestHandle] replying the way + * it came: privately, paid from [account]'s shielded notes, when it came + * privately and no reply exists yet; otherwise with a document, or by + * adopting the reply already sent. The wallet decides. Arguments and + * result as for [acceptPrivateContactRequest]. + * ← Swift `PlatformWalletManager.acceptContactRequestReplyingInKind`. + */ @Suppress("LongParameterList") external fun acceptContactRequestReplyingInKind( managerHandle: Long, From 8dd88f80d0d987a0ef36bdedf4f3693b1d984545 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 9 Nov 2026 09:57:08 +0700 Subject: [PATCH 103/269] fix(kotlin-example-app): keep a failed pending read apart from nothing pending `privateRepliesPending` caught a failed wallet read and returned an empty set, so Requests brought back Accept and Ignore, and Add Contact enabled Send, with nothing showing the pending request had cleared. The read now returns null on a failure, and `PrivateRepliesPendingSnapshot` keeps the last successful snapshot and leaves those controls off for every contact until a read covering it succeeds. An unmanaged identity still reads as nothing pending. A pending private request (code 71), which the first public send or accept since load can now meet, gets its own outcome: nothing was sent, so Add Contact shows it as pending and offers "Sync private requests", after which Send comes back for the user to try again. Port of the Swift change on #5812. Co-Authored-By: Claude Opus 5.5 --- .../example/ui/dashpay/AddContactScreen.kt | 71 ++++++++++++--- .../ui/dashpay/ContactRequestOutcomes.kt | 89 +++++++++++++++---- .../ui/dashpay/ContactRequestsScreen.kt | 35 ++++---- .../example/ui/dashpay/DashPaySyncHelpers.kt | 28 +++--- .../ui/dashpay/ContactRequestOutcomesTest.kt | 59 +++++++++++- 5 files changed, 225 insertions(+), 57 deletions(-) diff --git a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/AddContactScreen.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/AddContactScreen.kt index 9c47c5149c6..883b985fa84 100644 --- a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/AddContactScreen.kt +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/AddContactScreen.kt @@ -104,9 +104,11 @@ fun AddContactScreen(identityIdHex: String, navController: NavHostController) { var unconfirmedNotice by remember { mutableStateOf(null) } // Whether the wallet has a private request to the resolved recipient in // flight, or paid and not yet read back (`privateRepliesPending`). While - // it does, Send and the collision accept are off; a private sync settles - // it. Read from the wallet, so reopening this screen keeps it. - var recipientPending by remember { mutableStateOf(false) } + // it does, Send and the collision accept are off and the screen offers a + // private sync, which settles it. Read from the wallet, so reopening this + // screen keeps it. A failed read keeps them off until a read succeeds. + var recipientPending by remember { mutableStateOf(PrivateRepliesPendingSnapshot()) } + var isSyncingPrivate by remember { mutableStateOf(false) } var pendingReadVersion by remember { mutableStateOf(0) } var collisionRecipient by remember { mutableStateOf(null) } var previewProfile by remember { mutableStateOf(null) } @@ -118,7 +120,9 @@ fun AddContactScreen(identityIdHex: String, navController: NavHostController) { } val recipientIsSelf = resolvedRecipient?.contentEquals(idBytes) == true val canSend = resolvedRecipient != null && !recipientIsSelf && !isSending && - unconfirmedNotice == null && !recipientPending + unconfirmedNotice == null && recipientPending.allowsSubmission(resolvedRecipient.toHex()) + val recipientBlocked = resolvedRecipient != null && + (recipientPending.isPending(resolvedRecipient.toHex()) || recipientPending.isUnavailable) // Scan-to-send: consume the QR string handed back by the scanner screen // (the house pattern — observe the saved-state flow, then null it out). @@ -180,10 +184,11 @@ fun AddContactScreen(identityIdHex: String, navController: NavHostController) { // identities won't have one). val recipientHex = resolvedRecipient?.toHex() LaunchedEffect(recipientHex, wallet, pendingReadVersion) { - val recipient = resolvedRecipient - val w = wallet - recipientPending = recipient != null && w != null && - recipient.toHex() in privateRepliesPending(w, idBytes, listOf(recipient)) + val contactIds = listOfNotNull(resolvedRecipient) + recipientPending = recipientPending.recorded( + privateRepliesPending(wallet, idBytes, contactIds), + contactIds.map { it.toHex() }, + ) } LaunchedEffect(recipientHex) { val recipient = resolvedRecipient @@ -221,9 +226,38 @@ fun AddContactScreen(identityIdHex: String, navController: NavHostController) { kickDashPaySync(scope, m) navController.popBackStack() } catch (e: Exception) { - errorMessage = e.message ?: "Send failed" + when (val outcome = ContactRequestSubmissionOutcome.of(e)) { + // Nothing was sent: the screen shows the pending request + // and offers the private sync that settles it. + ContactRequestSubmissionOutcome.PrivateRequestPending -> Unit + ContactRequestSubmissionOutcome.MayHaveGoneThrough -> { + kickDashPaySync(scope, m) + unconfirmedNotice = ContactRequestSubmissionOutcome.MAY_HAVE_GONE_THROUGH_MESSAGE + } + is ContactRequestSubmissionOutcome.Failed -> errorMessage = outcome.message + } } finally { isSending = false + pendingReadVersion++ + } + } + } + + /** + * Read the private store, which settles a pending private request, then + * re-read the recipient's pending state: Send comes back once nothing is + * pending, for the user to try again. + */ + fun syncPrivateRequests() { + val w = wallet ?: return + val m = manager ?: return + isSyncingPrivate = true + scope.launch { + try { + syncPrivateContactRequests(w, m)?.let { errorMessage = it } + } finally { + isSyncingPrivate = false + pendingReadVersion++ } } } @@ -231,8 +265,8 @@ fun AddContactScreen(identityIdHex: String, navController: NavHostController) { fun acceptIncoming(recipient: ByteArray) { val w = wallet ?: return val m = manager ?: return - // A private reply to this contact is already pending in the wallet. - if (recipientPending) return + // A private reply to this contact may be pending in the wallet. + if (!recipientPending.allowsSubmission(recipient.toHex())) return isSending = true errorMessage = null scope.launch { @@ -257,6 +291,8 @@ fun AddContactScreen(identityIdHex: String, navController: NavHostController) { kickDashPaySync(scope, m) unconfirmedNotice = ContactRequestSubmissionOutcome.MAY_HAVE_GONE_THROUGH_MESSAGE } + // Nothing was sent: the screen offers the private sync. + ContactRequestSubmissionOutcome.PrivateRequestPending -> Unit is ContactRequestSubmissionOutcome.Failed -> errorMessage = "Accept failed: ${outcome.message}" } @@ -362,12 +398,21 @@ fun AddContactScreen(identityIdHex: String, navController: NavHostController) { enabled = canSend, modifier = Modifier.fillMaxWidth().testTag("dashpay.addContact.send"), ) { attemptSend() } - if (recipientPending) { + if (recipientBlocked) { Text( - ContactRequestSubmissionOutcome.MAY_HAVE_GONE_THROUGH_MESSAGE, + ContactRequestSubmissionOutcome.rowMessage( + pending = recipientPending.isPending(recipient.toHex()), + unavailable = recipientPending.isUnavailable, + error = null, + ).orEmpty(), style = MaterialTheme.typography.bodySmall, modifier = Modifier.testTag("dashpay.addContact.pending"), ) + TextButton( + onClick = { syncPrivateRequests() }, + enabled = !isSyncingPrivate, + modifier = Modifier.testTag("dashpay.addContact.syncPrivate"), + ) { Text(if (isSyncingPrivate) "Syncing private requests…" else "Sync private requests") } } } diff --git a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/ContactRequestOutcomes.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/ContactRequestOutcomes.kt index eeb67a10f60..e0c2cafe1a7 100644 --- a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/ContactRequestOutcomes.kt +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/ContactRequestOutcomes.kt @@ -3,20 +3,25 @@ package org.dashfoundation.example.ui.dashpay import org.dashfoundation.dashsdk.errors.DashSdkError /** - * How a contact request accept that threw ended, for the DashPay screens. - * Follows the send screen (and iOS `SendViewModel.swift`): a shielded spend - * whose result could not be confirmed may already be on chain (Rust keeps its - * notes reserved), so the screen keeps it pending and never offers a fresh - * submission; the next sync settles it. A private request Rust reports as - * pending (one to the same contact in flight, or paid and not yet read back) - * is the same outcome: nothing new was paid, and the next private sync - * settles it. Every other error is an ordinary failure the user may retry. + * How a contact request send or accept that threw ended, for the DashPay + * screens. Follows the send screen (and iOS `SendViewModel.swift`): a shielded + * spend whose result could not be confirmed may already be on chain (Rust + * keeps its notes reserved), so the screen keeps it pending and never offers a + * fresh submission; the next sync settles it. A private request Rust reports + * as pending (one to the same contact in flight, or paid and not yet read + * back), which a public send or accept can also meet, sent nothing new: the + * screen shows it as pending and offers a private sync, which settles it, + * after which the user may try again. Every other error is an ordinary + * failure the user may retry. * ← Swift `ContactRequestSubmissionOutcome` (`ContactRequestOutcomes.swift`). */ sealed interface ContactRequestSubmissionOutcome { /** The private reply may have gone through: do not submit again. */ data object MayHaveGoneThrough : ContactRequestSubmissionOutcome + /** A private request to the contact is pending in the wallet; nothing was sent. */ + data object PrivateRequestPending : ContactRequestSubmissionOutcome + /** It failed; the message is the error's. */ data class Failed(val message: String) : ContactRequestSubmissionOutcome @@ -24,20 +29,74 @@ sealed interface ContactRequestSubmissionOutcome { const val MAY_HAVE_GONE_THROUGH_MESSAGE = "This may have gone through. It shows up " + "after the next sync confirms it; do not submit it again." + const val PRIVATE_REQUEST_PENDING_MESSAGE = "A private request to this contact is " + + "pending. Sync private requests to settle it, then try again if it did not " + + "go through." + fun of(error: Throwable): ContactRequestSubmissionOutcome = when (error) { - is DashSdkError.PlatformWallet.ShieldedSpendUnconfirmed, - is DashSdkError.PlatformWallet.PrivateContactRequestPending, - -> MayHaveGoneThrough + is DashSdkError.PlatformWallet.ShieldedSpendUnconfirmed -> MayHaveGoneThrough + is DashSdkError.PlatformWallet.PrivateContactRequestPending -> PrivateRequestPending else -> Failed(error.message ?: "unknown error") } /** * The message a request row shows: the pending notice while the - * wallet has a private reply to the contact pending, whatever error an - * earlier attempt left, and that error otherwise. + * wallet has a private request to the contact pending, the + * unavailable notice while the wallet could not be read, whatever + * error an earlier attempt left, and that error otherwise. */ - fun rowMessage(pending: Boolean, error: String?): String? = - if (pending) MAY_HAVE_GONE_THROUGH_MESSAGE else error + fun rowMessage(pending: Boolean, unavailable: Boolean = false, error: String?): String? = when { + pending -> PRIVATE_REQUEST_PENDING_MESSAGE + unavailable -> PrivateRepliesPendingSnapshot.UNAVAILABLE_MESSAGE + else -> error + } + } +} + +/** + * Which contacts (lower hex ids) have a private request pending in the + * wallet, as the screens last read it (`privateRepliesPending`). While one + * is, Rust refuses another send or reply to that contact, so the screens turn + * off what would submit one (Accept, Ignore, Send). A failed read is not + * "nothing pending": the last successful snapshot stays, and those controls + * stay off for every contact until a read covering it succeeds. + * ← Swift `PrivateRepliesPendingSnapshot` (`ContactRequestOutcomes.swift`). + */ +data class PrivateRepliesPendingSnapshot( + /** The pending contacts the last successful read found. */ + val pending: Set = emptySet(), + /** The contacts the last successful read covered. */ + val covered: Set = emptySet(), + /** False after a failed read, until a read succeeds. */ + val available: Boolean = true, +) { + /** Whether the last read failed. */ + val isUnavailable: Boolean get() = !available + + /** + * This snapshot after a read of [contactIds]: [read] holds the pending + * ones, or is null when the read failed. + */ + fun recorded(read: Set?, contactIds: Collection): PrivateRepliesPendingSnapshot = + if (read == null) { + copy(available = false) + } else { + PrivateRepliesPendingSnapshot(read, contactIds.toSet(), available = true) + } + + /** Whether the last successful read found a private request to [contactId] pending. */ + fun isPending(contactId: String): Boolean = contactId in pending + + /** + * Whether a send or reply to [contactId] may be offered: a successful + * read covered it and found nothing pending. + */ + fun allowsSubmission(contactId: String): Boolean = + available && contactId in covered && contactId !in pending + + companion object { + const val UNAVAILABLE_MESSAGE = "Could not read whether a private request to this " + + "contact is pending. Sync private requests to check again." } } diff --git a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/ContactRequestsScreen.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/ContactRequestsScreen.kt index 817e30e67d6..976b7f91435 100644 --- a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/ContactRequestsScreen.kt +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/ContactRequestsScreen.kt @@ -97,8 +97,10 @@ fun ContactRequestsScreen(identityIdHex: String, navController: NavHostControlle // pending, without its buttons, until a private sync settles it. Read // from the wallet, not kept by this screen, so leaving and reopening it // keeps the state. Re-read on composition and row changes, after an - // accept, on refresh and after each sync pass. - var walletPendingIds by remember { mutableStateOf(emptySet()) } + // accept, on refresh and after each sync pass. A failed read keeps the + // last snapshot and leaves every row without its buttons until a read + // succeeds. + var walletPending by remember { mutableStateOf(PrivateRepliesPendingSnapshot()) } var pendingReadVersion by remember { mutableStateOf(0) } // What the last private-store refresh has to tell the user, if anything. var privateRefreshNotice by remember { mutableStateOf(null) } @@ -158,16 +160,13 @@ fun ContactRequestsScreen(identityIdHex: String, navController: NavHostControlle } LaunchedEffect(wallet, rows, pendingReadVersion) { - val w = wallet - walletPendingIds = if (w == null) { - emptySet() - } else { - privateRepliesPending( - wallet = w, - ourIdentityId = idBytes, - contactIds = rows.filter { !it.isOutgoing }.map { it.contactIdentityId }, - ) - } + val contactIds = rows.filter { !it.isOutgoing }.map { it.contactIdentityId } + val read = privateRepliesPending( + wallet = wallet, + ourIdentityId = idBytes, + contactIds = contactIds, + ) + walletPending = walletPending.recorded(read, contactIds.map { it.toHex() }) } LaunchedEffect(manager) { manager?.dashPaySyncIsSyncing?.collect { syncing -> if (!syncing) pendingReadVersion++ } @@ -195,10 +194,13 @@ fun ContactRequestsScreen(identityIdHex: String, navController: NavHostControlle } } catch (e: Exception) { when (val outcome = ContactRequestSubmissionOutcome.of(e)) { - ContactRequestSubmissionOutcome.MayHaveGoneThrough -> { + ContactRequestSubmissionOutcome.MayHaveGoneThrough, + ContactRequestSubmissionOutcome.PrivateRequestPending, + -> { // As the send screen treats an unconfirmed spend: not // a failure to retry. The wallet keeps the reply - // pending, and the row reads that. + // pending, and the row reads that; a sync (pull to + // refresh reads the private store) settles it. kickDashPaySync(scope, m) } is ContactRequestSubmissionOutcome.Failed -> @@ -285,9 +287,10 @@ fun ContactRequestsScreen(identityIdHex: String, navController: NavHostControlle val hex = row.contactId.toHex() IncomingRequestRow( item = row, - isInFlight = inFlightIds.contains(hex) || walletPendingIds.contains(hex), + isInFlight = inFlightIds.contains(hex) || !walletPending.allowsSubmission(hex), errorMessage = ContactRequestSubmissionOutcome.rowMessage( - pending = walletPendingIds.contains(hex), + pending = walletPending.isPending(hex), + unavailable = walletPending.isUnavailable, error = rowErrors[hex], ), onAccept = { accept(row.contactId) }, diff --git a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/DashPaySyncHelpers.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/DashPaySyncHelpers.kt index 658fe499add..2b83428721e 100644 --- a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/DashPaySyncHelpers.kt +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/DashPaySyncHelpers.kt @@ -39,22 +39,28 @@ fun kickDashPaySync(scope: CoroutineScope, manager: PlatformWalletManager) { /** * The hex ids of the contacts among [contactIds] that [ourIdentityId] has a * private request to in flight, or paid and not yet read back, as the wallet - * records it (one snapshot). While one is, Rust refuses another private send - * or reply to it, so the screens show it as pending and offer neither; a - * private sync settles it. Empty when the read fails or the identity is not - * managed. + * records it (one snapshot). While one is, Rust refuses another send or reply + * to it, so the screens show it as pending and offer neither; a private sync + * settles it. Empty when the identity is not managed, as the wallet then + * holds nothing for it. Null when the wallet could not be read (no wallet, + * or a read failed), which the screens keep apart from "nothing pending" + * ([PrivateRepliesPendingSnapshot]). * ← Swift `privateRepliesPending` (`ContactsView.swift`). */ suspend fun privateRepliesPending( - wallet: ManagedPlatformWallet, + wallet: ManagedPlatformWallet?, ourIdentityId: ByteArray, contactIds: List, -): Set = try { - wallet.dashpay.privateRepliesPending(ourIdentityId, contactIds).map { it.toHex() }.toSet() -} catch (e: CancellationException) { - throw e -} catch (e: Exception) { - emptySet() +): Set? { + if (contactIds.isEmpty()) return emptySet() + if (wallet == null) return null + return try { + wallet.dashpay.privateRepliesPending(ourIdentityId, contactIds).map { it.toHex() }.toSet() + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + null + } } /** diff --git a/packages/kotlin-sdk/KotlinExampleApp/app/src/test/java/org/dashfoundation/example/ui/dashpay/ContactRequestOutcomesTest.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/test/java/org/dashfoundation/example/ui/dashpay/ContactRequestOutcomesTest.kt index 1a1457161d0..1d1cffae97f 100644 --- a/packages/kotlin-sdk/KotlinExampleApp/app/src/test/java/org/dashfoundation/example/ui/dashpay/ContactRequestOutcomesTest.kt +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/test/java/org/dashfoundation/example/ui/dashpay/ContactRequestOutcomesTest.kt @@ -2,7 +2,9 @@ package org.dashfoundation.example.ui.dashpay import org.dashfoundation.dashsdk.errors.DashSdkError import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue import org.junit.Test /** @@ -21,11 +23,16 @@ class ContactRequestOutcomesTest { ) } + /** + * A pending private request (code 71), which a public send or accept can + * meet too, sent nothing: it is shown as pending, with a private sync to + * settle it, rather than as a submission that may have gone through. + */ @Test fun shouldKeepAPendingPrivateRequestPending() { val error = DashSdkError.PlatformWallet.PrivateContactRequestPending("not yet read back from the store") assertEquals( - ContactRequestSubmissionOutcome.MayHaveGoneThrough, + ContactRequestSubmissionOutcome.PrivateRequestPending, ContactRequestSubmissionOutcome.of(error), ) } @@ -33,16 +40,64 @@ class ContactRequestOutcomesTest { @Test fun shouldShowAWalletPendingReplyOverAnEarlierError() { assertEquals( - ContactRequestSubmissionOutcome.MAY_HAVE_GONE_THROUGH_MESSAGE, + ContactRequestSubmissionOutcome.PRIVATE_REQUEST_PENDING_MESSAGE, ContactRequestSubmissionOutcome.rowMessage(pending = true, error = "Accept failed: boom"), ) assertEquals( "Accept failed: boom", ContactRequestSubmissionOutcome.rowMessage(pending = false, error = "Accept failed: boom"), ) + assertEquals( + PrivateRepliesPendingSnapshot.UNAVAILABLE_MESSAGE, + ContactRequestSubmissionOutcome.rowMessage( + pending = false, + unavailable = true, + error = "Accept failed: boom", + ), + ) assertNull(ContactRequestSubmissionOutcome.rowMessage(pending = false, error = null)) } + /** + * A read that found a private request pending, followed by a failed + * read: the contact stays pending and nothing is offered for any + * contact, until a read succeeds again. + */ + @Test + fun shouldKeepThePendingSnapshotAndOfferNothingAfterAFailedRead() { + val contacts = listOf("01", "02") + var snapshot = PrivateRepliesPendingSnapshot().recorded(setOf("01"), contacts) + assertTrue(snapshot.isPending("01")) + assertFalse(snapshot.allowsSubmission("01")) + assertTrue(snapshot.allowsSubmission("02")) + + snapshot = snapshot.recorded(null, contacts) + assertTrue(snapshot.isUnavailable) + assertTrue("the last snapshot stays", snapshot.isPending("01")) + assertFalse(snapshot.allowsSubmission("01")) + assertFalse(snapshot.allowsSubmission("02")) + + snapshot = snapshot.recorded(emptySet(), contacts) + assertFalse(snapshot.isUnavailable) + assertTrue(snapshot.allowsSubmission("01")) + } + + /** + * A successful read that found nothing pending offers every contact it + * covered, and nothing it did not. + */ + @Test + fun shouldOfferEveryContactASuccessfulEmptyReadCovered() { + val snapshot = PrivateRepliesPendingSnapshot().recorded(emptySet(), listOf("01")) + assertFalse(snapshot.isUnavailable) + assertFalse(snapshot.isPending("01")) + assertTrue(snapshot.allowsSubmission("01")) + assertFalse( + "a contact the read did not cover is not offered yet", + snapshot.allowsSubmission("02"), + ) + } + @Test fun shouldOfferARetryForAnOrdinaryFailure() { val error = DashSdkError.PlatformWallet.ShieldedBroadcastFailed("rejected by the relay") From 342162b4a1d3cc302194937c009c1e1c64e9c5b9 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 9 Nov 2026 09:57:52 +0700 Subject: [PATCH 104/269] feat(swift-example-app): show the private contact request scan state in Storage Explorer `PersistentPrivateContactRequestsPosition` and `PersistentPrivateShieldedAddress` get a row, a count, a list view and a detail view, as `PersistentIdentityBalanceMetadata` does, so the explorer covers every model `DashModelContainer` registers again. Co-Authored-By: Claude Opus 5.5 --- .../Views/StorageExplorerView.swift | 16 +++++ .../Views/StorageModelListViews.swift | 62 +++++++++++++++++++ .../Views/StorageRecordDetailViews.swift | 44 +++++++++++++ 3 files changed, 122 insertions(+) diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageExplorerView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageExplorerView.swift index a567917dedf..d5565d27f42 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageExplorerView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageExplorerView.swift @@ -76,6 +76,20 @@ struct StorageExplorerView: View { ) { DashpayIgnoredSenderStorageListView(network: network) } + modelRow( + "Private Request Positions", + icon: "bookmark", + type: PersistentPrivateContactRequestsPosition.self + ) { + PrivateContactRequestsPositionStorageListView(network: network) + } + modelRow( + "Private Shielded Addresses", + icon: "lock.rectangle", + type: PersistentPrivateShieldedAddress.self + ) { + PrivateShieldedAddressStorageListView(network: network) + } modelRow( "Sent Invitations", icon: "paperplane", @@ -291,6 +305,8 @@ struct StorageExplorerView: View { directCount(PersistentDashpayContactProfile.self, predicate: #Predicate { $0.networkRaw == raw }) directCount(PersistentDashpayPayment.self, predicate: #Predicate { $0.networkRaw == raw }) directCount(PersistentDashpayIgnoredSender.self, predicate: #Predicate { $0.networkRaw == raw }) + directCount(PersistentPrivateContactRequestsPosition.self, predicate: #Predicate { $0.networkRaw == raw }) + directCount(PersistentPrivateShieldedAddress.self, predicate: #Predicate { $0.networkRaw == raw }) directCount(PersistentDocument.self, predicate: #Predicate { $0.networkRaw == raw }) directCount(PersistentDataContract.self, predicate: #Predicate { $0.networkRaw == raw }) directCount(PersistentTokenBalance.self, predicate: #Predicate { $0.networkRaw == raw }) diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageModelListViews.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageModelListViews.swift index 23bc60dd8c3..fb8ff7e41bb 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageModelListViews.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageModelListViews.swift @@ -63,6 +63,68 @@ struct IdentityBalanceMetadataStorageListView: View { } } +// MARK: - PersistentPrivateContactRequestsPosition + +struct PrivateContactRequestsPositionStorageListView: View { + let network: Network + @Query private var records: [PersistentPrivateContactRequestsPosition] + + private var filtered: [PersistentPrivateContactRequestsPosition] { + records.filter { $0.networkRaw == network.rawValue } + .sorted { $0.identityId.toHexString() < $1.identityId.toHexString() } + } + + var body: some View { + let visible = filtered + List(visible) { record in + NavigationLink(destination: PrivateContactRequestsPositionStorageDetailView(record: record)) { + VStack(alignment: .leading, spacing: 4) { + Text(record.identityId.toHexString()) + .font(.body).lineLimit(1).truncationMode(.middle) + Text("Wallet: \(record.walletId.toHexString())") + .font(.caption).foregroundColor(.secondary) + .lineLimit(1).truncationMode(.middle) + // Stored as a bit pattern; show the unsigned position. + Text("Position \(UInt64(bitPattern: record.position))") + .font(.caption).foregroundColor(.secondary) + } + } + } + .navigationTitle("Private Positions (\(visible.count))") + .overlay { if visible.isEmpty { ContentUnavailableView("No Records", systemImage: "bookmark") } } + } +} + +// MARK: - PersistentPrivateShieldedAddress + +struct PrivateShieldedAddressStorageListView: View { + let network: Network + @Query private var records: [PersistentPrivateShieldedAddress] + + private var filtered: [PersistentPrivateShieldedAddress] { + records.filter { $0.networkRaw == network.rawValue } + .sorted { ($0.ownerId.toHexString(), $0.contactId.toHexString()) + < ($1.ownerId.toHexString(), $1.contactId.toHexString()) } + } + + var body: some View { + let visible = filtered + List(visible) { record in + NavigationLink(destination: PrivateShieldedAddressStorageDetailView(record: record)) { + VStack(alignment: .leading, spacing: 4) { + Text("Contact: \(record.contactId.toHexString())") + .font(.body).lineLimit(1).truncationMode(.middle) + Text("Owner: \(record.ownerId.toHexString())") + .font(.caption).foregroundColor(.secondary) + .lineLimit(1).truncationMode(.middle) + } + } + } + .navigationTitle("Private Addresses (\(visible.count))") + .overlay { if visible.isEmpty { ContentUnavailableView("No Records", systemImage: "lock.rectangle") } } + } +} + // MARK: - PersistentDocument struct DocumentStorageListView: View { diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageRecordDetailViews.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageRecordDetailViews.swift index 7fe6ed1cd28..9425366cd32 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageRecordDetailViews.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageRecordDetailViews.swift @@ -86,6 +86,50 @@ struct IdentityBalanceMetadataStorageDetailView: View { } } +// MARK: - PersistentPrivateContactRequestsPosition + +struct PrivateContactRequestsPositionStorageDetailView: View { + let record: PersistentPrivateContactRequestsPosition + + var body: some View { + Form { + Section("Identity") { + FieldRow(label: "Network", value: Network(rawValue: record.networkRaw)?.displayName ?? "raw \(record.networkRaw)") + FieldRow(label: "Wallet ID", value: hexString(record.walletId)) + FieldRow(label: "Identity ID", value: hexString(record.identityId)) + } + Section("Private Contact Request Store") { + FieldRow(label: "Read Position", value: String(UInt64(bitPattern: record.position))) + FieldRow(label: "Key Fingerprint", value: hexString(record.keyFingerprint)) + } + } + .navigationTitle("Private Position") + .navigationBarTitleDisplayMode(.inline) + } +} + +// MARK: - PersistentPrivateShieldedAddress + +struct PrivateShieldedAddressStorageDetailView: View { + let record: PersistentPrivateShieldedAddress + + var body: some View { + Form { + Section("Contact") { + FieldRow(label: "Network", value: Network(rawValue: record.networkRaw)?.displayName ?? "raw \(record.networkRaw)") + FieldRow(label: "Wallet ID", value: hexString(record.walletId)) + FieldRow(label: "Owner ID", value: hexString(record.ownerId)) + FieldRow(label: "Contact ID", value: hexString(record.contactId)) + } + Section("Shielded Address") { + FieldRow(label: "Raw Address", value: hexString(record.address)) + } + } + .navigationTitle("Private Address") + .navigationBarTitleDisplayMode(.inline) + } +} + // MARK: - PersistentIdentity struct IdentityStorageDetailView: View { From 0efc8875a45d935e19eb9b2b61d6283b7a858155 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 9 Nov 2026 10:01:04 +0700 Subject: [PATCH 105/269] chore(kotlin-sdk): export Room schema 16 The schema the Room compiler exports for version 16, with the two private contact request scan state tables; `DashDatabaseMigrationTest` validates the 15 to 16 migration against it. Co-Authored-By: Claude Opus 5.5 --- .../16.json | 4277 +++++++++++++++++ 1 file changed, 4277 insertions(+) create mode 100644 packages/kotlin-sdk/sdk/schemas/org.dashfoundation.dashsdk.persistence.DashDatabase/16.json diff --git a/packages/kotlin-sdk/sdk/schemas/org.dashfoundation.dashsdk.persistence.DashDatabase/16.json b/packages/kotlin-sdk/sdk/schemas/org.dashfoundation.dashsdk.persistence.DashDatabase/16.json new file mode 100644 index 00000000000..8fc2d4e5725 --- /dev/null +++ b/packages/kotlin-sdk/sdk/schemas/org.dashfoundation.dashsdk.persistence.DashDatabase/16.json @@ -0,0 +1,4277 @@ +{ + "formatVersion": 1, + "database": { + "version": 16, + "identityHash": "cfad080e6fde9c3bc142ef0829b3af4b", + "entities": [ + { + "tableName": "wallets", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`walletId` BLOB NOT NULL, `walletGroupId` BLOB NOT NULL, `networkRaw` INTEGER, `name` TEXT, `walletDescription` TEXT, `birthHeight` INTEGER NOT NULL, `syncedHeight` INTEGER NOT NULL, `lastSynced` INTEGER NOT NULL, `lastAppliedChainLockBytes` BLOB, `lastAppliedChainLockHeight` INTEGER, `dashPayBackfillFloor` INTEGER, `dashPayBackfillRewoundFrom` INTEGER, `dashPayBackfillCovered` BLOB, `isImported` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, PRIMARY KEY(`walletId`))", + "fields": [ + { + "fieldPath": "walletId", + "columnName": "walletId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "walletGroupId", + "columnName": "walletGroupId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "networkRaw", + "columnName": "networkRaw", + "affinity": "INTEGER" + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT" + }, + { + "fieldPath": "walletDescription", + "columnName": "walletDescription", + "affinity": "TEXT" + }, + { + "fieldPath": "birthHeight", + "columnName": "birthHeight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "syncedHeight", + "columnName": "syncedHeight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastSynced", + "columnName": "lastSynced", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastAppliedChainLockBytes", + "columnName": "lastAppliedChainLockBytes", + "affinity": "BLOB" + }, + { + "fieldPath": "lastAppliedChainLockHeight", + "columnName": "lastAppliedChainLockHeight", + "affinity": "INTEGER" + }, + { + "fieldPath": "dashPayBackfillFloor", + "columnName": "dashPayBackfillFloor", + "affinity": "INTEGER" + }, + { + "fieldPath": "dashPayBackfillRewoundFrom", + "columnName": "dashPayBackfillRewoundFrom", + "affinity": "INTEGER" + }, + { + "fieldPath": "dashPayBackfillCovered", + "columnName": "dashPayBackfillCovered", + "affinity": "BLOB" + }, + { + "fieldPath": "isImported", + "columnName": "isImported", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "walletId" + ] + }, + "indices": [ + { + "name": "index_wallets_networkRaw", + "unique": false, + "columnNames": [ + "networkRaw" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_wallets_networkRaw` ON `${TABLE_NAME}` (`networkRaw`)" + }, + { + "name": "index_wallets_walletGroupId", + "unique": false, + "columnNames": [ + "walletGroupId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_wallets_walletGroupId` ON `${TABLE_NAME}` (`walletGroupId`)" + } + ] + }, + { + "tableName": "accounts", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `walletId` BLOB NOT NULL, `accountType` INTEGER NOT NULL, `accountIndex` INTEGER NOT NULL, `accountTypeName` TEXT NOT NULL, `balanceConfirmed` INTEGER NOT NULL, `balanceUnconfirmed` INTEGER NOT NULL, `externalHighestUsed` INTEGER NOT NULL, `internalHighestUsed` INTEGER NOT NULL, `standardTag` INTEGER NOT NULL, `registrationIndex` INTEGER NOT NULL, `keyClass` INTEGER NOT NULL, `userIdentityId` BLOB NOT NULL, `friendIdentityId` BLOB NOT NULL, `accountExtendedPubKeyBytes` BLOB, `createdAt` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, FOREIGN KEY(`walletId`) REFERENCES `wallets`(`walletId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "walletId", + "columnName": "walletId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "accountType", + "columnName": "accountType", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accountIndex", + "columnName": "accountIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accountTypeName", + "columnName": "accountTypeName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "balanceConfirmed", + "columnName": "balanceConfirmed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "balanceUnconfirmed", + "columnName": "balanceUnconfirmed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "externalHighestUsed", + "columnName": "externalHighestUsed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "internalHighestUsed", + "columnName": "internalHighestUsed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "standardTag", + "columnName": "standardTag", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "registrationIndex", + "columnName": "registrationIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "keyClass", + "columnName": "keyClass", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "userIdentityId", + "columnName": "userIdentityId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "friendIdentityId", + "columnName": "friendIdentityId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "accountExtendedPubKeyBytes", + "columnName": "accountExtendedPubKeyBytes", + "affinity": "BLOB" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_accounts_walletId", + "unique": false, + "columnNames": [ + "walletId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_accounts_walletId` ON `${TABLE_NAME}` (`walletId`)" + }, + { + "name": "index_accounts_walletId_accountType_accountIndex_standardTag_registrationIndex_keyClass_userIdentityId_friendIdentityId", + "unique": true, + "columnNames": [ + "walletId", + "accountType", + "accountIndex", + "standardTag", + "registrationIndex", + "keyClass", + "userIdentityId", + "friendIdentityId" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_accounts_walletId_accountType_accountIndex_standardTag_registrationIndex_keyClass_userIdentityId_friendIdentityId` ON `${TABLE_NAME}` (`walletId`, `accountType`, `accountIndex`, `standardTag`, `registrationIndex`, `keyClass`, `userIdentityId`, `friendIdentityId`)" + }, + { + "name": "index_accounts_accountExtendedPubKeyBytes", + "unique": true, + "columnNames": [ + "accountExtendedPubKeyBytes" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_accounts_accountExtendedPubKeyBytes` ON `${TABLE_NAME}` (`accountExtendedPubKeyBytes`)" + } + ], + "foreignKeys": [ + { + "table": "wallets", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "walletId" + ], + "referencedColumns": [ + "walletId" + ] + } + ] + }, + { + "tableName": "transactions", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`txid` BLOB NOT NULL, `transactionData` BLOB NOT NULL, `context` INTEGER NOT NULL, `blockHeight` INTEGER NOT NULL, `blockHash` BLOB, `blockTimestamp` INTEGER NOT NULL, `blockPosition` INTEGER NOT NULL, `hasBlockPosition` INTEGER NOT NULL, `direction` INTEGER NOT NULL, `transactionType` TEXT NOT NULL, `transactionTypeKind` INTEGER NOT NULL, `netAmount` INTEGER NOT NULL, `fee` INTEGER, `label` TEXT NOT NULL, `firstSeen` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, PRIMARY KEY(`txid`))", + "fields": [ + { + "fieldPath": "txid", + "columnName": "txid", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "transactionData", + "columnName": "transactionData", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "context", + "columnName": "context", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "blockHeight", + "columnName": "blockHeight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "blockHash", + "columnName": "blockHash", + "affinity": "BLOB" + }, + { + "fieldPath": "blockTimestamp", + "columnName": "blockTimestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "blockPosition", + "columnName": "blockPosition", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hasBlockPosition", + "columnName": "hasBlockPosition", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "direction", + "columnName": "direction", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "transactionType", + "columnName": "transactionType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "transactionTypeKind", + "columnName": "transactionTypeKind", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "netAmount", + "columnName": "netAmount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "fee", + "columnName": "fee", + "affinity": "INTEGER" + }, + { + "fieldPath": "label", + "columnName": "label", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "firstSeen", + "columnName": "firstSeen", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "txid" + ] + }, + "indices": [ + { + "name": "index_transactions_firstSeen", + "unique": false, + "columnNames": [ + "firstSeen" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_transactions_firstSeen` ON `${TABLE_NAME}` (`firstSeen`)" + } + ] + }, + { + "tableName": "transaction_account_involvements", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`transactionTxid` BLOB NOT NULL, `accountId` INTEGER NOT NULL, PRIMARY KEY(`transactionTxid`, `accountId`), FOREIGN KEY(`transactionTxid`) REFERENCES `transactions`(`txid`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`accountId`) REFERENCES `accounts`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "transactionTxid", + "columnName": "transactionTxid", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "transactionTxid", + "accountId" + ] + }, + "indices": [ + { + "name": "index_transaction_account_involvements_accountId", + "unique": false, + "columnNames": [ + "accountId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_transaction_account_involvements_accountId` ON `${TABLE_NAME}` (`accountId`)" + } + ], + "foreignKeys": [ + { + "table": "transactions", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "transactionTxid" + ], + "referencedColumns": [ + "txid" + ] + }, + { + "table": "accounts", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "accountId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "txos", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`outpoint` BLOB NOT NULL, `vout` INTEGER NOT NULL, `amount` INTEGER NOT NULL, `address` TEXT NOT NULL, `scriptPubKey` BLOB NOT NULL, `height` INTEGER NOT NULL, `isCoinbase` INTEGER NOT NULL, `isConfirmed` INTEGER NOT NULL, `isInstantLocked` INTEGER NOT NULL, `isLocked` INTEGER NOT NULL, `isSpent` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, `walletId` BLOB NOT NULL, `txid` BLOB, `spendingTxid` BLOB, `spendingInputIndex` INTEGER, `accountId` INTEGER, `coreAddressId` TEXT, `supersededByTxid` BLOB, PRIMARY KEY(`outpoint`), FOREIGN KEY(`txid`) REFERENCES `transactions`(`txid`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`spendingTxid`) REFERENCES `transactions`(`txid`) ON UPDATE NO ACTION ON DELETE SET NULL , FOREIGN KEY(`accountId`) REFERENCES `accounts`(`id`) ON UPDATE NO ACTION ON DELETE SET NULL , FOREIGN KEY(`coreAddressId`) REFERENCES `core_addresses`(`address`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "outpoint", + "columnName": "outpoint", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "vout", + "columnName": "vout", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "amount", + "columnName": "amount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "address", + "columnName": "address", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "scriptPubKey", + "columnName": "scriptPubKey", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "height", + "columnName": "height", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isCoinbase", + "columnName": "isCoinbase", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isConfirmed", + "columnName": "isConfirmed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isInstantLocked", + "columnName": "isInstantLocked", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isLocked", + "columnName": "isLocked", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isSpent", + "columnName": "isSpent", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "walletId", + "columnName": "walletId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "txid", + "columnName": "txid", + "affinity": "BLOB" + }, + { + "fieldPath": "spendingTxid", + "columnName": "spendingTxid", + "affinity": "BLOB" + }, + { + "fieldPath": "spendingInputIndex", + "columnName": "spendingInputIndex", + "affinity": "INTEGER" + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "INTEGER" + }, + { + "fieldPath": "coreAddressId", + "columnName": "coreAddressId", + "affinity": "TEXT" + }, + { + "fieldPath": "supersededByTxid", + "columnName": "supersededByTxid", + "affinity": "BLOB" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "outpoint" + ] + }, + "indices": [ + { + "name": "index_txos_walletId", + "unique": false, + "columnNames": [ + "walletId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_txos_walletId` ON `${TABLE_NAME}` (`walletId`)" + }, + { + "name": "index_txos_txid", + "unique": false, + "columnNames": [ + "txid" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_txos_txid` ON `${TABLE_NAME}` (`txid`)" + }, + { + "name": "index_txos_spendingTxid", + "unique": false, + "columnNames": [ + "spendingTxid" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_txos_spendingTxid` ON `${TABLE_NAME}` (`spendingTxid`)" + }, + { + "name": "index_txos_accountId", + "unique": false, + "columnNames": [ + "accountId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_txos_accountId` ON `${TABLE_NAME}` (`accountId`)" + }, + { + "name": "index_txos_coreAddressId", + "unique": false, + "columnNames": [ + "coreAddressId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_txos_coreAddressId` ON `${TABLE_NAME}` (`coreAddressId`)" + } + ], + "foreignKeys": [ + { + "table": "transactions", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "txid" + ], + "referencedColumns": [ + "txid" + ] + }, + { + "table": "transactions", + "onDelete": "SET NULL", + "onUpdate": "NO ACTION", + "columns": [ + "spendingTxid" + ], + "referencedColumns": [ + "txid" + ] + }, + { + "table": "accounts", + "onDelete": "SET NULL", + "onUpdate": "NO ACTION", + "columns": [ + "accountId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "core_addresses", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "coreAddressId" + ], + "referencedColumns": [ + "address" + ] + } + ] + }, + { + "tableName": "core_addresses", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`address` TEXT NOT NULL, `publicKey` BLOB NOT NULL, `poolTypeTag` INTEGER NOT NULL, `addressIndex` INTEGER NOT NULL, `derivationPath` TEXT NOT NULL, `isUsed` INTEGER NOT NULL, `firstSeenHeight` INTEGER NOT NULL, `lastSeenHeight` INTEGER NOT NULL, `balance` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, `accountId` INTEGER, PRIMARY KEY(`address`), FOREIGN KEY(`accountId`) REFERENCES `accounts`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "address", + "columnName": "address", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "poolTypeTag", + "columnName": "poolTypeTag", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "addressIndex", + "columnName": "addressIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "derivationPath", + "columnName": "derivationPath", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isUsed", + "columnName": "isUsed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "firstSeenHeight", + "columnName": "firstSeenHeight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastSeenHeight", + "columnName": "lastSeenHeight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "balance", + "columnName": "balance", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "address" + ] + }, + "indices": [ + { + "name": "index_core_addresses_accountId", + "unique": false, + "columnNames": [ + "accountId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_core_addresses_accountId` ON `${TABLE_NAME}` (`accountId`)" + } + ], + "foreignKeys": [ + { + "table": "accounts", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "accountId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "asset_locks", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`outPointHex` TEXT NOT NULL, `walletId` BLOB NOT NULL, `transactionBytes` BLOB NOT NULL, `fundingTypeRaw` INTEGER NOT NULL, `identityIndexRaw` INTEGER NOT NULL, `accountIndexRaw` INTEGER NOT NULL, `amountDuffs` INTEGER NOT NULL, `statusRaw` INTEGER NOT NULL, `proofBytes` BLOB, `recipientPlatformAddressHash` BLOB, `recipientPlatformAddressType` INTEGER, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`outPointHex`))", + "fields": [ + { + "fieldPath": "outPointHex", + "columnName": "outPointHex", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "walletId", + "columnName": "walletId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "transactionBytes", + "columnName": "transactionBytes", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "fundingTypeRaw", + "columnName": "fundingTypeRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "identityIndexRaw", + "columnName": "identityIndexRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accountIndexRaw", + "columnName": "accountIndexRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "amountDuffs", + "columnName": "amountDuffs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "statusRaw", + "columnName": "statusRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "proofBytes", + "columnName": "proofBytes", + "affinity": "BLOB" + }, + { + "fieldPath": "recipientPlatformAddressHash", + "columnName": "recipientPlatformAddressHash", + "affinity": "BLOB" + }, + { + "fieldPath": "recipientPlatformAddressType", + "columnName": "recipientPlatformAddressType", + "affinity": "INTEGER" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "outPointHex" + ] + }, + "indices": [ + { + "name": "index_asset_locks_walletId", + "unique": false, + "columnNames": [ + "walletId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_asset_locks_walletId` ON `${TABLE_NAME}` (`walletId`)" + } + ] + }, + { + "tableName": "invitations", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`outPointHex` TEXT NOT NULL, `rawOutPoint` BLOB NOT NULL, `walletId` BLOB NOT NULL, `fundingIndexRaw` INTEGER NOT NULL, `amountDuffs` INTEGER NOT NULL, `expiryUnix` INTEGER NOT NULL, `createdAtSecs` INTEGER NOT NULL, `hasInviter` INTEGER NOT NULL, `statusRaw` INTEGER NOT NULL, `reclaimInFlight` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`outPointHex`))", + "fields": [ + { + "fieldPath": "outPointHex", + "columnName": "outPointHex", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "rawOutPoint", + "columnName": "rawOutPoint", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "walletId", + "columnName": "walletId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "fundingIndexRaw", + "columnName": "fundingIndexRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "amountDuffs", + "columnName": "amountDuffs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "expiryUnix", + "columnName": "expiryUnix", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAtSecs", + "columnName": "createdAtSecs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hasInviter", + "columnName": "hasInviter", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "statusRaw", + "columnName": "statusRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "reclaimInFlight", + "columnName": "reclaimInFlight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "outPointHex" + ] + }, + "indices": [ + { + "name": "index_invitations_walletId", + "unique": false, + "columnNames": [ + "walletId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_invitations_walletId` ON `${TABLE_NAME}` (`walletId`)" + } + ] + }, + { + "tableName": "identities", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`identityId` BLOB NOT NULL, `balance` INTEGER NOT NULL, `revision` INTEGER NOT NULL, `isLocal` INTEGER NOT NULL, `alias` TEXT, `dpnsName` TEXT, `mainDpnsName` TEXT, `identityType` TEXT NOT NULL, `votingPrivateKeyIdentifier` TEXT, `ownerPrivateKeyIdentifier` TEXT, `payoutPrivateKeyIdentifier` TEXT, `createdAt` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, `lastSyncedAt` INTEGER, `networkRaw` INTEGER NOT NULL, `walletId` BLOB, `identityIndex` INTEGER NOT NULL, PRIMARY KEY(`identityId`), FOREIGN KEY(`walletId`) REFERENCES `wallets`(`walletId`) ON UPDATE NO ACTION ON DELETE SET NULL )", + "fields": [ + { + "fieldPath": "identityId", + "columnName": "identityId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "balance", + "columnName": "balance", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "revision", + "columnName": "revision", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isLocal", + "columnName": "isLocal", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "alias", + "columnName": "alias", + "affinity": "TEXT" + }, + { + "fieldPath": "dpnsName", + "columnName": "dpnsName", + "affinity": "TEXT" + }, + { + "fieldPath": "mainDpnsName", + "columnName": "mainDpnsName", + "affinity": "TEXT" + }, + { + "fieldPath": "identityType", + "columnName": "identityType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "votingPrivateKeyIdentifier", + "columnName": "votingPrivateKeyIdentifier", + "affinity": "TEXT" + }, + { + "fieldPath": "ownerPrivateKeyIdentifier", + "columnName": "ownerPrivateKeyIdentifier", + "affinity": "TEXT" + }, + { + "fieldPath": "payoutPrivateKeyIdentifier", + "columnName": "payoutPrivateKeyIdentifier", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastSyncedAt", + "columnName": "lastSyncedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "networkRaw", + "columnName": "networkRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "walletId", + "columnName": "walletId", + "affinity": "BLOB" + }, + { + "fieldPath": "identityIndex", + "columnName": "identityIndex", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "identityId" + ] + }, + "indices": [ + { + "name": "index_identities_networkRaw", + "unique": false, + "columnNames": [ + "networkRaw" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_identities_networkRaw` ON `${TABLE_NAME}` (`networkRaw`)" + }, + { + "name": "index_identities_walletId", + "unique": false, + "columnNames": [ + "walletId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_identities_walletId` ON `${TABLE_NAME}` (`walletId`)" + } + ], + "foreignKeys": [ + { + "table": "wallets", + "onDelete": "SET NULL", + "onUpdate": "NO ACTION", + "columns": [ + "walletId" + ], + "referencedColumns": [ + "walletId" + ] + } + ] + }, + { + "tableName": "public_keys", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `keyId` INTEGER NOT NULL, `purpose` TEXT NOT NULL, `securityLevel` TEXT NOT NULL, `keyType` TEXT NOT NULL, `readOnly` INTEGER NOT NULL, `disabledAt` INTEGER, `totalBudget` INTEGER, `expiresAt` INTEGER, `publicKeyData` BLOB NOT NULL, `contractBoundsData` BLOB, `contractBoundsDocumentTypeName` TEXT, `contractBoundsKind` INTEGER, `privateKeyKeychainIdentifier` TEXT, `derivationIdentityIndex` INTEGER, `derivationKeyIndex` INTEGER, `identityId` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `lastAccessed` INTEGER, `identityIdData` BLOB, FOREIGN KEY(`identityIdData`) REFERENCES `identities`(`identityId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "keyId", + "columnName": "keyId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "purpose", + "columnName": "purpose", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "securityLevel", + "columnName": "securityLevel", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keyType", + "columnName": "keyType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "readOnly", + "columnName": "readOnly", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "disabledAt", + "columnName": "disabledAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "totalBudget", + "columnName": "totalBudget", + "affinity": "INTEGER" + }, + { + "fieldPath": "expiresAt", + "columnName": "expiresAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "publicKeyData", + "columnName": "publicKeyData", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "contractBoundsData", + "columnName": "contractBoundsData", + "affinity": "BLOB" + }, + { + "fieldPath": "contractBoundsDocumentTypeName", + "columnName": "contractBoundsDocumentTypeName", + "affinity": "TEXT" + }, + { + "fieldPath": "contractBoundsKind", + "columnName": "contractBoundsKind", + "affinity": "INTEGER" + }, + { + "fieldPath": "privateKeyKeychainIdentifier", + "columnName": "privateKeyKeychainIdentifier", + "affinity": "TEXT" + }, + { + "fieldPath": "derivationIdentityIndex", + "columnName": "derivationIdentityIndex", + "affinity": "INTEGER" + }, + { + "fieldPath": "derivationKeyIndex", + "columnName": "derivationKeyIndex", + "affinity": "INTEGER" + }, + { + "fieldPath": "identityId", + "columnName": "identityId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastAccessed", + "columnName": "lastAccessed", + "affinity": "INTEGER" + }, + { + "fieldPath": "identityIdData", + "columnName": "identityIdData", + "affinity": "BLOB" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_public_keys_identityId_keyId", + "unique": false, + "columnNames": [ + "identityId", + "keyId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_public_keys_identityId_keyId` ON `${TABLE_NAME}` (`identityId`, `keyId`)" + }, + { + "name": "index_public_keys_identityIdData", + "unique": false, + "columnNames": [ + "identityIdData" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_public_keys_identityIdData` ON `${TABLE_NAME}` (`identityIdData`)" + }, + { + "name": "index_public_keys_publicKeyData", + "unique": false, + "columnNames": [ + "publicKeyData" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_public_keys_publicKeyData` ON `${TABLE_NAME}` (`publicKeyData`)" + } + ], + "foreignKeys": [ + { + "table": "identities", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "identityIdData" + ], + "referencedColumns": [ + "identityId" + ] + } + ] + }, + { + "tableName": "dpns_names", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`networkRaw` INTEGER NOT NULL, `label` TEXT NOT NULL, `normalizedLabel` TEXT NOT NULL, `parentDomainName` TEXT NOT NULL, `normalizedParentDomainName` TEXT NOT NULL, `acquiredAt` INTEGER NOT NULL, `identityId` BLOB NOT NULL, `documentId` BLOB, `isOwned` INTEGER NOT NULL, `priceCredits` INTEGER, `saleStatusRaw` INTEGER NOT NULL, `counterpartyIdentityId` BLOB, `documentCreatedAtMs` INTEGER NOT NULL, `documentUpdatedAtMs` INTEGER NOT NULL, `documentTransferredAtMs` INTEGER NOT NULL, `marketplaceUpdatedAt` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, PRIMARY KEY(`networkRaw`, `normalizedParentDomainName`, `normalizedLabel`), FOREIGN KEY(`identityId`) REFERENCES `identities`(`identityId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "networkRaw", + "columnName": "networkRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "label", + "columnName": "label", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "normalizedLabel", + "columnName": "normalizedLabel", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "parentDomainName", + "columnName": "parentDomainName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "normalizedParentDomainName", + "columnName": "normalizedParentDomainName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "acquiredAt", + "columnName": "acquiredAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "identityId", + "columnName": "identityId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "documentId", + "columnName": "documentId", + "affinity": "BLOB" + }, + { + "fieldPath": "isOwned", + "columnName": "isOwned", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "priceCredits", + "columnName": "priceCredits", + "affinity": "INTEGER" + }, + { + "fieldPath": "saleStatusRaw", + "columnName": "saleStatusRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "counterpartyIdentityId", + "columnName": "counterpartyIdentityId", + "affinity": "BLOB" + }, + { + "fieldPath": "documentCreatedAtMs", + "columnName": "documentCreatedAtMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "documentUpdatedAtMs", + "columnName": "documentUpdatedAtMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "documentTransferredAtMs", + "columnName": "documentTransferredAtMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "marketplaceUpdatedAt", + "columnName": "marketplaceUpdatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "networkRaw", + "normalizedParentDomainName", + "normalizedLabel" + ] + }, + "indices": [ + { + "name": "index_dpns_names_identityId", + "unique": false, + "columnNames": [ + "identityId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_dpns_names_identityId` ON `${TABLE_NAME}` (`identityId`)" + }, + { + "name": "index_dpns_names_documentId", + "unique": false, + "columnNames": [ + "documentId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_dpns_names_documentId` ON `${TABLE_NAME}` (`documentId`)" + } + ], + "foreignKeys": [ + { + "table": "identities", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "identityId" + ], + "referencedColumns": [ + "identityId" + ] + } + ] + }, + { + "tableName": "dashpay_profiles", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`networkRaw` INTEGER NOT NULL, `identityId` BLOB NOT NULL, `displayName` TEXT, `publicMessage` TEXT, `bio` TEXT, `avatarUrl` TEXT, `avatarHash` BLOB, `avatarFingerprint` BLOB, `createdAt` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, PRIMARY KEY(`networkRaw`, `identityId`), FOREIGN KEY(`identityId`) REFERENCES `identities`(`identityId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "networkRaw", + "columnName": "networkRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "identityId", + "columnName": "identityId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT" + }, + { + "fieldPath": "publicMessage", + "columnName": "publicMessage", + "affinity": "TEXT" + }, + { + "fieldPath": "bio", + "columnName": "bio", + "affinity": "TEXT" + }, + { + "fieldPath": "avatarUrl", + "columnName": "avatarUrl", + "affinity": "TEXT" + }, + { + "fieldPath": "avatarHash", + "columnName": "avatarHash", + "affinity": "BLOB" + }, + { + "fieldPath": "avatarFingerprint", + "columnName": "avatarFingerprint", + "affinity": "BLOB" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "networkRaw", + "identityId" + ] + }, + "indices": [ + { + "name": "index_dashpay_profiles_identityId", + "unique": false, + "columnNames": [ + "identityId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_dashpay_profiles_identityId` ON `${TABLE_NAME}` (`identityId`)" + } + ], + "foreignKeys": [ + { + "table": "identities", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "identityId" + ], + "referencedColumns": [ + "identityId" + ] + } + ] + }, + { + "tableName": "dashpay_contact_requests", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`networkRaw` INTEGER NOT NULL, `ownerIdentityId` BLOB NOT NULL, `contactIdentityId` BLOB NOT NULL, `isOutgoing` INTEGER NOT NULL, `senderKeyIndex` INTEGER NOT NULL, `recipientKeyIndex` INTEGER NOT NULL, `accountReference` INTEGER NOT NULL, `encryptedPublicKey` BLOB NOT NULL, `encryptedAccountLabel` BLOB, `autoAcceptProof` BLOB, `coreHeightCreatedAt` INTEGER NOT NULL, `createdAtMillis` INTEGER NOT NULL, `paymentChannelBroken` INTEGER NOT NULL DEFAULT 0, `contactAlias` TEXT, `contactNote` TEXT, `contactHidden` INTEGER NOT NULL DEFAULT 0, `contactAccountLabel` TEXT, `contactAcceptedAccounts` BLOB, `externalAccountReference` INTEGER, `createdAt` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, PRIMARY KEY(`networkRaw`, `ownerIdentityId`, `contactIdentityId`, `isOutgoing`), FOREIGN KEY(`ownerIdentityId`) REFERENCES `identities`(`identityId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "networkRaw", + "columnName": "networkRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "ownerIdentityId", + "columnName": "ownerIdentityId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "contactIdentityId", + "columnName": "contactIdentityId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "isOutgoing", + "columnName": "isOutgoing", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "senderKeyIndex", + "columnName": "senderKeyIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "recipientKeyIndex", + "columnName": "recipientKeyIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accountReference", + "columnName": "accountReference", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "encryptedPublicKey", + "columnName": "encryptedPublicKey", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "encryptedAccountLabel", + "columnName": "encryptedAccountLabel", + "affinity": "BLOB" + }, + { + "fieldPath": "autoAcceptProof", + "columnName": "autoAcceptProof", + "affinity": "BLOB" + }, + { + "fieldPath": "coreHeightCreatedAt", + "columnName": "coreHeightCreatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAtMillis", + "columnName": "createdAtMillis", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "paymentChannelBroken", + "columnName": "paymentChannelBroken", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "contactAlias", + "columnName": "contactAlias", + "affinity": "TEXT" + }, + { + "fieldPath": "contactNote", + "columnName": "contactNote", + "affinity": "TEXT" + }, + { + "fieldPath": "contactHidden", + "columnName": "contactHidden", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "contactAccountLabel", + "columnName": "contactAccountLabel", + "affinity": "TEXT" + }, + { + "fieldPath": "contactAcceptedAccounts", + "columnName": "contactAcceptedAccounts", + "affinity": "BLOB" + }, + { + "fieldPath": "externalAccountReference", + "columnName": "externalAccountReference", + "affinity": "INTEGER" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "networkRaw", + "ownerIdentityId", + "contactIdentityId", + "isOutgoing" + ] + }, + "indices": [ + { + "name": "index_dashpay_contact_requests_ownerIdentityId", + "unique": false, + "columnNames": [ + "ownerIdentityId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_dashpay_contact_requests_ownerIdentityId` ON `${TABLE_NAME}` (`ownerIdentityId`)" + } + ], + "foreignKeys": [ + { + "table": "identities", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "ownerIdentityId" + ], + "referencedColumns": [ + "identityId" + ] + } + ] + }, + { + "tableName": "dashpay_ignored_senders", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`networkRaw` INTEGER NOT NULL, `ownerIdentityId` BLOB NOT NULL, `ignoredSenderId` BLOB NOT NULL, `ignoredAt` INTEGER NOT NULL, PRIMARY KEY(`networkRaw`, `ownerIdentityId`, `ignoredSenderId`), FOREIGN KEY(`ownerIdentityId`) REFERENCES `identities`(`identityId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "networkRaw", + "columnName": "networkRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "ownerIdentityId", + "columnName": "ownerIdentityId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "ignoredSenderId", + "columnName": "ignoredSenderId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "ignoredAt", + "columnName": "ignoredAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "networkRaw", + "ownerIdentityId", + "ignoredSenderId" + ] + }, + "indices": [ + { + "name": "index_dashpay_ignored_senders_ownerIdentityId", + "unique": false, + "columnNames": [ + "ownerIdentityId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_dashpay_ignored_senders_ownerIdentityId` ON `${TABLE_NAME}` (`ownerIdentityId`)" + } + ], + "foreignKeys": [ + { + "table": "identities", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "ownerIdentityId" + ], + "referencedColumns": [ + "identityId" + ] + } + ] + }, + { + "tableName": "dashpay_contact_profiles", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`networkRaw` INTEGER NOT NULL, `ownerIdentityId` BLOB NOT NULL, `contactIdentityId` BLOB NOT NULL, `displayName` TEXT, `publicMessage` TEXT, `bio` TEXT, `avatarUrl` TEXT, `avatarHash` BLOB, `avatarFingerprint` BLOB, `checkedAtMs` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, PRIMARY KEY(`networkRaw`, `ownerIdentityId`, `contactIdentityId`), FOREIGN KEY(`ownerIdentityId`) REFERENCES `identities`(`identityId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "networkRaw", + "columnName": "networkRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "ownerIdentityId", + "columnName": "ownerIdentityId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "contactIdentityId", + "columnName": "contactIdentityId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT" + }, + { + "fieldPath": "publicMessage", + "columnName": "publicMessage", + "affinity": "TEXT" + }, + { + "fieldPath": "bio", + "columnName": "bio", + "affinity": "TEXT" + }, + { + "fieldPath": "avatarUrl", + "columnName": "avatarUrl", + "affinity": "TEXT" + }, + { + "fieldPath": "avatarHash", + "columnName": "avatarHash", + "affinity": "BLOB" + }, + { + "fieldPath": "avatarFingerprint", + "columnName": "avatarFingerprint", + "affinity": "BLOB" + }, + { + "fieldPath": "checkedAtMs", + "columnName": "checkedAtMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "networkRaw", + "ownerIdentityId", + "contactIdentityId" + ] + }, + "indices": [ + { + "name": "index_dashpay_contact_profiles_ownerIdentityId", + "unique": false, + "columnNames": [ + "ownerIdentityId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_dashpay_contact_profiles_ownerIdentityId` ON `${TABLE_NAME}` (`ownerIdentityId`)" + } + ], + "foreignKeys": [ + { + "table": "identities", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "ownerIdentityId" + ], + "referencedColumns": [ + "identityId" + ] + } + ] + }, + { + "tableName": "dashpay_payments", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`networkRaw` INTEGER NOT NULL, `ownerIdentityId` BLOB NOT NULL, `counterpartyIdentityId` BLOB NOT NULL, `amountDuffs` INTEGER NOT NULL, `directionRaw` INTEGER NOT NULL, `statusRaw` INTEGER NOT NULL, `txid` TEXT NOT NULL, `memo` TEXT, `createdAt` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, PRIMARY KEY(`networkRaw`, `ownerIdentityId`, `txid`), FOREIGN KEY(`ownerIdentityId`) REFERENCES `identities`(`identityId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "networkRaw", + "columnName": "networkRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "ownerIdentityId", + "columnName": "ownerIdentityId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "counterpartyIdentityId", + "columnName": "counterpartyIdentityId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "amountDuffs", + "columnName": "amountDuffs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "directionRaw", + "columnName": "directionRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "statusRaw", + "columnName": "statusRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "txid", + "columnName": "txid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "memo", + "columnName": "memo", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "networkRaw", + "ownerIdentityId", + "txid" + ] + }, + "indices": [ + { + "name": "index_dashpay_payments_ownerIdentityId", + "unique": false, + "columnNames": [ + "ownerIdentityId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_dashpay_payments_ownerIdentityId` ON `${TABLE_NAME}` (`ownerIdentityId`)" + } + ], + "foreignKeys": [ + { + "table": "identities", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "ownerIdentityId" + ], + "referencedColumns": [ + "identityId" + ] + } + ] + }, + { + "tableName": "data_contracts", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` BLOB NOT NULL, `name` TEXT NOT NULL, `serializedContract` BLOB NOT NULL, `createdAt` INTEGER NOT NULL, `lastAccessedAt` INTEGER NOT NULL, `binarySerialization` BLOB, `version` INTEGER, `ownerId` BLOB, `contractDescription` TEXT, `schemaData` BLOB NOT NULL, `documentTypesData` BLOB NOT NULL, `groupsData` BLOB, `networkRaw` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, `lastSyncedAt` INTEGER, `canBeDeleted` INTEGER NOT NULL, `readonly` INTEGER NOT NULL, `keepsHistory` INTEGER NOT NULL, `schemaDefs` INTEGER, `documentsKeepHistoryContractDefault` INTEGER NOT NULL, `documentsMutableContractDefault` INTEGER NOT NULL, `documentsCanBeDeletedContractDefault` INTEGER NOT NULL, `hasTokens` INTEGER NOT NULL, `tokensData` BLOB, `ownerIdentityId` BLOB, PRIMARY KEY(`id`), FOREIGN KEY(`ownerIdentityId`) REFERENCES `identities`(`identityId`) ON UPDATE NO ACTION ON DELETE SET NULL )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "serializedContract", + "columnName": "serializedContract", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastAccessedAt", + "columnName": "lastAccessedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "binarySerialization", + "columnName": "binarySerialization", + "affinity": "BLOB" + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "INTEGER" + }, + { + "fieldPath": "ownerId", + "columnName": "ownerId", + "affinity": "BLOB" + }, + { + "fieldPath": "contractDescription", + "columnName": "contractDescription", + "affinity": "TEXT" + }, + { + "fieldPath": "schemaData", + "columnName": "schemaData", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "documentTypesData", + "columnName": "documentTypesData", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "groupsData", + "columnName": "groupsData", + "affinity": "BLOB" + }, + { + "fieldPath": "networkRaw", + "columnName": "networkRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastSyncedAt", + "columnName": "lastSyncedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "canBeDeleted", + "columnName": "canBeDeleted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "readonly", + "columnName": "readonly", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "keepsHistory", + "columnName": "keepsHistory", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "schemaDefs", + "columnName": "schemaDefs", + "affinity": "INTEGER" + }, + { + "fieldPath": "documentsKeepHistoryContractDefault", + "columnName": "documentsKeepHistoryContractDefault", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "documentsMutableContractDefault", + "columnName": "documentsMutableContractDefault", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "documentsCanBeDeletedContractDefault", + "columnName": "documentsCanBeDeletedContractDefault", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hasTokens", + "columnName": "hasTokens", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tokensData", + "columnName": "tokensData", + "affinity": "BLOB" + }, + { + "fieldPath": "ownerIdentityId", + "columnName": "ownerIdentityId", + "affinity": "BLOB" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_data_contracts_networkRaw", + "unique": false, + "columnNames": [ + "networkRaw" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_data_contracts_networkRaw` ON `${TABLE_NAME}` (`networkRaw`)" + }, + { + "name": "index_data_contracts_ownerIdentityId", + "unique": false, + "columnNames": [ + "ownerIdentityId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_data_contracts_ownerIdentityId` ON `${TABLE_NAME}` (`ownerIdentityId`)" + } + ], + "foreignKeys": [ + { + "table": "identities", + "onDelete": "SET NULL", + "onUpdate": "NO ACTION", + "columns": [ + "ownerIdentityId" + ], + "referencedColumns": [ + "identityId" + ] + } + ] + }, + { + "tableName": "document_types", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` BLOB NOT NULL, `contractId` BLOB NOT NULL, `name` TEXT NOT NULL, `schemaJSON` BLOB NOT NULL, `propertiesJSON` BLOB NOT NULL, `documentsKeepHistory` INTEGER NOT NULL, `documentsMutable` INTEGER NOT NULL, `documentsCanBeDeleted` INTEGER NOT NULL, `documentsTransferable` INTEGER NOT NULL, `requiredFieldsJSON` BLOB, `securityLevel` INTEGER NOT NULL, `tradeMode` INTEGER NOT NULL, `creationRestrictionMode` INTEGER NOT NULL, `requiresIdentityEncryptionBoundedKey` INTEGER NOT NULL, `requiresIdentityDecryptionBoundedKey` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `lastAccessedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`contractId`) REFERENCES `data_contracts`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "contractId", + "columnName": "contractId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "schemaJSON", + "columnName": "schemaJSON", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "propertiesJSON", + "columnName": "propertiesJSON", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "documentsKeepHistory", + "columnName": "documentsKeepHistory", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "documentsMutable", + "columnName": "documentsMutable", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "documentsCanBeDeleted", + "columnName": "documentsCanBeDeleted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "documentsTransferable", + "columnName": "documentsTransferable", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "requiredFieldsJSON", + "columnName": "requiredFieldsJSON", + "affinity": "BLOB" + }, + { + "fieldPath": "securityLevel", + "columnName": "securityLevel", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tradeMode", + "columnName": "tradeMode", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "creationRestrictionMode", + "columnName": "creationRestrictionMode", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "requiresIdentityEncryptionBoundedKey", + "columnName": "requiresIdentityEncryptionBoundedKey", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "requiresIdentityDecryptionBoundedKey", + "columnName": "requiresIdentityDecryptionBoundedKey", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastAccessedAt", + "columnName": "lastAccessedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_document_types_contractId", + "unique": false, + "columnNames": [ + "contractId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_document_types_contractId` ON `${TABLE_NAME}` (`contractId`)" + } + ], + "foreignKeys": [ + { + "table": "data_contracts", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "contractId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "documents", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`documentId` TEXT NOT NULL, `documentType` TEXT NOT NULL, `revision` INTEGER NOT NULL, `data` BLOB NOT NULL, `contractId` TEXT NOT NULL, `ownerId` TEXT NOT NULL, `contractIdData` BLOB NOT NULL, `ownerIdData` BLOB NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `transferredAt` INTEGER, `createdAtBlockHeight` INTEGER, `updatedAtBlockHeight` INTEGER, `transferredAtBlockHeight` INTEGER, `createdAtCoreBlockHeight` INTEGER, `updatedAtCoreBlockHeight` INTEGER, `transferredAtCoreBlockHeight` INTEGER, `networkRaw` INTEGER NOT NULL, `isDeleted` INTEGER NOT NULL, `localCreatedAt` INTEGER NOT NULL, `localUpdatedAt` INTEGER NOT NULL, `documentTypeRelationId` BLOB, `dataContractId` BLOB, `ownerIdentityId` BLOB, PRIMARY KEY(`documentId`), FOREIGN KEY(`documentTypeRelationId`) REFERENCES `document_types`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`dataContractId`) REFERENCES `data_contracts`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`ownerIdentityId`) REFERENCES `identities`(`identityId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "documentId", + "columnName": "documentId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "documentType", + "columnName": "documentType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "revision", + "columnName": "revision", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "data", + "columnName": "data", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "contractId", + "columnName": "contractId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "ownerId", + "columnName": "ownerId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "contractIdData", + "columnName": "contractIdData", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "ownerIdData", + "columnName": "ownerIdData", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "transferredAt", + "columnName": "transferredAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "createdAtBlockHeight", + "columnName": "createdAtBlockHeight", + "affinity": "INTEGER" + }, + { + "fieldPath": "updatedAtBlockHeight", + "columnName": "updatedAtBlockHeight", + "affinity": "INTEGER" + }, + { + "fieldPath": "transferredAtBlockHeight", + "columnName": "transferredAtBlockHeight", + "affinity": "INTEGER" + }, + { + "fieldPath": "createdAtCoreBlockHeight", + "columnName": "createdAtCoreBlockHeight", + "affinity": "INTEGER" + }, + { + "fieldPath": "updatedAtCoreBlockHeight", + "columnName": "updatedAtCoreBlockHeight", + "affinity": "INTEGER" + }, + { + "fieldPath": "transferredAtCoreBlockHeight", + "columnName": "transferredAtCoreBlockHeight", + "affinity": "INTEGER" + }, + { + "fieldPath": "networkRaw", + "columnName": "networkRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isDeleted", + "columnName": "isDeleted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "localCreatedAt", + "columnName": "localCreatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "localUpdatedAt", + "columnName": "localUpdatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "documentTypeRelationId", + "columnName": "documentTypeRelationId", + "affinity": "BLOB" + }, + { + "fieldPath": "dataContractId", + "columnName": "dataContractId", + "affinity": "BLOB" + }, + { + "fieldPath": "ownerIdentityId", + "columnName": "ownerIdentityId", + "affinity": "BLOB" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "documentId" + ] + }, + "indices": [ + { + "name": "index_documents_networkRaw", + "unique": false, + "columnNames": [ + "networkRaw" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_documents_networkRaw` ON `${TABLE_NAME}` (`networkRaw`)" + }, + { + "name": "index_documents_contractId", + "unique": false, + "columnNames": [ + "contractId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_documents_contractId` ON `${TABLE_NAME}` (`contractId`)" + }, + { + "name": "index_documents_ownerId", + "unique": false, + "columnNames": [ + "ownerId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_documents_ownerId` ON `${TABLE_NAME}` (`ownerId`)" + }, + { + "name": "index_documents_documentTypeRelationId", + "unique": false, + "columnNames": [ + "documentTypeRelationId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_documents_documentTypeRelationId` ON `${TABLE_NAME}` (`documentTypeRelationId`)" + }, + { + "name": "index_documents_dataContractId", + "unique": false, + "columnNames": [ + "dataContractId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_documents_dataContractId` ON `${TABLE_NAME}` (`dataContractId`)" + }, + { + "name": "index_documents_ownerIdentityId", + "unique": false, + "columnNames": [ + "ownerIdentityId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_documents_ownerIdentityId` ON `${TABLE_NAME}` (`ownerIdentityId`)" + } + ], + "foreignKeys": [ + { + "table": "document_types", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "documentTypeRelationId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "data_contracts", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "dataContractId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "identities", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "ownerIdentityId" + ], + "referencedColumns": [ + "identityId" + ] + } + ] + }, + { + "tableName": "indices", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` BLOB NOT NULL, `contractId` BLOB NOT NULL, `documentTypeName` TEXT NOT NULL, `name` TEXT NOT NULL, `unique` INTEGER NOT NULL, `nullSearchable` INTEGER NOT NULL, `contested` INTEGER NOT NULL, `propertiesJSON` BLOB NOT NULL, `contestedDetailsJSON` BLOB, `createdAt` INTEGER NOT NULL, `documentTypeId` BLOB, PRIMARY KEY(`id`), FOREIGN KEY(`documentTypeId`) REFERENCES `document_types`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "contractId", + "columnName": "contractId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "documentTypeName", + "columnName": "documentTypeName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "unique", + "columnName": "unique", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "nullSearchable", + "columnName": "nullSearchable", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contested", + "columnName": "contested", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "propertiesJSON", + "columnName": "propertiesJSON", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "contestedDetailsJSON", + "columnName": "contestedDetailsJSON", + "affinity": "BLOB" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "documentTypeId", + "columnName": "documentTypeId", + "affinity": "BLOB" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_indices_documentTypeId", + "unique": false, + "columnNames": [ + "documentTypeId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_indices_documentTypeId` ON `${TABLE_NAME}` (`documentTypeId`)" + } + ], + "foreignKeys": [ + { + "table": "document_types", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "documentTypeId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "keywords", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `keyword` TEXT NOT NULL, `contractId` TEXT NOT NULL, `dataContractId` BLOB, PRIMARY KEY(`id`), FOREIGN KEY(`dataContractId`) REFERENCES `data_contracts`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keyword", + "columnName": "keyword", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "contractId", + "columnName": "contractId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "dataContractId", + "columnName": "dataContractId", + "affinity": "BLOB" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_keywords_contractId", + "unique": false, + "columnNames": [ + "contractId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_keywords_contractId` ON `${TABLE_NAME}` (`contractId`)" + }, + { + "name": "index_keywords_dataContractId", + "unique": false, + "columnNames": [ + "dataContractId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_keywords_dataContractId` ON `${TABLE_NAME}` (`dataContractId`)" + } + ], + "foreignKeys": [ + { + "table": "data_contracts", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "dataContractId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "properties", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` BLOB NOT NULL, `contractId` BLOB NOT NULL, `documentTypeName` TEXT NOT NULL, `name` TEXT NOT NULL, `type` TEXT NOT NULL, `format` TEXT, `contentMediaType` TEXT, `byteArray` INTEGER NOT NULL, `minItems` INTEGER, `maxItems` INTEGER, `pattern` TEXT, `minLength` INTEGER, `maxLength` INTEGER, `minValue` INTEGER, `maxValue` INTEGER, `fieldDescription` TEXT, `transient` INTEGER NOT NULL, `isRequired` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `documentTypeId` BLOB, PRIMARY KEY(`id`), FOREIGN KEY(`documentTypeId`) REFERENCES `document_types`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "contractId", + "columnName": "contractId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "documentTypeName", + "columnName": "documentTypeName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "format", + "columnName": "format", + "affinity": "TEXT" + }, + { + "fieldPath": "contentMediaType", + "columnName": "contentMediaType", + "affinity": "TEXT" + }, + { + "fieldPath": "byteArray", + "columnName": "byteArray", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "minItems", + "columnName": "minItems", + "affinity": "INTEGER" + }, + { + "fieldPath": "maxItems", + "columnName": "maxItems", + "affinity": "INTEGER" + }, + { + "fieldPath": "pattern", + "columnName": "pattern", + "affinity": "TEXT" + }, + { + "fieldPath": "minLength", + "columnName": "minLength", + "affinity": "INTEGER" + }, + { + "fieldPath": "maxLength", + "columnName": "maxLength", + "affinity": "INTEGER" + }, + { + "fieldPath": "minValue", + "columnName": "minValue", + "affinity": "INTEGER" + }, + { + "fieldPath": "maxValue", + "columnName": "maxValue", + "affinity": "INTEGER" + }, + { + "fieldPath": "fieldDescription", + "columnName": "fieldDescription", + "affinity": "TEXT" + }, + { + "fieldPath": "transient", + "columnName": "transient", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isRequired", + "columnName": "isRequired", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "documentTypeId", + "columnName": "documentTypeId", + "affinity": "BLOB" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_properties_documentTypeId", + "unique": false, + "columnNames": [ + "documentTypeId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_properties_documentTypeId` ON `${TABLE_NAME}` (`documentTypeId`)" + } + ], + "foreignKeys": [ + { + "table": "document_types", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "documentTypeId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "pending_inputs", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `outpoint` BLOB NOT NULL, `inputIndex` INTEGER NOT NULL, `spendingTxid` BLOB NOT NULL, `spendingTransactionTxid` BLOB, `walletId` BLOB NOT NULL, `createdAt` INTEGER NOT NULL, `isSweptTombstone` INTEGER NOT NULL DEFAULT 0, `winnerMinedHeight` INTEGER, FOREIGN KEY(`spendingTransactionTxid`) REFERENCES `transactions`(`txid`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "outpoint", + "columnName": "outpoint", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "inputIndex", + "columnName": "inputIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "spendingTxid", + "columnName": "spendingTxid", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "spendingTransactionTxid", + "columnName": "spendingTransactionTxid", + "affinity": "BLOB" + }, + { + "fieldPath": "walletId", + "columnName": "walletId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isSweptTombstone", + "columnName": "isSweptTombstone", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "winnerMinedHeight", + "columnName": "winnerMinedHeight", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_pending_inputs_outpoint", + "unique": false, + "columnNames": [ + "outpoint" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_pending_inputs_outpoint` ON `${TABLE_NAME}` (`outpoint`)" + }, + { + "name": "index_pending_inputs_walletId", + "unique": false, + "columnNames": [ + "walletId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_pending_inputs_walletId` ON `${TABLE_NAME}` (`walletId`)" + }, + { + "name": "index_pending_inputs_spendingTransactionTxid", + "unique": false, + "columnNames": [ + "spendingTransactionTxid" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_pending_inputs_spendingTransactionTxid` ON `${TABLE_NAME}` (`spendingTransactionTxid`)" + }, + { + "name": "index_pending_inputs_spendingTxid", + "unique": false, + "columnNames": [ + "spendingTxid" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_pending_inputs_spendingTxid` ON `${TABLE_NAME}` (`spendingTxid`)" + }, + { + "name": "index_pending_inputs_walletId_isSweptTombstone_winnerMinedHeight", + "unique": false, + "columnNames": [ + "walletId", + "isSweptTombstone", + "winnerMinedHeight" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_pending_inputs_walletId_isSweptTombstone_winnerMinedHeight` ON `${TABLE_NAME}` (`walletId`, `isSweptTombstone`, `winnerMinedHeight`)" + } + ], + "foreignKeys": [ + { + "table": "transactions", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "spendingTransactionTxid" + ], + "referencedColumns": [ + "txid" + ] + } + ] + }, + { + "tableName": "tokens", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` BLOB NOT NULL, `contractId` BLOB NOT NULL, `position` INTEGER NOT NULL, `name` TEXT NOT NULL, `baseSupply` TEXT NOT NULL, `maxSupply` TEXT, `decimals` INTEGER NOT NULL, `localizations` TEXT, `isPaused` INTEGER NOT NULL, `allowTransferToFrozenBalance` INTEGER NOT NULL, `keepsTransferHistory` INTEGER NOT NULL, `keepsFreezingHistory` INTEGER NOT NULL, `keepsMintingHistory` INTEGER NOT NULL, `keepsBurningHistory` INTEGER NOT NULL, `keepsDirectPricingHistory` INTEGER NOT NULL, `keepsDirectPurchaseHistory` INTEGER NOT NULL, `conventionsChangeRules` TEXT, `maxSupplyChangeRules` TEXT, `manualMintingRules` TEXT, `manualBurningRules` TEXT, `freezeRules` TEXT, `unfreezeRules` TEXT, `destroyFrozenFundsRules` TEXT, `emergencyActionRules` TEXT, `perpetualDistribution` TEXT, `preProgrammedDistribution` TEXT, `oncePerIdentityDistribution` TEXT, `newTokensDestinationIdentity` BLOB, `mintingAllowChoosingDestination` INTEGER NOT NULL, `distributionChangeRules` TEXT, `tradeMode` TEXT NOT NULL, `tradeModeChangeRules` TEXT, `mainControlGroupPosition` INTEGER, `mainControlGroupCanBeModified` TEXT, `tokenDescription` TEXT, `createdAt` INTEGER NOT NULL, `lastUpdatedAt` INTEGER NOT NULL, `canManuallyMint` INTEGER NOT NULL, `canManuallyBurn` INTEGER NOT NULL, `canFreeze` INTEGER NOT NULL, `canUnfreeze` INTEGER NOT NULL, `canDestroyFrozenFunds` INTEGER NOT NULL, `hasEmergencyActions` INTEGER NOT NULL, `canChangeMaxSupply` INTEGER NOT NULL, `canChangeConventions` INTEGER NOT NULL, `canChangeTradeMode` INTEGER NOT NULL, `hasDistribution` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`contractId`) REFERENCES `data_contracts`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "contractId", + "columnName": "contractId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "baseSupply", + "columnName": "baseSupply", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "maxSupply", + "columnName": "maxSupply", + "affinity": "TEXT" + }, + { + "fieldPath": "decimals", + "columnName": "decimals", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "localizations", + "columnName": "localizations", + "affinity": "TEXT" + }, + { + "fieldPath": "isPaused", + "columnName": "isPaused", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "allowTransferToFrozenBalance", + "columnName": "allowTransferToFrozenBalance", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "keepsTransferHistory", + "columnName": "keepsTransferHistory", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "keepsFreezingHistory", + "columnName": "keepsFreezingHistory", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "keepsMintingHistory", + "columnName": "keepsMintingHistory", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "keepsBurningHistory", + "columnName": "keepsBurningHistory", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "keepsDirectPricingHistory", + "columnName": "keepsDirectPricingHistory", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "keepsDirectPurchaseHistory", + "columnName": "keepsDirectPurchaseHistory", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "conventionsChangeRules", + "columnName": "conventionsChangeRules", + "affinity": "TEXT" + }, + { + "fieldPath": "maxSupplyChangeRules", + "columnName": "maxSupplyChangeRules", + "affinity": "TEXT" + }, + { + "fieldPath": "manualMintingRules", + "columnName": "manualMintingRules", + "affinity": "TEXT" + }, + { + "fieldPath": "manualBurningRules", + "columnName": "manualBurningRules", + "affinity": "TEXT" + }, + { + "fieldPath": "freezeRules", + "columnName": "freezeRules", + "affinity": "TEXT" + }, + { + "fieldPath": "unfreezeRules", + "columnName": "unfreezeRules", + "affinity": "TEXT" + }, + { + "fieldPath": "destroyFrozenFundsRules", + "columnName": "destroyFrozenFundsRules", + "affinity": "TEXT" + }, + { + "fieldPath": "emergencyActionRules", + "columnName": "emergencyActionRules", + "affinity": "TEXT" + }, + { + "fieldPath": "perpetualDistribution", + "columnName": "perpetualDistribution", + "affinity": "TEXT" + }, + { + "fieldPath": "preProgrammedDistribution", + "columnName": "preProgrammedDistribution", + "affinity": "TEXT" + }, + { + "fieldPath": "oncePerIdentityDistribution", + "columnName": "oncePerIdentityDistribution", + "affinity": "TEXT" + }, + { + "fieldPath": "newTokensDestinationIdentity", + "columnName": "newTokensDestinationIdentity", + "affinity": "BLOB" + }, + { + "fieldPath": "mintingAllowChoosingDestination", + "columnName": "mintingAllowChoosingDestination", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "distributionChangeRules", + "columnName": "distributionChangeRules", + "affinity": "TEXT" + }, + { + "fieldPath": "tradeMode", + "columnName": "tradeMode", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "tradeModeChangeRules", + "columnName": "tradeModeChangeRules", + "affinity": "TEXT" + }, + { + "fieldPath": "mainControlGroupPosition", + "columnName": "mainControlGroupPosition", + "affinity": "INTEGER" + }, + { + "fieldPath": "mainControlGroupCanBeModified", + "columnName": "mainControlGroupCanBeModified", + "affinity": "TEXT" + }, + { + "fieldPath": "tokenDescription", + "columnName": "tokenDescription", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdatedAt", + "columnName": "lastUpdatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "canManuallyMint", + "columnName": "canManuallyMint", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "canManuallyBurn", + "columnName": "canManuallyBurn", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "canFreeze", + "columnName": "canFreeze", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "canUnfreeze", + "columnName": "canUnfreeze", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "canDestroyFrozenFunds", + "columnName": "canDestroyFrozenFunds", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hasEmergencyActions", + "columnName": "hasEmergencyActions", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "canChangeMaxSupply", + "columnName": "canChangeMaxSupply", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "canChangeConventions", + "columnName": "canChangeConventions", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "canChangeTradeMode", + "columnName": "canChangeTradeMode", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hasDistribution", + "columnName": "hasDistribution", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_tokens_contractId", + "unique": false, + "columnNames": [ + "contractId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_tokens_contractId` ON `${TABLE_NAME}` (`contractId`)" + } + ], + "foreignKeys": [ + { + "table": "data_contracts", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "contractId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "token_balances", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `tokenId` TEXT NOT NULL, `identityId` BLOB NOT NULL, `balance` BLOB NOT NULL, `frozen` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, `lastSyncedAt` INTEGER, `tokenName` TEXT, `tokenSymbol` TEXT, `tokenDecimals` INTEGER, `networkRaw` INTEGER NOT NULL, `identityRef` BLOB, `tokenRef` BLOB, FOREIGN KEY(`identityRef`) REFERENCES `identities`(`identityId`) ON UPDATE NO ACTION ON DELETE SET NULL , FOREIGN KEY(`tokenRef`) REFERENCES `tokens`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tokenId", + "columnName": "tokenId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "identityId", + "columnName": "identityId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "balance", + "columnName": "balance", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "frozen", + "columnName": "frozen", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastSyncedAt", + "columnName": "lastSyncedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "tokenName", + "columnName": "tokenName", + "affinity": "TEXT" + }, + { + "fieldPath": "tokenSymbol", + "columnName": "tokenSymbol", + "affinity": "TEXT" + }, + { + "fieldPath": "tokenDecimals", + "columnName": "tokenDecimals", + "affinity": "INTEGER" + }, + { + "fieldPath": "networkRaw", + "columnName": "networkRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "identityRef", + "columnName": "identityRef", + "affinity": "BLOB" + }, + { + "fieldPath": "tokenRef", + "columnName": "tokenRef", + "affinity": "BLOB" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_token_balances_networkRaw", + "unique": false, + "columnNames": [ + "networkRaw" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_token_balances_networkRaw` ON `${TABLE_NAME}` (`networkRaw`)" + }, + { + "name": "index_token_balances_tokenId_identityId", + "unique": false, + "columnNames": [ + "tokenId", + "identityId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_token_balances_tokenId_identityId` ON `${TABLE_NAME}` (`tokenId`, `identityId`)" + }, + { + "name": "index_token_balances_identityId", + "unique": false, + "columnNames": [ + "identityId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_token_balances_identityId` ON `${TABLE_NAME}` (`identityId`)" + }, + { + "name": "index_token_balances_identityRef", + "unique": false, + "columnNames": [ + "identityRef" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_token_balances_identityRef` ON `${TABLE_NAME}` (`identityRef`)" + }, + { + "name": "index_token_balances_tokenRef", + "unique": false, + "columnNames": [ + "tokenRef" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_token_balances_tokenRef` ON `${TABLE_NAME}` (`tokenRef`)" + } + ], + "foreignKeys": [ + { + "table": "identities", + "onDelete": "SET NULL", + "onUpdate": "NO ACTION", + "columns": [ + "identityRef" + ], + "referencedColumns": [ + "identityId" + ] + }, + { + "table": "tokens", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "tokenRef" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "token_history_events", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `eventType` TEXT NOT NULL, `transactionId` BLOB, `blockHeight` INTEGER, `coreBlockHeight` INTEGER, `fromIdentity` BLOB, `toIdentity` BLOB, `performedByIdentity` BLOB NOT NULL, `amount` TEXT, `balanceBefore` TEXT, `balanceAfter` TEXT, `additionalDataJSON` BLOB, `eventDescription` TEXT, `createdAt` INTEGER NOT NULL, `eventTimestamp` INTEGER NOT NULL, `tokenRef` BLOB, PRIMARY KEY(`id`), FOREIGN KEY(`tokenRef`) REFERENCES `tokens`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "eventType", + "columnName": "eventType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "transactionId", + "columnName": "transactionId", + "affinity": "BLOB" + }, + { + "fieldPath": "blockHeight", + "columnName": "blockHeight", + "affinity": "INTEGER" + }, + { + "fieldPath": "coreBlockHeight", + "columnName": "coreBlockHeight", + "affinity": "INTEGER" + }, + { + "fieldPath": "fromIdentity", + "columnName": "fromIdentity", + "affinity": "BLOB" + }, + { + "fieldPath": "toIdentity", + "columnName": "toIdentity", + "affinity": "BLOB" + }, + { + "fieldPath": "performedByIdentity", + "columnName": "performedByIdentity", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "amount", + "columnName": "amount", + "affinity": "TEXT" + }, + { + "fieldPath": "balanceBefore", + "columnName": "balanceBefore", + "affinity": "TEXT" + }, + { + "fieldPath": "balanceAfter", + "columnName": "balanceAfter", + "affinity": "TEXT" + }, + { + "fieldPath": "additionalDataJSON", + "columnName": "additionalDataJSON", + "affinity": "BLOB" + }, + { + "fieldPath": "eventDescription", + "columnName": "eventDescription", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "eventTimestamp", + "columnName": "eventTimestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tokenRef", + "columnName": "tokenRef", + "affinity": "BLOB" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_token_history_events_tokenRef", + "unique": false, + "columnNames": [ + "tokenRef" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_token_history_events_tokenRef` ON `${TABLE_NAME}` (`tokenRef`)" + } + ], + "foreignKeys": [ + { + "table": "tokens", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "tokenRef" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "platform_addresses", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`address` TEXT NOT NULL, `addressType` INTEGER NOT NULL, `addressHash` BLOB NOT NULL, `publicKey` BLOB NOT NULL, `accountIndex` INTEGER NOT NULL, `addressIndex` INTEGER NOT NULL, `derivationPath` TEXT NOT NULL, `isUsed` INTEGER NOT NULL, `balance` INTEGER NOT NULL, `nonce` INTEGER NOT NULL, `firstSeenHeight` INTEGER NOT NULL, `lastSeenHeight` INTEGER NOT NULL, `walletId` BLOB NOT NULL, `createdAt` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, `accountId` INTEGER, PRIMARY KEY(`walletId`, `address`), FOREIGN KEY(`accountId`) REFERENCES `accounts`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "address", + "columnName": "address", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "addressType", + "columnName": "addressType", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "addressHash", + "columnName": "addressHash", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "accountIndex", + "columnName": "accountIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "addressIndex", + "columnName": "addressIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "derivationPath", + "columnName": "derivationPath", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isUsed", + "columnName": "isUsed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "balance", + "columnName": "balance", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "nonce", + "columnName": "nonce", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "firstSeenHeight", + "columnName": "firstSeenHeight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastSeenHeight", + "columnName": "lastSeenHeight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "walletId", + "columnName": "walletId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "walletId", + "address" + ] + }, + "indices": [ + { + "name": "index_platform_addresses_walletId_addressHash", + "unique": true, + "columnNames": [ + "walletId", + "addressHash" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_platform_addresses_walletId_addressHash` ON `${TABLE_NAME}` (`walletId`, `addressHash`)" + }, + { + "name": "index_platform_addresses_accountId", + "unique": false, + "columnNames": [ + "accountId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_platform_addresses_accountId` ON `${TABLE_NAME}` (`accountId`)" + } + ], + "foreignKeys": [ + { + "table": "accounts", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "accountId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "platform_addresses_sync_states", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`walletId` BLOB NOT NULL, `networkRaw` INTEGER NOT NULL, `syncHeight` INTEGER NOT NULL, `syncTimestamp` INTEGER NOT NULL, `lastKnownRecentBlock` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, PRIMARY KEY(`walletId`))", + "fields": [ + { + "fieldPath": "walletId", + "columnName": "walletId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "networkRaw", + "columnName": "networkRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "syncHeight", + "columnName": "syncHeight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "syncTimestamp", + "columnName": "syncTimestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastKnownRecentBlock", + "columnName": "lastKnownRecentBlock", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "walletId" + ] + }, + "indices": [ + { + "name": "index_platform_addresses_sync_states_networkRaw", + "unique": false, + "columnNames": [ + "networkRaw" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_platform_addresses_sync_states_networkRaw` ON `${TABLE_NAME}` (`networkRaw`)" + } + ] + }, + { + "tableName": "shielded_notes", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`nullifier` BLOB NOT NULL, `walletId` BLOB NOT NULL, `accountIndex` INTEGER NOT NULL, `position` INTEGER NOT NULL, `cmx` BLOB NOT NULL, `blockHeight` INTEGER NOT NULL, `isSpent` INTEGER NOT NULL, `value` INTEGER NOT NULL, `noteData` BLOB NOT NULL, `createdAt` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, PRIMARY KEY(`nullifier`))", + "fields": [ + { + "fieldPath": "nullifier", + "columnName": "nullifier", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "walletId", + "columnName": "walletId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "accountIndex", + "columnName": "accountIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "cmx", + "columnName": "cmx", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "blockHeight", + "columnName": "blockHeight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isSpent", + "columnName": "isSpent", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "noteData", + "columnName": "noteData", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "nullifier" + ] + }, + "indices": [ + { + "name": "index_shielded_notes_walletId_accountIndex", + "unique": false, + "columnNames": [ + "walletId", + "accountIndex" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_shielded_notes_walletId_accountIndex` ON `${TABLE_NAME}` (`walletId`, `accountIndex`)" + } + ] + }, + { + "tableName": "shielded_outgoing_notes", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`walletId` BLOB NOT NULL, `accountIndex` INTEGER NOT NULL, `cmx` BLOB NOT NULL, `recipient` BLOB NOT NULL, `value` INTEGER NOT NULL, `memo` BLOB NOT NULL, `blockHeight` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, PRIMARY KEY(`walletId`, `accountIndex`, `cmx`))", + "fields": [ + { + "fieldPath": "walletId", + "columnName": "walletId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "accountIndex", + "columnName": "accountIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "cmx", + "columnName": "cmx", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "recipient", + "columnName": "recipient", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "memo", + "columnName": "memo", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "blockHeight", + "columnName": "blockHeight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "walletId", + "accountIndex", + "cmx" + ] + }, + "indices": [ + { + "name": "index_shielded_outgoing_notes_walletId_accountIndex", + "unique": false, + "columnNames": [ + "walletId", + "accountIndex" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_shielded_outgoing_notes_walletId_accountIndex` ON `${TABLE_NAME}` (`walletId`, `accountIndex`)" + } + ] + }, + { + "tableName": "shielded_activities", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`walletId` BLOB NOT NULL, `accountIndex` INTEGER NOT NULL, `entryId` BLOB NOT NULL, `kindTag` INTEGER NOT NULL, `direction` INTEGER NOT NULL, `status` INTEGER NOT NULL, `amount` INTEGER NOT NULL, `fee` INTEGER NOT NULL, `hasFee` INTEGER NOT NULL, `blockHeight` INTEGER NOT NULL, `hasBlockHeight` INTEGER NOT NULL, `createdAtMs` INTEGER NOT NULL, `identityId` BLOB NOT NULL, `counterparty` BLOB NOT NULL, `memo` BLOB NOT NULL, `noteCmxs` BLOB NOT NULL, `spentNullifiers` BLOB NOT NULL, `createdAt` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, PRIMARY KEY(`walletId`, `accountIndex`, `entryId`))", + "fields": [ + { + "fieldPath": "walletId", + "columnName": "walletId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "accountIndex", + "columnName": "accountIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "entryId", + "columnName": "entryId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "kindTag", + "columnName": "kindTag", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "direction", + "columnName": "direction", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "amount", + "columnName": "amount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "fee", + "columnName": "fee", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hasFee", + "columnName": "hasFee", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "blockHeight", + "columnName": "blockHeight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hasBlockHeight", + "columnName": "hasBlockHeight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAtMs", + "columnName": "createdAtMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "identityId", + "columnName": "identityId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "counterparty", + "columnName": "counterparty", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "memo", + "columnName": "memo", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "noteCmxs", + "columnName": "noteCmxs", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "spentNullifiers", + "columnName": "spentNullifiers", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "walletId", + "accountIndex", + "entryId" + ] + }, + "indices": [ + { + "name": "index_shielded_activities_walletId_accountIndex", + "unique": false, + "columnNames": [ + "walletId", + "accountIndex" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_shielded_activities_walletId_accountIndex` ON `${TABLE_NAME}` (`walletId`, `accountIndex`)" + } + ] + }, + { + "tableName": "shielded_sync_states", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`walletId` BLOB NOT NULL, `accountIndex` INTEGER NOT NULL, `lastSyncedIndex` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, PRIMARY KEY(`walletId`, `accountIndex`))", + "fields": [ + { + "fieldPath": "walletId", + "columnName": "walletId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "accountIndex", + "columnName": "accountIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastSyncedIndex", + "columnName": "lastSyncedIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "walletId", + "accountIndex" + ] + }, + "indices": [ + { + "name": "index_shielded_sync_states_walletId", + "unique": false, + "columnNames": [ + "walletId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_shielded_sync_states_walletId` ON `${TABLE_NAME}` (`walletId`)" + } + ] + }, + { + "tableName": "shielded_viewing_keys", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`walletId` BLOB NOT NULL, `accountIndex` INTEGER NOT NULL, `fvkBytes` BLOB NOT NULL, `lastUpdated` INTEGER NOT NULL, PRIMARY KEY(`walletId`, `accountIndex`))", + "fields": [ + { + "fieldPath": "walletId", + "columnName": "walletId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "accountIndex", + "columnName": "accountIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "fvkBytes", + "columnName": "fvkBytes", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "walletId", + "accountIndex" + ] + }, + "indices": [ + { + "name": "index_shielded_viewing_keys_walletId", + "unique": false, + "columnNames": [ + "walletId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_shielded_viewing_keys_walletId` ON `${TABLE_NAME}` (`walletId`)" + } + ] + }, + { + "tableName": "wallet_manager_metadata", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`networkRaw` INTEGER NOT NULL, `combinedSyncHeight` INTEGER NOT NULL, `combinedSyncBlockHash` BLOB, `walletCount` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, PRIMARY KEY(`networkRaw`))", + "fields": [ + { + "fieldPath": "networkRaw", + "columnName": "networkRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "combinedSyncHeight", + "columnName": "combinedSyncHeight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "combinedSyncBlockHash", + "columnName": "combinedSyncBlockHash", + "affinity": "BLOB" + }, + { + "fieldPath": "walletCount", + "columnName": "walletCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "networkRaw" + ] + } + }, + { + "tableName": "private_contact_requests_positions", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`walletId` BLOB NOT NULL, `identityId` BLOB NOT NULL, `position` INTEGER NOT NULL, `keyFingerprint` BLOB NOT NULL, PRIMARY KEY(`walletId`, `identityId`))", + "fields": [ + { + "fieldPath": "walletId", + "columnName": "walletId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "identityId", + "columnName": "identityId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "keyFingerprint", + "columnName": "keyFingerprint", + "affinity": "BLOB", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "walletId", + "identityId" + ] + } + }, + { + "tableName": "private_shielded_addresses", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`walletId` BLOB NOT NULL, `ownerId` BLOB NOT NULL, `contactId` BLOB NOT NULL, `address` BLOB NOT NULL, PRIMARY KEY(`walletId`, `ownerId`, `contactId`))", + "fields": [ + { + "fieldPath": "walletId", + "columnName": "walletId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "ownerId", + "columnName": "ownerId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "contactId", + "columnName": "contactId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "address", + "columnName": "address", + "affinity": "BLOB", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "walletId", + "ownerId", + "contactId" + ] + } + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'cfad080e6fde9c3bc142ef0829b3af4b')" + ] + } +} \ No newline at end of file From 808bb9b938b6b33ce669cfefdb1a14d0b2e9668f Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 9 Nov 2026 10:01:04 +0700 Subject: [PATCH 106/269] docs(sdk): record the private contact request scan state in the parity manifest Declares the `private_contact_request_scan_state` persistence capability, adds `dashpay.private_contact_request_scan_state` with the three extension slots and the tests on each layer, and notes the unavailable pending state and the code 71 sync offer in the private contact request entry. Co-Authored-By: Claude Opus 5.5 --- docs/sdk/sdk-parity-manifest.json | 79 ++++++++++++++++++++++++++- packages/kotlin-sdk/PARITY_SUMMARY.md | 15 ++--- 2 files changed, 85 insertions(+), 9 deletions(-) diff --git a/docs/sdk/sdk-parity-manifest.json b/docs/sdk/sdk-parity-manifest.json index a7a6cd1510f..5c0277b3646 100644 --- a/docs/sdk/sdk-parity-manifest.json +++ b/docs/sdk/sdk-parity-manifest.json @@ -8,6 +8,7 @@ "atomic_changesets", "invitations", "pending_contact_crypto", + "private_contact_request_scan_state", "provider_transactions", "shielded_viewing_keys", "tracked_asset_locks", @@ -32,10 +33,13 @@ "managed_identity_get_dashpay_payments": "packages/rs-platform-wallet-ffi/src/dashpay_payment.rs", "managed_identity_get_private_contact": "packages/rs-platform-wallet-ffi/src/private_contact_request.rs", "managed_identity_get_private_reply_pending": "packages/rs-platform-wallet-ffi/src/private_contact_request.rs", + "on_load_private_contact_scan_state_fn": "packages/rs-platform-wallet-ffi/src/persistence.rs", + "on_load_private_contact_scan_state_free_fn": "packages/rs-platform-wallet-ffi/src/persistence.rs", "on_load_shielded_viewing_keys_fn": "packages/rs-platform-wallet-ffi/src/persistence.rs", "on_load_shielded_viewing_keys_free_fn": "packages/rs-platform-wallet-ffi/src/persistence.rs", "on_persist_address_balances_fn": "packages/rs-platform-wallet-ffi/src/persistence.rs", "on_persist_dashpay_payments_fn": "packages/rs-platform-wallet-ffi/src/persistence.rs", + "on_persist_private_contact_scan_state_fn": "packages/rs-platform-wallet-ffi/src/persistence.rs", "on_persist_shielded_viewing_keys_fn": "packages/rs-platform-wallet-ffi/src/persistence.rs", "platform_address_wallet_addresses_with_balances": "packages/rs-platform-wallet-ffi/src/platform_addresses/wallet.rs", "platform_wallet_claim_invitation": "packages/rs-platform-wallet-ffi/src/invitation.rs", @@ -797,13 +801,13 @@ "sdk": "partial", "example_app": "partial", "restart": "required", - "reason": "PrivateContactRequests.swift wraps the store sync, the shielded send, the accept replying in kind (the wallet replies privately to a request that came privately, and with a document otherwise; a public or adopted reply needs no shielded support or payment mnemonic read, and a build without shielded support refuses only a private reply, with code 69), the explicit private-reply accept, PrivateContactInfo and the wallet's pending-reply read, all signed with the identity signer the public calls take and counted as native manager operations; codes 68 to 71 are typed and pinned in ErrorHandlingTests. The sync, send and accepts have no automated or recorded gate: a private reply needs a protocol version 15 network with shielded funds. The private marker (core height 0) round-trips through the persisted coreHeightCreatedAt, but no test covers a restart, and the wallet keeps a pending reply in memory only. The example app has the send-privately toggle, accepts with acceptContactRequestReplyingInKind, keeps a pending or unconfirmed private send or reply pending by reading the wallet's flag, and reports private refresh failures (quiet before protocol version 15), with no recorded gate." + "reason": "PrivateContactRequests.swift wraps the store sync, the shielded send, the accept replying in kind (the wallet replies privately to a request that came privately, and with a document otherwise; a public or adopted reply needs no shielded support or payment mnemonic read, and a build without shielded support refuses only a private reply, with code 69), the explicit private-reply accept, PrivateContactInfo and the wallet's pending-reply read, all signed with the identity signer the public calls take and counted as native manager operations; codes 68 to 71 are typed and pinned in ErrorHandlingTests. The sync, send and accepts have no automated or recorded gate: a private reply needs a protocol version 15 network with shielded funds. The private marker (core height 0) round-trips through the persisted coreHeightCreatedAt, but no test covers a restart, and the wallet keeps a pending reply in memory only. The example app has the send-privately toggle, accepts with acceptContactRequestReplyingInKind, keeps a pending or unconfirmed private send or reply pending by reading the wallet's flag (a failed read keeps the last snapshot and the controls off), offers a private sync for a pending request a public send or accept meets (code 71), and reports private refresh failures (quiet before protocol version 15), with no recorded gate." }, "kotlin": { "sdk": "partial", "example_app": "partial", "restart": "required", - "reason": "Dashpay.syncPrivateContactRequests, privateContact and privateRepliesPending (fresh managed-identity snapshots) and PlatformWalletManager.sendPrivateContactRequest / acceptContactRequestReplyingInKind / acceptPrivateContactRequest bridge the same FFI (signed with the manager's identity signer, behind the manager's teardown gate); the in-kind accept is exported in every build, and codes 68 to 71 map to typed PlatformWallet errors (DashSdkErrorTest). JNI descriptors are covered only by the FfiSmokeTest symbol test; the flows need a protocol version 15 network with shielded funds and have no recorded gate, no test covers a restart, and the wallet keeps a pending reply in memory only. The example app accepts with acceptContactRequestReplyingInKind, keeps a pending or unconfirmed reply pending by reading the wallet's flag on its Requests rows and Add Contact, and reports private refresh failures (quiet before protocol version 15); it has no send-privately toggle." + "reason": "Dashpay.syncPrivateContactRequests, privateContact and privateRepliesPending (fresh managed-identity snapshots) and PlatformWalletManager.sendPrivateContactRequest / acceptContactRequestReplyingInKind / acceptPrivateContactRequest bridge the same FFI (signed with the manager's identity signer, behind the manager's teardown gate); the in-kind accept is exported in every build, and codes 68 to 71 map to typed PlatformWallet errors (DashSdkErrorTest). JNI descriptors are covered only by the FfiSmokeTest symbol test; the flows need a protocol version 15 network with shielded funds and have no recorded gate, no test covers a restart, and the wallet keeps a pending reply in memory only. The example app accepts with acceptContactRequestReplyingInKind, keeps a pending or unconfirmed reply pending by reading the wallet's flag on its Requests rows and Add Contact (a failed read keeps the last snapshot and the controls off), offers a private sync for a pending request a public send or accept meets (code 71), and reports private refresh failures (quiet before protocol version 15); it has no send-privately toggle." } }, "verification": [ @@ -913,6 +917,77 @@ } ] }, + { + "id": "dashpay.private_contact_request_scan_state", + "title": "DashPay private contact request sync resumes after restart from its persisted read position", + "area": "persistence", + "shared_apis": [ + "on_persist_private_contact_scan_state_fn", + "on_load_private_contact_scan_state_fn", + "on_load_private_contact_scan_state_free_fn" + ], + "required_persistence_capabilities": [ + "atomic_changesets", + "private_contact_request_scan_state", + "wallet_restore" + ], + "hosts": { + "swift": { + "sdk": "partial", + "example_app": "partial", + "restart": "required", + "reason": "SwiftData schema V5 stores each identity's read position and its private contacts' shielded addresses (PersistentPrivateContactRequestsPosition, PersistentPrivateShieldedAddress) inside the changeset round, clears the state of a removed identity, purges it with the wallet, hands it back through the load/free shims and declares the capability; the example app's Storage Explorer lists both models. No test restarts a process and resumes a private sync from the stored position." + }, + "kotlin": { + "sdk": "partial", + "example_app": "not-applicable", + "restart": "required", + "reason": "Room v16 (migration 15 to 16) stores the same rows in the round, clears a removed identity's state, purges it with the wallet and declares the capability; JNI wires the persist/load/free slots only for a bridge that overrides both callbacks, and rows cross as packed byte arrays. The example app has no storage explorer. No test restarts a process and resumes a private sync from the stored position." + } + }, + "verification": [ + { + "host": "shared", + "kind": "unit", + "file": "packages/rs-platform-wallet-ffi/src/persistence.rs", + "id": "should_restore_the_private_contact_request_scan_state_onto_its_identity", + "command": "cargo test -p platform-wallet-ffi --lib should_restore_the_private_contact_request_scan_state_onto_its_identity", + "covers_restart": false + }, + { + "host": "shared", + "kind": "unit", + "file": "packages/rs-platform-wallet-ffi/src/persistence.rs", + "id": "should_leave_hosts_without_the_scan_state_capability_unchanged", + "command": "cargo test -p platform-wallet-ffi --lib should_leave_hosts_without_the_scan_state_capability_unchanged", + "covers_restart": false + }, + { + "host": "swift", + "kind": "unit", + "file": "packages/swift-sdk/SwiftTests/SwiftDashSDKTests/PrivateContactScanStatePersistenceTests.swift", + "id": "testShouldDeclareTheCapabilityAndRoundTripThroughTheCallbackShims", + "command": "cd packages/swift-sdk && xcodebuild test -scheme SwiftDashSDK -destination 'platform=iOS Simulator,name=iPhone 17' -only-testing:SwiftDashSDKTests/PrivateContactScanStatePersistenceTests", + "covers_restart": false + }, + { + "host": "kotlin", + "kind": "unit", + "file": "packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/persistence/PrivateContactScanStatePersistenceTest.kt", + "id": "shouldStoreReplaceAndRemoveTheScanStateWithItsRound", + "command": "cd packages/kotlin-sdk && ./gradlew :sdk:testDebugUnitTest --tests '*PrivateContactScanStatePersistenceTest*'", + "covers_restart": false + }, + { + "host": "kotlin", + "kind": "unit", + "file": "packages/rs-unified-sdk-jni/src/persistence.rs", + "id": "the_scan_state_slots_are_wired_only_for_a_bridge_that_stores_and_loads_it", + "command": "cargo test -p rs-unified-sdk-jni --lib the_scan_state_slots_are_wired_only_for_a_bridge_that_stores_and_loads_it", + "covers_restart": false + } + ] + }, { "id": "identity.asset_lock_resume", "title": "Identity registration and top-up resume from tracked locks", diff --git a/packages/kotlin-sdk/PARITY_SUMMARY.md b/packages/kotlin-sdk/PARITY_SUMMARY.md index 5b6a2af0178..7bc14e553d1 100644 --- a/packages/kotlin-sdk/PARITY_SUMMARY.md +++ b/packages/kotlin-sdk/PARITY_SUMMARY.md @@ -2,23 +2,23 @@ # Kotlin/Swift executable parity summary Audit baseline: `PR #3999 @ 6dbc72a54df72d26eb9c4a014b425d2b95134e4e` -Capabilities tracked: **28** +Capabilities tracked: **29** ## Status counts | Host | Surface | Supported | Partial | Unsupported | Not applicable | | --- | --- | ---: | ---: | ---: | ---: | -| Swift | SDK | 16 | 10 | 1 | 1 | -| Swift | Example app | 4 | 13 | 2 | 9 | -| Kotlin | SDK | 13 | 13 | 1 | 1 | -| Kotlin | Example app | 5 | 13 | 1 | 9 | +| Swift | SDK | 16 | 11 | 1 | 1 | +| Swift | Example app | 4 | 14 | 2 | 9 | +| Kotlin | SDK | 13 | 14 | 1 | 1 | +| Kotlin | Example app | 5 | 13 | 1 | 10 | ## Restart coverage | Host | Tested | Required | Not applicable | | --- | ---: | ---: | ---: | -| Swift | 1 | 9 | 18 | -| Kotlin | 4 | 7 | 17 | +| Swift | 1 | 10 | 18 | +| Kotlin | 4 | 8 | 17 | ## Capability status @@ -34,6 +34,7 @@ Capabilities tracked: **28** | `core.provider_restore` | supported / partial / required | partial / partial / required | | `dashpay.deferred_contact_crypto` | partial / partial / required | partial / partial / required | | `dashpay.private_contact_requests` | partial / partial / required | partial / partial / required | +| `dashpay.private_contact_request_scan_state` | partial / partial / required | partial / not-applicable / required | | `identity.asset_lock_resume` | supported / partial / required | partial / partial / required | | `core.compact_filter_rescan` | supported / partial / not_applicable | partial / partial / not_applicable | | `dpns.contested_names_by_identity` | supported / partial / not_applicable | partial / partial / required | From 31d393e4ab55e5eb7597064c33eed4dec08d76c1 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 9 Nov 2026 10:08:05 +0700 Subject: [PATCH 107/269] test(kotlin-sdk): expect Room schema 16 in the schema version test The scan state migration (15 -> 16) moved the schema to version 16; the schema version unit test still asserted 15. Co-Authored-By: Claude Opus 5.5 --- .../dashsdk/persistence/DashDatabaseTest.kt | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/persistence/DashDatabaseTest.kt b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/persistence/DashDatabaseTest.kt index 41326e84f92..ac1ae6f77ae 100644 --- a/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/persistence/DashDatabaseTest.kt +++ b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/persistence/DashDatabaseTest.kt @@ -233,16 +233,17 @@ class DashDatabaseTest { } @Test - fun schemaIsAtVersion15WithTheSweepHoldIndexes() = runTest { + fun schemaIsAtVersion16WithTheSweepHoldIndexes() = runTest { // The sweep-hold columns land in ONE migration (10 → 11), with the // two `pending_inputs` indexes the sweep's claimed-row lookup // (`spendingTxid`) and the end-of-round collector // (`walletId, isSweptTombstone, winnerMinedHeight`) rely on. // 11 → 12 adds the identity key usage limits columns on top, // 12 → 13 the contract bounds kind, 13 → 14 the token - // once-per-identity distribution block, and 14 → 15 the DashPay - // backfill record columns on `wallets`. - assertEquals(15, db.openHelper.readableDatabase.version) + // once-per-identity distribution block, 14 → 15 the DashPay + // backfill record columns on `wallets`, and 15 → 16 the private + // contact request scan state tables. + assertEquals(16, db.openHelper.readableDatabase.version) val indexes = mutableSetOf() db.openHelper.readableDatabase.query("PRAGMA index_list('pending_inputs')").use { c -> val nameColumn = c.getColumnIndexOrThrow("name") From b065ca3853256cd94bf039a342f90948ff2df71a Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 9 Nov 2026 10:08:54 +0700 Subject: [PATCH 108/269] 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 109/269] 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 110/269] 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 111/269] 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 112/269] 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 113/269] 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 114/269] 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 115/269] 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 116/269] 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 117/269] 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 118/269] 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 5166952033e70b84798ba7f48db64165c00aece8 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 9 Nov 2026 12:04:24 +0700 Subject: [PATCH 119/269] test(platform-wallet-ffi): pin the layouts of the private contact request FFI structs Compile-time size, alignment and field-offset guards, as the contact structs have: the scan-position row (72/8), the shielded-address row (107/1), `PrivateContactSyncReportFFI` (24/8) and `PrivateContactFFI` (46/1), on 64-bit targets. Co-Authored-By: Claude Opus 5.5 --- .../src/contact_persistence.rs | 25 +++++++++++++++ .../src/private_contact_request.rs | 32 +++++++++++++++++++ 2 files changed, 57 insertions(+) diff --git a/packages/rs-platform-wallet-ffi/src/contact_persistence.rs b/packages/rs-platform-wallet-ffi/src/contact_persistence.rs index eedd9996390..ada1d0cdb79 100644 --- a/packages/rs-platform-wallet-ffi/src/contact_persistence.rs +++ b/packages/rs-platform-wallet-ffi/src/contact_persistence.rs @@ -308,6 +308,31 @@ const _: [u8; 1] = [0u8; std::mem::align_of::()]; const _: [u8; 65] = [0u8; std::mem::size_of::()]; const _: [u8; 1] = [0u8; std::mem::align_of::()]; +// Expected `PrivateContactRequestsPositionFFI` layout on 64-bit targets: +// +// 0..=31 identity_id [u8; 32] +// 32..=39 position u64 +// 40..=71 key_fingerprint [u8; 32] +// +// Total size = 72, alignment = 8 (from the u64). +const _: [u8; 72] = [0u8; std::mem::size_of::()]; +const _: [u8; 8] = [0u8; std::mem::align_of::()]; +const _: () = assert!(std::mem::offset_of!(PrivateContactRequestsPositionFFI, position) == 32); +const _: () = + assert!(std::mem::offset_of!(PrivateContactRequestsPositionFFI, key_fingerprint) == 40); + +// Expected `PrivateShieldedAddressFFI` layout on all targets: +// +// 0..=31 owner_id [u8; 32] +// 32..=63 contact_id [u8; 32] +// 64..=106 address [u8; 43] +// +// Total size = 107, alignment = 1 (all-byte fields). +const _: [u8; 107] = [0u8; std::mem::size_of::()]; +const _: [u8; 1] = [0u8; std::mem::align_of::()]; +const _: () = assert!(std::mem::offset_of!(PrivateShieldedAddressFFI, contact_id) == 32); +const _: () = assert!(std::mem::offset_of!(PrivateShieldedAddressFFI, address) == 64); + impl ContactIgnoredSenderFFI { /// Project an `(owner, sender)` ignore key onto its flat C mirror. /// `is_ignored` distinguishes an ignore (persist the row) from an diff --git a/packages/rs-platform-wallet-ffi/src/private_contact_request.rs b/packages/rs-platform-wallet-ffi/src/private_contact_request.rs index c5ad4d911a4..069e24ab81d 100644 --- a/packages/rs-platform-wallet-ffi/src/private_contact_request.rs +++ b/packages/rs-platform-wallet-ffi/src/private_contact_request.rs @@ -97,6 +97,24 @@ pub struct PrivateContactSyncReportFFI { pub complete: bool, } +// Expected `PrivateContactSyncReportFFI` layout on 64-bit targets: +// +// 0..=7 next_position u64 +// 8..=11 pages_read u32 +// 12..=15 rejected_count u32 +// 16..=19 unpersisted_identity_count u32 +// 20 complete bool +// 21..=23 (tail padding to 8) +// +// Total size = 24, alignment = 8 (from the u64). +const _: [u8; 24] = [0u8; std::mem::size_of::()]; +const _: [u8; 8] = [0u8; std::mem::align_of::()]; +const _: () = assert!(std::mem::offset_of!(PrivateContactSyncReportFFI, pages_read) == 8); +const _: () = assert!(std::mem::offset_of!(PrivateContactSyncReportFFI, rejected_count) == 12); +const _: () = + assert!(std::mem::offset_of!(PrivateContactSyncReportFFI, unpersisted_identity_count) == 16); +const _: () = assert!(std::mem::offset_of!(PrivateContactSyncReportFFI, complete) == 20); + fn saturating_count(count: usize) -> u32 { u32::try_from(count).unwrap_or(u32::MAX) } @@ -191,6 +209,20 @@ pub struct PrivateContactFFI { pub shielded_address: [u8; 43], } +// Expected `PrivateContactFFI` layout on all targets: +// +// 0 received_privately bool +// 1 sent_privately bool +// 2 has_shielded_address bool +// 3..=45 shielded_address [u8; 43] +// +// Total size = 46, alignment = 1 (all-byte fields). +const _: [u8; 46] = [0u8; std::mem::size_of::()]; +const _: [u8; 1] = [0u8; std::mem::align_of::()]; +const _: () = assert!(std::mem::offset_of!(PrivateContactFFI, sent_privately) == 1); +const _: () = assert!(std::mem::offset_of!(PrivateContactFFI, has_shielded_address) == 2); +const _: () = assert!(std::mem::offset_of!(PrivateContactFFI, shielded_address) == 3); + impl PrivateContactFFI { fn empty() -> Self { Self { From 26bdbf7e50e4ec87e9e296b1fe134580f37d00d6 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 9 Nov 2026 12:04:24 +0700 Subject: [PATCH 120/269] fix(platform-wallet-ffi): require contact persistence for the private scan-state capability A read position vouches for the contact requests read before it. Without `on_persist_contacts_fn`, `FFIPersister::store` skips those requests and returns success, so the advanced position was stored without them and a restart skipped the entries that carried them. The capability now also needs the contacts callback; a host with the scan-state trio and the declaration but no contacts callback is not attested and has nothing read at load. Co-Authored-By: Claude Opus 5.5 --- .../rs-platform-wallet-ffi/src/persistence.rs | 54 ++++++++++++++++--- 1 file changed, 47 insertions(+), 7 deletions(-) diff --git a/packages/rs-platform-wallet-ffi/src/persistence.rs b/packages/rs-platform-wallet-ffi/src/persistence.rs index f1b6636fce0..0ba37f7e406 100644 --- a/packages/rs-platform-wallet-ffi/src/persistence.rs +++ b/packages/rs-platform-wallet-ffi/src/persistence.rs @@ -169,7 +169,8 @@ pub const PLATFORM_WALLET_PERSISTENCE_CAPABILITY_ASSET_LOCK_OPERATION_IDS: u64 = /// `on_persist_private_contact_scan_state_fn` + /// `on_load_private_contact_scan_state_fn` + /// `on_load_private_contact_scan_state_free_fn`, the wallet-list restore -/// callbacks, and the host declaring the bit. +/// callbacks, `on_persist_contacts_fn` (a read position vouches for the +/// contact requests read before it), and the host declaring the bit. pub const PLATFORM_WALLET_PERSISTENCE_CAPABILITY_PRIVATE_CONTACT_REQUEST_SCAN_STATE: u64 = 1 << 14; /// Version of [`PersistenceCallbacksExtension`]. The extension is deliberately @@ -1795,8 +1796,13 @@ impl FFIPersister { capabilities = capabilities.union(PersistenceCapabilities::UNSIGNED_TOKEN_STORAGE); } // The scan state is restored per wallet during the wallet-list load, - // so that load is required alongside the extension trio. + // so that load is required alongside the extension trio. So is the + // contacts callback: a read position vouches for the contact + // requests read before it, and without that callback `store` skips + // them, so a restored position would skip entries whose requests + // were never kept. if wallet_restore + && self.callbacks.on_persist_contacts_fn.is_some() && self .tracked_masternodes_callbacks .persist_private_contact_scan_state @@ -8180,10 +8186,35 @@ mod tests { drop(Box::from_raw(row.identities.cast_mut())); } + #[allow(clippy::too_many_arguments)] + unsafe extern "C" fn keep_scan_state_contacts( + _: *mut c_void, + _: *const u8, + _: *const ContactRequestFFI, + _: usize, + _: *const ContactRequestRemovalFFI, + _: usize, + _: *const ContactRequestRemovalFFI, + _: usize, + _: *const ContactIgnoredSenderFFI, + _: usize, + ) -> i32 { + 0 + } + fn scan_state_persister( host: &ScanStateHost, declared: PersistenceCapabilities, extensions: PersistenceExtensionCallbacks, + ) -> FFIPersister { + scan_state_persister_with_contacts(host, declared, extensions, true) + } + + fn scan_state_persister_with_contacts( + host: &ScanStateHost, + declared: PersistenceCapabilities, + extensions: PersistenceExtensionCallbacks, + persists_contacts: bool, ) -> FFIPersister { FFIPersister::new_with_persistence_capabilities_and_extensions( PersistenceCallbacks { @@ -8192,6 +8223,7 @@ mod tests { on_changeset_end_fn: Some(noop_end), on_load_wallet_list_fn: Some(load_one_identity), on_load_wallet_list_free_fn: Some(free_one_identity), + on_persist_contacts_fn: persists_contacts.then_some(keep_scan_state_contacts), ..Default::default() }, declared, @@ -8358,10 +8390,10 @@ mod tests { } } - /// A host that does not declare the capability, or wires only part of - /// the trio, is not attested, has nothing read at load, and still - /// stores and loads everything else. A host without the slots stores a - /// round carrying scan state without error. + /// A host that does not declare the capability, wires only part of the + /// trio, or keeps no contact requests, is not attested, has nothing read + /// at load, and still stores and loads everything else. A host without + /// the slots stores a round carrying scan state without error. #[test] fn should_leave_hosts_without_the_scan_state_capability_unchanged() { let host = ScanStateHost::default(); @@ -8389,7 +8421,15 @@ mod tests { PersistenceCapabilities::PRIVATE_CONTACT_REQUEST_SCAN_STATE, PersistenceExtensionCallbacks::default(), ); - for persister in [&undeclared, &half_wired, &unwired] { + // A read position vouches for the requests read before it, which a + // host without the contacts callback never keeps. + let without_contacts = scan_state_persister_with_contacts( + &host, + PersistenceCapabilities::PRIVATE_CONTACT_REQUEST_SCAN_STATE, + scan_state_trio(), + false, + ); + for persister in [&undeclared, &half_wired, &unwired, &without_contacts] { assert!(!persister .persistence_capabilities() .contains(PersistenceCapabilities::PRIVATE_CONTACT_REQUEST_SCAN_STATE)); From 17ffe98f9c740f61818188a40a641950387c5444 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 9 Nov 2026 12:04:24 +0700 Subject: [PATCH 121/269] fix(platform-wallet-ffi): keep protocol-version refusals typed through map_spend_result The store read a private send or accept makes before paying can return `Sdk(ProtocolVersionNotActive)`, which the spend mapper flattened to `ErrorWalletOperation` (6). It now passes through the blanket conversion, like `PrivateContactPaymentsNotRestored`: a lasting refusal is 70, a transient one `ErrorUnknown`, as on every other path. Co-Authored-By: Claude Opus 5.5 --- .../src/shielded_send.rs | 55 +++++++++++++++++-- 1 file changed, 51 insertions(+), 4 deletions(-) diff --git a/packages/rs-platform-wallet-ffi/src/shielded_send.rs b/packages/rs-platform-wallet-ffi/src/shielded_send.rs index 19b67d45942..1702ebe696c 100644 --- a/packages/rs-platform-wallet-ffi/src/shielded_send.rs +++ b/packages/rs-platform-wallet-ffi/src/shielded_send.rs @@ -622,11 +622,18 @@ 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, as do unrestored + // private payments. 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::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 @@ -3023,6 +3030,46 @@ mod tests { assert!(message_of(&result).contains("this request was not started")); } + /// A protocol-version refusal from the store read a private send or + /// accept makes before paying keeps the blanket conversion's split: a + /// lasting refusal is code 70, a transient one the catch-all. + #[test] + fn map_spend_result_keeps_a_protocol_version_refusal_typed() { + let refused = |cause: Option>| { + map_spend_result( + Err(PlatformWalletError::Sdk( + dash_sdk::Error::ProtocolVersionNotActive { + method: "get_private_document_entries", + required: 15, + feature: None, + network: 14, + pinned: false, + cause, + }, + )), + "private contact request", + ) + }; + let lasting = refused(None); + assert_eq!( + lasting.code, + PlatformWalletFFIResultCode::ErrorProtocolVersionNotActive + ); + assert_eq!(lasting.code as i32, 70); + let transient = refused(Some(Box::new(dash_sdk::Error::Generic( + "no node answered".to_string(), + )))); + assert_eq!(transient.code, PlatformWalletFFIResultCode::ErrorUnknown); + assert_eq!( + map_spend_result( + Err(PlatformWalletError::PrivateContactPaymentsNotRestored), + "private contact request", + ) + .code, + PlatformWalletFFIResultCode::ErrorPrivateContactRequestPending + ); + } + /// A private payment refused because its wallet was removed first keeps /// the lifecycle refusal code, not the generic operation failure. #[test] From 0f884bc027b57dae378b00c006b4448e49c7d41a Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 9 Nov 2026 12:04:24 +0700 Subject: [PATCH 122/269] fix(platform-wallet-ffi): report a seed mismatch from the private store by its DashPay code Each private sync pass, and the store read a send or accept makes first, now checks that the resolver's seed is the wallet's. A mismatch reached the host as `ErrorUnknown` (or `ErrorWalletOperation` through the spend mapper); it now takes `ErrorInvalidParameter`, the code every DashPay export and the seed check give it. Co-Authored-By: Claude Opus 5.5 --- .../src/private_contact_request.rs | 50 +++++++++++++++++-- 1 file changed, 47 insertions(+), 3 deletions(-) diff --git a/packages/rs-platform-wallet-ffi/src/private_contact_request.rs b/packages/rs-platform-wallet-ffi/src/private_contact_request.rs index 069e24ab81d..28c785b24c3 100644 --- a/packages/rs-platform-wallet-ffi/src/private_contact_request.rs +++ b/packages/rs-platform-wallet-ffi/src/private_contact_request.rs @@ -77,6 +77,22 @@ use crate::shielded_send::{ /// 32-byte transmission key. const SHIELDED_ADDRESS_LEN: usize = 43; +/// A private sync, and the store read a send or accept makes first, check +/// that the resolver's seed is the wallet's before reading anything. A +/// mismatch takes the code every DashPay export gives it, and the seed +/// check reports (`ErrorInvalidParameter`); any other error is handed back. +fn seed_mismatch_result( + error: PlatformWalletError, +) -> Result { + match error { + error @ PlatformWalletError::SeedMismatch { .. } => Ok(PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorInvalidParameter, + error.to_string(), + )), + error => Err(error), + } +} + /// What a pass over DashPay's private contact request store did /// (`PrivateContactSyncReport`). Counts above `u32::MAX` saturate. #[repr(C)] @@ -179,7 +195,10 @@ pub unsafe extern "C" fn platform_wallet_sync_private_contact_requests( .sync_private_contact_requests(&provider, max_pages as usize) .await }); - let report = unwrap_result_or_return!(result); + let report = match result { + Ok(report) => report, + Err(error) => return seed_mismatch_result(error).unwrap_or_else(|error| error.into()), + }; unsafe { *out_report = PrivateContactSyncReportFFI { next_position: report.next_position, @@ -482,7 +501,8 @@ unsafe fn send_private_contact_request_inner( unsafe { *out_request_handle = CONTACT_REQUEST_STORAGE.insert(request) }; PlatformWalletFFIResult::ok() } - Err(error) => map_spend_result(Err(error), "private contact request"), + Err(error) => seed_mismatch_result(error) + .unwrap_or_else(|error| map_spend_result(Err(error), "private contact request")), } } @@ -603,7 +623,8 @@ unsafe fn accept_private_contact_request_inner( unsafe { *out_established_handle = ESTABLISHED_CONTACT_STORAGE.insert(contact) }; PlatformWalletFFIResult::ok() } - Err(error) => map_spend_result(Err(error), "private contact request accept"), + Err(error) => seed_mismatch_result(error) + .unwrap_or_else(|error| map_spend_result(Err(error), "private contact request accept")), } } @@ -888,6 +909,10 @@ unsafe fn accept_contact_request_replying_in_kind_inner( { return PlatformWalletFFIResult::err(code, message); } + let error = match seed_mismatch_result(error) { + Ok(result) => return result, + Err(error) => error, + }; #[cfg(feature = "shielded")] if payment_asked.load(Ordering::SeqCst) { return map_spend_result(Err(error), "contact request accept"); @@ -903,6 +928,25 @@ unsafe fn accept_contact_request_replying_in_kind_inner( mod tests { use super::*; + /// A wrong seed refused by the private sync, or by the store read a send + /// or accept makes first, takes the code every DashPay export gives it; + /// any other error is left to the export's own mapping. + #[test] + fn should_report_a_seed_mismatch_from_the_private_store_by_its_dashpay_code() { + let result = seed_mismatch_result(PlatformWalletError::SeedMismatch { + wallet_id: "ab".to_string(), + }) + .expect("a seed mismatch is mapped here"); + assert_eq!( + result.code, + PlatformWalletFFIResultCode::ErrorInvalidParameter + ); + assert!(matches!( + seed_mismatch_result(PlatformWalletError::PrivateContactPaymentsNotRestored), + Err(PlatformWalletError::PrivateContactPaymentsNotRestored) + )); + } + #[test] fn should_clear_both_sync_outputs_on_an_unknown_wallet() { let mut requests = ContactRequestHandleArray { From 7e93663c9540dc7306bee42c3a179a85030e10d8 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 9 Nov 2026 12:09:09 +0700 Subject: [PATCH 123/269] fix(swift-sdk): fail a scan-state load on a stored row that cannot cross the ABI The load shim filtered out stored rows of the wrong length and handed back the rest, so an address row of 42 bytes vanished while its identity's read position was restored, and the sync resumed past the entry that carried the address. Every row is now checked before either array is allocated, and one that cannot be marshalled fails the load as an unreadable store does. Co-Authored-By: Claude Opus 5.5 --- .../PlatformWalletPersistenceHandler.swift | 17 +++++----- ...vateContactScanStatePersistenceTests.swift | 31 +++++++++++++++++++ 2 files changed, 41 insertions(+), 7 deletions(-) diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift index d5c0519d897..f25a88d4d02 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift @@ -12108,9 +12108,9 @@ private func persistPrivateContactScanStateCallback( } /// C shim for `on_load_private_contact_scan_state_fn`. Hands Rust two -/// arrays of flat rows, released by the free shim below. A row whose -/// stored bytes have the wrong length is skipped rather than handed back -/// on zeros. +/// arrays of flat rows, released by the free shim below. A stored row whose +/// bytes have the wrong length cannot cross the ABI: the load fails, as for +/// an unreadable store, rather than handing back the rest without it. private func loadPrivateContactScanStateCallback( context: UnsafeMutableRawPointer?, walletId: UnsafePointer?, outPositions: UnsafeMutablePointer?>?, @@ -12130,10 +12130,13 @@ private func loadPrivateContactScanStateCallback( do { stored = try handler.loadPrivateContactScanState(walletId: Data(bytes: walletId, count: 32)) } catch { return -1 } - let positions = stored.positions.filter { $0.identityId.count == 32 && $0.keyFingerprint.count == 32 } - let addresses = stored.addresses.filter { - $0.ownerId.count == 32 && $0.contactId.count == 32 && $0.address.count == 43 - } + // Every row is checked before either output is allocated. + guard stored.positions.allSatisfy({ $0.identityId.count == 32 && $0.keyFingerprint.count == 32 }), + stored.addresses.allSatisfy({ + $0.ownerId.count == 32 && $0.contactId.count == 32 && $0.address.count == 43 + }) else { return -1 } + let positions = stored.positions + let addresses = stored.addresses if !positions.isEmpty { let buffer = UnsafeMutablePointer.allocate(capacity: positions.count) for (index, row) in positions.enumerated() { diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/PrivateContactScanStatePersistenceTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/PrivateContactScanStatePersistenceTests.swift index 0e001e2bd19..6bea09bc492 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/PrivateContactScanStatePersistenceTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/PrivateContactScanStatePersistenceTests.swift @@ -112,6 +112,37 @@ final class PrivateContactScanStatePersistenceTests: XCTestCase { XCTAssertEqual(try context.fetchCount(FetchDescriptor()), 0) } + /// A stored row that cannot cross the ABI fails the load instead of + /// handing back the rest without it: a valid position beside a 42-byte + /// address would otherwise resume past the entry that carried it. + func testShouldFailTheLoadOnAStoredRowThatCannotCrossTheABI() throws { + let (container, handler) = try makeHandler() + try round( + handler, + positions: [.init(identityId: owner, position: 12, keyFingerprint: Data(repeating: 3, count: 32))]) + let context = ModelContext(container) + context.insert(PersistentPrivateShieldedAddress( + networkRaw: Network.testnet.rawValue, walletId: walletId, ownerId: owner, + contactId: contact, address: Data(repeating: 9, count: 42))) + try context.save() + + let ext = handler.makePersistenceCallbacksExtension() + let load = try XCTUnwrap(ext.on_load_private_contact_scan_state_fn) + let context_ = Unmanaged.passUnretained(handler).toOpaque() + var positions: UnsafePointer? + var positionsCount: UInt = 0 + var addresses: UnsafePointer? + var addressesCount: UInt = 0 + let rc = walletId.withUnsafeBytes { wallet in + load(context_, wallet.bindMemory(to: UInt8.self).baseAddress, + &positions, &positionsCount, &addresses, &addressesCount) + } + XCTAssertNotEqual(rc, 0) + XCTAssertNil(positions) + XCTAssertNil(addresses) + XCTAssertEqual(positionsCount + addressesCount, 0) + } + func testShouldRefuseAScanStateWriteOutsideAChangeset() throws { let (_, handler) = try makeHandler() XCTAssertThrowsError(try handler.persistPrivateContactScanState( From 490128af86ae2dadd8e564e1be115db2ebf72796 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 9 Nov 2026 12:09:09 +0700 Subject: [PATCH 124/269] docs(swift-sdk): describe the restored private scan state and pending replies The private sync and `PrivateContactInfo` docs said the store cursors and shielded addresses live in memory only; they are restored where the store attests `privateContactRequestScanState`, with the full read as the fallback. `privateReplyPending` is restored too once the shielded store is configured after the load or the wallet binds. Co-Authored-By: Claude Opus 5.5 --- .../PrivateContactRequests.swift | 23 +++++++++++++------ 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PrivateContactRequests.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PrivateContactRequests.swift index 50522dbd86c..80e6bafd0af 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PrivateContactRequests.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PrivateContactRequests.swift @@ -19,8 +19,10 @@ import DashSDKFFI // `PlatformWalletManager.sendPrivateContactRequest` instead. /// How an identity and one contact exchanged private contact requests. Rust -/// reads it from the stored requests, so it holds across launches; only the -/// contact's shielded address is kept in memory, rebuilt by +/// reads it from the stored requests, so it holds across launches. The +/// contact's shielded address holds across launches too where the store +/// attests `PlatformWalletPersistenceCapabilities.privateContactRequestScanState` +/// (the SwiftData handler does); otherwise it comes back with the next /// `ManagedPlatformWallet.syncPrivateContactRequests`. public struct PrivateContactInfo: Sendable, Equatable { /// The contact's request came privately, so accepting it replies @@ -84,7 +86,11 @@ extension ManagedIdentity { /// contact (`PlatformWalletError.privateContactRequestPending`), so show /// the contact as pending and offer neither; a private sync settles it. /// Read from this snapshot: take a fresh `ManagedIdentity` to see a - /// change. Rust keeps it in memory only, so it is false after a relaunch. + /// change. After a relaunch, a reply paid for in an earlier process is + /// pending again once Rust restores it from the shielded store, which it + /// does when the store is configured after the load + /// (`PlatformWalletManager.configureShielded`) or the wallet binds its + /// shielded account. public func privateReplyPending(contactId: Identifier) throws -> Bool { var pending = false let result = contactId.withFFIBytes { idPtr in @@ -103,10 +109,13 @@ extension ManagedPlatformWallet { /// /// The store is opened with keys that stay in the Keychain mnemonic /// resolver Rust-side; each request's signature is checked against its - /// sender's keys. The store cursors live in memory only, so the first - /// pass after launch reads the store from its start. A request it - /// ingests is stored as having come privately, which holds across - /// launches. + /// sender's keys. Where the store attests + /// `PlatformWalletPersistenceCapabilities.privateContactRequestScanState` + /// (the SwiftData handler does), each identity's read position is stored + /// with the requests read before it and restored at load, so a pass + /// after launch reads on from there; otherwise the first pass after + /// launch reads the store from its start. A request it ingests is stored + /// as having come privately, which holds across launches. public func syncPrivateContactRequests( maxPages: UInt32 = .max ) async throws -> PrivateContactSyncResult { From 289829a3944b74749f9dc8f88ca3a855c08499d7 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 9 Nov 2026 12:09:09 +0700 Subject: [PATCH 125/269] fix(swift-example-app): configure the shielded store right after the load A wallet that bound a shielded account in an earlier process has its public contact requests refused (code 71) until Rust restores its unconfirmed private payments, which configuring the shielded store does with no keys. The app configured it only when binding, which needs the mnemonic; it now configures the same per-network path right after `loadFromPersistor`, so the bind's own call is a no-op. Co-Authored-By: Claude Opus 5.5 --- .../Core/Services/ShieldedService.swift | 2 +- .../SwiftExampleApp/WalletManagerStore.swift | 17 +++++++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Services/ShieldedService.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Services/ShieldedService.swift index 057fca7a0dd..d76f2fe1745 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Services/ShieldedService.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Services/ShieldedService.swift @@ -1153,7 +1153,7 @@ class ShieldedService: ObservableObject { /// store via `SubwalletId`, so multiple wallets cohabiting /// the same SQLite file no longer leak notes across each /// other. (See `wallet/shielded/store.rs` for the trait.) - private static func dbPath(for network: Network) -> String { + static func dbPath(for network: Network) -> String { let docs = FileManager.default .urls(for: .documentDirectory, in: .userDomainMask) .first! diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/WalletManagerStore.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/WalletManagerStore.swift index ec183dfa41e..8dea8c41a2c 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/WalletManagerStore.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/WalletManagerStore.swift @@ -249,6 +249,23 @@ final class WalletManagerStore: ObservableObject { error: error ) } + // Open the shielded store right after the load: Rust then restores, + // with no keys, the unconfirmed private contact request payments of + // every wallet that bound a shielded account in an earlier process. + // Until it does, those wallets' public contact requests are refused + // (code 71), and a later bind may never come (no mnemonic, shielded + // off). The same path the bind opens, so the bind's call is a no-op. + do { + try manager.configureShielded(dbPath: ShieldedService.dbPath(for: network)) + } catch { + SDKLogger.event( + "manager_shielded_configure_failed", + category: .lifecycle, + severity: .error, + fields: ["network": .publicText(network.displayName)], + error: error + ) + } managers[network] = manager managerSdkHandles[network] = sdk.handle if makeActive { From edc9dd1f244800be996b7b7573b3b5a00f0c68ea Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 9 Nov 2026 12:09:09 +0700 Subject: [PATCH 126/269] fix(swift-example-app): keep a code 71 refusal visible until a private sync Code 71 does not mean the wallet holds a reservation for the contact: it is also returned, with nothing reserved, when the private store could not be read to its end before the first send or accept since load. The views dropped the refusal and read the pending state, which then found nothing and brought Send and Accept back with no notice. `PrivateRepliesPendingSnapshot` now keeps the refused contact pending, with the private sync offered, until a private sync completes. Co-Authored-By: Claude Opus 5.5 --- .../Views/DashPay/AddContactView.swift | 11 ++++--- .../DashPay/ContactRequestOutcomes.swift | 30 ++++++++++++++++--- .../Views/DashPay/ContactRequestsView.swift | 15 ++++++++-- .../ContactRequestOutcomesTests.swift | 23 ++++++++++++++ 4 files changed, 68 insertions(+), 11 deletions(-) diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/AddContactView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/AddContactView.swift index 7067207135d..2956cc698ec 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/AddContactView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/AddContactView.swift @@ -472,6 +472,8 @@ struct AddContactView: View { if let wallet = try? requireWallet() { if let notice = await syncPrivateContactRequests(wallet) { errorMessage = notice + } else { + recipientPending.recordPrivateSync() } } reloadRecipientPending() @@ -583,9 +585,10 @@ struct AddContactView: View { kickDashPaySync(walletManager) unconfirmedNotice = ContactRequestSubmissionOutcome.mayHaveGoneThroughMessage case .privateRequestPending: - // Nothing was sent: the footer shows the pending request - // and offers the private sync that settles it. - break + // Nothing was sent, and the wallet may hold nothing for + // it: the footer shows it as pending and offers the + // private sync that settles it. + recipientPending.recordRefusal(of: recipient) case .failed(let message): errorMessage = message } @@ -646,7 +649,7 @@ struct AddContactView: View { unconfirmedNotice = ContactRequestSubmissionOutcome.mayHaveGoneThroughMessage case .privateRequestPending: // Nothing was sent: the footer offers the private sync. - break + recipientPending.recordRefusal(of: recipient) case .failed(let message): errorMessage = "Accept failed: \(message)" } diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ContactRequestOutcomes.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ContactRequestOutcomes.swift index c37f2c28909..88859c6328c 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ContactRequestOutcomes.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ContactRequestOutcomes.swift @@ -57,6 +57,12 @@ enum ContactRequestSubmissionOutcome: Equatable { /// (Accept, Ignore, Send). A failed read is not "nothing pending": the last /// successful snapshot stays, and those controls stay off for every contact /// until a read covering it succeeds. +/// +/// A send or accept Rust refused as pending (code 71) is kept apart from the +/// read: code 71 also means the private store could not be read to its end +/// before the first send or accept since load, with nothing reserved, so the +/// read may find nothing pending. That contact stays pending, with the +/// private sync offered, until a private sync completes. struct PrivateRepliesPendingSnapshot: Equatable { static let unavailableMessage = "Could not read whether a private request to " + "this contact is pending. Sync private requests to check again." @@ -67,6 +73,9 @@ struct PrivateRepliesPendingSnapshot: Equatable { private(set) var covered: Set = [] /// False after a failed read, until a read succeeds. private(set) var available = true + /// Contacts a send or accept was refused for as pending (code 71) since + /// the last completed private sync. + private(set) var refused: Set = [] /// Record a read of `contactIds`: the pending ones, or nil when the read /// failed. @@ -80,10 +89,23 @@ struct PrivateRepliesPendingSnapshot: Equatable { available = true } - /// Whether the last successful read found a private request to - /// `contactId` pending. + /// Record that a send or accept to `contactId` was refused as pending + /// (code 71). + mutating func recordRefusal(of contactId: Data) { + refused.insert(contactId) + } + + /// Record a completed private sync, which settles every refusal: the + /// next read says what is still pending. + mutating func recordPrivateSync() { + refused.removeAll() + } + + /// Whether `contactId` is pending: the last successful read found a + /// private request to it, or a send or accept to it was refused as + /// pending since the last completed private sync. func isPending(_ contactId: Data) -> Bool { - pending.contains(contactId) + pending.contains(contactId) || refused.contains(contactId) } /// Whether the last read failed. @@ -92,7 +114,7 @@ struct PrivateRepliesPendingSnapshot: Equatable { /// Whether a send or reply to `contactId` may be offered: a successful /// read covered it and found nothing pending. func allowsSubmission(to contactId: Data) -> Bool { - available && covered.contains(contactId) && !pending.contains(contactId) + available && covered.contains(contactId) && !isPending(contactId) } } diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ContactRequestsView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ContactRequestsView.swift index a9b835e369c..6709ba9a1c6 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ContactRequestsView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ContactRequestsView.swift @@ -186,6 +186,9 @@ struct ContactRequestsView: View { await attachOrStartSync(walletManager) if let wallet = try? requireWallet() { privateRefreshNotice = await syncPrivateContactRequests(wallet) + if privateRefreshNotice == nil { + walletPending.recordPrivateSync() + } } reloadPendingReplies() } @@ -292,11 +295,17 @@ struct ContactRequestsView: View { kickDashPaySync(walletManager) } catch { switch ContactRequestSubmissionOutcome.of(error) { - case .mayHaveGoneThrough, .privateRequestPending: + case .mayHaveGoneThrough: // As `SendViewModel` treats an unconfirmed spend: not a // failure to retry. The wallet keeps the reply pending, - // and the row reads that; a sync (pull to refresh reads - // the private store) settles it. + // and the row reads that; the next sync settles it. + kickDashPaySync(walletManager) + case .privateRequestPending: + // Nothing was sent, and the wallet may hold nothing for + // it (the store was not read to its end): the row stays + // pending until a private sync (pull to refresh) settles + // it. + walletPending.recordRefusal(of: contactId) kickDashPaySync(walletManager) case .failed(let message): rowErrors[contactId] = "Accept failed: \(message)" diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/ContactRequestOutcomesTests.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/ContactRequestOutcomesTests.swift index f414cf4f010..01b787ac509 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/ContactRequestOutcomesTests.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/ContactRequestOutcomesTests.swift @@ -66,6 +66,29 @@ final class ContactRequestOutcomesTests: XCTestCase { XCTAssertTrue(snapshot.allowsSubmission(to: pendingContact)) } + /// A send refused as pending (code 71) when the wallet reserved + /// nothing (its store was not read to its end): the following read finds + /// nothing pending, yet the contact stays pending, with the private sync + /// offered, until a private sync completes. + func testShouldKeepARefusedContactPendingAfterAnEmptyReadUntilAPrivateSync() { + let contact = Data(repeating: 1, count: 32) + var snapshot = PrivateRepliesPendingSnapshot() + snapshot.recordRefusal(of: contact) + snapshot.record([], for: [contact]) + XCTAssertFalse(snapshot.isUnavailable) + XCTAssertTrue(snapshot.isPending(contact)) + XCTAssertFalse(snapshot.allowsSubmission(to: contact)) + XCTAssertEqual( + ContactRequestSubmissionOutcome.rowMessage( + pending: snapshot.isPending(contact), error: nil), + ContactRequestSubmissionOutcome.privateRequestPendingMessage) + + snapshot.recordPrivateSync() + snapshot.record([], for: [contact]) + XCTAssertFalse(snapshot.isPending(contact)) + XCTAssertTrue(snapshot.allowsSubmission(to: contact)) + } + /// A successful read that found nothing pending offers every contact it /// covered, and nothing it did not. func testShouldOfferEveryContactASuccessfulEmptyReadCovered() { From 4f2b553d66508bf18e2e62f378f9ddc0e03c2bfa Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 9 Nov 2026 12:12:46 +0700 Subject: [PATCH 127/269] fix(kotlin-sdk): fail a scan-state load on a stored row that cannot cross the ABI Room keeps a blob of any length, and the load packer filtered out rows of the wrong length while handing back the rest: a 42-byte address vanished while its identity's read position was restored, so the sync resumed past the entry that carried it. A malformed row now throws, which fails the load through `loadOrThrow`; what to do about such a store is the wallet's call, not the adapter's. Co-Authored-By: Claude Opus 5.5 --- .../PlatformWalletPersistenceHandler.kt | 22 ++++++++++++------- .../PrivateContactScanStatePersistenceTest.kt | 21 ++++++++++++++++++ 2 files changed, 35 insertions(+), 8 deletions(-) diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandler.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandler.kt index 912fa65111d..5703d67f6d4 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandler.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandler.kt @@ -4646,15 +4646,19 @@ internal fun unpackPrivateAddresses( /** * [rows] packed as [NativePersistenceBridge.onLoadPrivateContactScanState] - * hands them back. A row whose stored bytes have the wrong length is - * skipped rather than handed back misaligned. + * hands them back. A stored row whose bytes have the wrong length (Room + * keeps any blob) cannot cross the ABI: it throws, which fails the load + * through [PlatformWalletPersistenceHandler.onLoadPrivateContactScanState]'s + * `loadOrThrow`, rather than handing back the rest without it. */ internal fun packPrivatePositions(rows: List): ByteArray { - val kept = rows.filter { it.identityId.size == 32 && it.keyFingerprint.size == 32 } + require(rows.all { it.identityId.size == 32 && it.keyFingerprint.size == 32 }) { + "a stored private contact request read position has a malformed id or fingerprint" + } val buffer = java.nio.ByteBuffer - .allocate(kept.size * NativePersistenceBridge.PRIVATE_POSITION_ENTRY_SIZE) + .allocate(rows.size * NativePersistenceBridge.PRIVATE_POSITION_ENTRY_SIZE) .order(java.nio.ByteOrder.LITTLE_ENDIAN) - for (row in kept) { + for (row in rows) { buffer.put(row.identityId).putLong(row.position).put(row.keyFingerprint) } return buffer.array() @@ -4662,9 +4666,11 @@ internal fun packPrivatePositions(rows: List): ByteArray { - val kept = rows.filter { it.ownerId.size == 32 && it.contactId.size == 32 && it.address.size == 43 } - val buffer = java.nio.ByteBuffer.allocate(kept.size * NativePersistenceBridge.PRIVATE_ADDRESS_ENTRY_SIZE) - for (row in kept) { + require(rows.all { it.ownerId.size == 32 && it.contactId.size == 32 && it.address.size == 43 }) { + "a stored private shielded address has a malformed id or address" + } + val buffer = java.nio.ByteBuffer.allocate(rows.size * NativePersistenceBridge.PRIVATE_ADDRESS_ENTRY_SIZE) + for (row in rows) { buffer.put(row.ownerId).put(row.contactId).put(row.address) } return buffer.array() diff --git a/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/persistence/PrivateContactScanStatePersistenceTest.kt b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/persistence/PrivateContactScanStatePersistenceTest.kt index 013d1971d9b..730ce0820ef 100644 --- a/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/persistence/PrivateContactScanStatePersistenceTest.kt +++ b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/persistence/PrivateContactScanStatePersistenceTest.kt @@ -132,6 +132,27 @@ class PrivateContactScanStatePersistenceTest { assertEquals(0, addresses.size) } + /** + * A stored row that cannot cross the ABI fails the load instead of + * handing back the rest without it: a valid position beside a 42-byte + * address would otherwise resume past the entry that carried it. + */ + @Test + fun shouldFailTheLoadOnAStoredRowThatCannotCrossTheAbi() = runTest { + round(positions = position(owner, 12)) + db.privateContactScanStateDao().upsertAddress( + org.dashfoundation.dashsdk.persistence.entities.PrivateShieldedAddressEntity( + walletId = walletId, + ownerId = owner, + contactId = contact, + address = ByteArray(42) { 9 }, + ), + ) + assertThrows(IllegalArgumentException::class.java) { + handler.onLoadPrivateContactScanState(walletId) + } + } + /** A partial row fails the callback instead of storing a misread row. */ @Test fun shouldRefuseARoundWithAPartialRow() { From 622677caa09ded344b61beef8e3648eadad67d30 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 9 Nov 2026 12:12:46 +0700 Subject: [PATCH 128/269] fix(unified-sdk-jni): tie the scan-state slots to contact persistence and check their descriptors The scan-state slots are now wired only for a bridge that also overrides `onPersistContactUpsert`: a read position vouches for the contact requests read before it, which an inherited no-op upsert never keeps. Both scan-state callbacks join `BRIDGE_METHOD_TABLE`, so the descriptor smoke check resolves them against the compiled Kotlin methods; a drift would otherwise drop the slots and the capability without a failure. Co-Authored-By: Claude Opus 5.5 --- .../rs-unified-sdk-jni/src/persistence.rs | 23 ++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/packages/rs-unified-sdk-jni/src/persistence.rs b/packages/rs-unified-sdk-jni/src/persistence.rs index cc469d508aa..e0e243ea233 100644 --- a/packages/rs-unified-sdk-jni/src/persistence.rs +++ b/packages/rs-unified-sdk-jni/src/persistence.rs @@ -254,6 +254,11 @@ pub(crate) fn build_extension(env: &mut JNIEnv, bridge: &JObject) -> Persistence bridge, "onLoadPrivateContactScanState", LOAD_PRIVATE_CONTACT_SCAN_STATE_DESCRIPTOR, + ) && bridge_overrides( + env, + bridge, + "onPersistContactUpsert", + CONTACT_UPSERT_DESCRIPTOR, ), }) } @@ -311,9 +316,11 @@ struct BridgeOverrides { header: bool, /// `onWalletChangesetDashPayBackfill`, which stores the record. backfill: bool, - /// Both `onPersistPrivateContactScanState` and - /// `onLoadPrivateContactScanState`: an inherited no-op store would - /// acknowledge a read position it never kept. + /// `onPersistPrivateContactScanState`, `onLoadPrivateContactScanState` + /// and `onPersistContactUpsert`: an inherited no-op store would + /// acknowledge a read position it never kept, and a read position + /// vouches for the contact requests read before it, which an inherited + /// no-op contact upsert never keeps. private_contact_scan_state: bool, } @@ -5148,6 +5155,16 @@ const BRIDGE_METHOD_TABLE: &[(&str, &str)] = &[ "onWalletChangesetDashPayBackfill", WALLET_CHANGESET_DASHPAY_BACKFILL_DESCRIPTOR, ), + // Wired only for a bridge that overrides both (`bridge_overrides`), so a + // drifted descriptor would silently drop the slots and the capability. + ( + "onPersistPrivateContactScanState", + PERSIST_PRIVATE_CONTACT_SCAN_STATE_DESCRIPTOR, + ), + ( + "onLoadPrivateContactScanState", + LOAD_PRIVATE_CONTACT_SCAN_STATE_DESCRIPTOR, + ), ( "onPersistIdentityUpsert", "([B[BJJZIBZ[B[Ljava/lang/String;[JZLjava/lang/String;Ljava/lang/String;\ From 69c83102b6b682342c73e0097ffa1c29e604db8f Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 9 Nov 2026 12:12:46 +0700 Subject: [PATCH 129/269] docs(kotlin-sdk): describe the restored private scan state and pending replies `syncPrivateContactRequests` said the store cursors live in memory only; they are restored where the persister attests `PRIVATE_CONTACT_REQUEST_SCAN_STATE`, with the full read as the fallback. `privateRepliesPending` is restored too once the shielded store is configured after the load or the wallet binds. Co-Authored-By: Claude Opus 5.5 --- .../org/dashfoundation/dashsdk/tokens/Dashpay.kt | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/tokens/Dashpay.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/tokens/Dashpay.kt index bc36e55427a..fcc43fb9c49 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/tokens/Dashpay.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/tokens/Dashpay.kt @@ -350,8 +350,11 @@ class Dashpay internal constructor(private val walletHandle: Long, * alone. The ingested requests reach Room through the persister. * * The store is opened with keys derived inside [coreSignerHandle], the - * manager's `MnemonicResolverHandle`. The store cursors live in memory - * only, so the first pass after launch reads the store from its start. + * manager's `MnemonicResolverHandle`. Where the persister attests + * `PRIVATE_CONTACT_REQUEST_SCAN_STATE` (the Room handler does), each + * identity's read position is stored with the requests read before it + * and restored at load, so a pass after launch reads on from there; + * otherwise the first pass after launch reads the store from its start. * Private requests reach the wallet only through this read. * ← Swift `ManagedPlatformWallet.syncPrivateContactRequests(maxPages:)`. */ @@ -403,8 +406,10 @@ class Dashpay internal constructor(private val walletHandle: Long, * managed-identity snapshot. While one is, Rust refuses another private * send or reply to it ([DashSdkError.PlatformWallet.PrivateContactRequestPending]), * so a host shows it as pending and offers neither; a private sync - * settles it. Empty when the identity isn't managed. Rust keeps it in - * memory only, so it is empty after a relaunch. + * settles it. Empty when the identity isn't managed. After a relaunch, a + * reply paid for in an earlier process is pending again once Rust + * restores it from the shielded store, which it does when the store is + * configured after the load or the wallet binds its shielded account. * ← Swift `ManagedIdentity.privateReplyPending(contactId:)` * (packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PrivateContactRequests.swift). */ From f0d7c95a13e72592eff44e641884b73e31c6fc6b Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 9 Nov 2026 12:12:46 +0700 Subject: [PATCH 130/269] fix(kotlin-example-app): configure the shielded store right after the load A wallet that bound a shielded account in an earlier process has its public contact requests refused (code 71) until Rust restores its unconfirmed private payments, which configuring the shielded store does with no keys. The app configured it only when binding, which needs the mnemonic; it now configures the same per-network path right after `loadPersistedWallets`. Port of the Swift change on #5812. Co-Authored-By: Claude Opus 5.5 --- .../dashfoundation/example/di/AppContainer.kt | 25 ++++++++++++++++--- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/di/AppContainer.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/di/AppContainer.kt index b2d0b1fed42..e8f13a71848 100644 --- a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/di/AppContainer.kt +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/di/AppContainer.kt @@ -154,6 +154,22 @@ class AppContainer(private val context: Context) { loadWallets = { val activatedManager = walletManagerStore.activate(sdk.network, sdk) activatedManager.loadPersistedWallets() + // Open the shielded store right after the load: Rust then + // restores, with no keys, the unconfirmed private contact + // request payments of every wallet that bound a shielded + // account in an earlier process. Until it does, their public + // contact requests are refused (code 71), and a later bind + // may never come (no mnemonic, declined biometric). The same + // path the bind opens, so the bind's call is a no-op. + if (shieldedService.isAvailable) { + try { + activatedManager.configureShielded(shieldedDbPath(activatedManager.network)) + } catch (e: kotlinx.coroutines.CancellationException) { + throw e + } catch (e: Exception) { + android.util.Log.w(TAG, "Failed to configure the shielded store after the load", e) + } + } org.dashfoundation.example.ui.dashpay.DashPayWalletLoad( value = activatedManager, loadedWalletIdsHex = activatedManager.wallets.value.keys, @@ -207,6 +223,10 @@ class AppContainer(private val context: Context) { * `bindShielded` and the start-if-not-running guards make repeated calls * (on every wallet-set change) safe. */ + /** The per-network commitment-tree store, as iOS `ShieldedService.dbPath(for:)` names it. */ + private fun shieldedDbPath(network: org.dashfoundation.dashsdk.Network): String = + java.io.File(context.filesDir, "shielded_tree_${network.networkName}.sqlite").absolutePath + private suspend fun rebindWalletScopedServices( manager: org.dashfoundation.dashsdk.wallet.PlatformWalletManager, ) { @@ -243,10 +263,7 @@ class AppContainer(private val context: Context) { // and doesn't throw. dbPath mirrors iOS's // `ShieldedService.dbPath(for:)` naming // (`shielded_tree_.sqlite`), rooted in filesDir. - val shieldedDbPath = java.io.File( - context.filesDir, - "shielded_tree_${manager.network.networkName}.sqlite", - ).absolutePath + val shieldedDbPath = shieldedDbPath(manager.network) shieldedService.bind( manager = manager, walletId = wallet.walletId, From 6b3c4d4748de72148dc9fb42c38ed54d525f2404 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 9 Nov 2026 12:12:47 +0700 Subject: [PATCH 131/269] fix(kotlin-example-app): keep a code 71 refusal visible until a private sync Code 71 does not mean the wallet holds a reservation for the contact: it is also returned, with nothing reserved, when the private store could not be read to its end before the first send or accept since load. Add Contact's public Send, its collision accept and the Requests rows dropped the refusal and read the pending state, which then found nothing and brought the controls back with no notice. `PrivateRepliesPendingSnapshot` now keeps the refused contact pending, with the private sync offered, until a private sync completes. Port of the Swift change on #5812. Co-Authored-By: Claude Opus 5.5 --- .../example/ui/dashpay/AddContactScreen.kt | 18 +++++++++---- .../ui/dashpay/ContactRequestOutcomes.kt | 26 ++++++++++++++++--- .../ui/dashpay/ContactRequestsScreen.kt | 20 +++++++++----- .../ui/dashpay/ContactRequestOutcomesTest.kt | 24 +++++++++++++++++ 4 files changed, 73 insertions(+), 15 deletions(-) diff --git a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/AddContactScreen.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/AddContactScreen.kt index 883b985fa84..e051ae7bc7a 100644 --- a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/AddContactScreen.kt +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/AddContactScreen.kt @@ -227,9 +227,11 @@ fun AddContactScreen(identityIdHex: String, navController: NavHostController) { navController.popBackStack() } catch (e: Exception) { when (val outcome = ContactRequestSubmissionOutcome.of(e)) { - // Nothing was sent: the screen shows the pending request - // and offers the private sync that settles it. - ContactRequestSubmissionOutcome.PrivateRequestPending -> Unit + // Nothing was sent, and the wallet may hold nothing for + // it: the screen shows it as pending and offers the + // private sync that settles it. + ContactRequestSubmissionOutcome.PrivateRequestPending -> + recipientPending = recipientPending.withRefusal(recipient.toHex()) ContactRequestSubmissionOutcome.MayHaveGoneThrough -> { kickDashPaySync(scope, m) unconfirmedNotice = ContactRequestSubmissionOutcome.MAY_HAVE_GONE_THROUGH_MESSAGE @@ -254,7 +256,12 @@ fun AddContactScreen(identityIdHex: String, navController: NavHostController) { isSyncingPrivate = true scope.launch { try { - syncPrivateContactRequests(w, m)?.let { errorMessage = it } + val notice = syncPrivateContactRequests(w, m) + if (notice == null) { + recipientPending = recipientPending.afterPrivateSync() + } else { + errorMessage = notice + } } finally { isSyncingPrivate = false pendingReadVersion++ @@ -292,7 +299,8 @@ fun AddContactScreen(identityIdHex: String, navController: NavHostController) { unconfirmedNotice = ContactRequestSubmissionOutcome.MAY_HAVE_GONE_THROUGH_MESSAGE } // Nothing was sent: the screen offers the private sync. - ContactRequestSubmissionOutcome.PrivateRequestPending -> Unit + ContactRequestSubmissionOutcome.PrivateRequestPending -> + recipientPending = recipientPending.withRefusal(recipient.toHex()) is ContactRequestSubmissionOutcome.Failed -> errorMessage = "Accept failed: ${outcome.message}" } diff --git a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/ContactRequestOutcomes.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/ContactRequestOutcomes.kt index e0c2cafe1a7..b89bb838a35 100644 --- a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/ContactRequestOutcomes.kt +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/ContactRequestOutcomes.kt @@ -60,6 +60,12 @@ sealed interface ContactRequestSubmissionOutcome { * off what would submit one (Accept, Ignore, Send). A failed read is not * "nothing pending": the last successful snapshot stays, and those controls * stay off for every contact until a read covering it succeeds. + * + * A send or accept Rust refused as pending (code 71) is kept apart from the + * read: code 71 also means the private store could not be read to its end + * before the first send or accept since load, with nothing reserved, so the + * read may find nothing pending. That contact stays pending, with the + * private sync offered, until a private sync completes. * ← Swift `PrivateRepliesPendingSnapshot` (`ContactRequestOutcomes.swift`). */ data class PrivateRepliesPendingSnapshot( @@ -69,6 +75,8 @@ data class PrivateRepliesPendingSnapshot( val covered: Set = emptySet(), /** False after a failed read, until a read succeeds. */ val available: Boolean = true, + /** Contacts a send or accept was refused for as pending since the last completed private sync. */ + val refused: Set = emptySet(), ) { /** Whether the last read failed. */ val isUnavailable: Boolean get() = !available @@ -81,18 +89,28 @@ data class PrivateRepliesPendingSnapshot( if (read == null) { copy(available = false) } else { - PrivateRepliesPendingSnapshot(read, contactIds.toSet(), available = true) + copy(pending = read, covered = contactIds.toSet(), available = true) } - /** Whether the last successful read found a private request to [contactId] pending. */ - fun isPending(contactId: String): Boolean = contactId in pending + /** This snapshot after a send or accept to [contactId] was refused as pending (code 71). */ + fun withRefusal(contactId: String): PrivateRepliesPendingSnapshot = copy(refused = refused + contactId) + + /** This snapshot after a completed private sync, which settles every refusal. */ + fun afterPrivateSync(): PrivateRepliesPendingSnapshot = copy(refused = emptySet()) + + /** + * Whether [contactId] is pending: the last successful read found a + * private request to it, or a send or accept to it was refused as + * pending since the last completed private sync. + */ + fun isPending(contactId: String): Boolean = contactId in pending || contactId in refused /** * Whether a send or reply to [contactId] may be offered: a successful * read covered it and found nothing pending. */ fun allowsSubmission(contactId: String): Boolean = - available && contactId in covered && contactId !in pending + available && contactId in covered && !isPending(contactId) companion object { const val UNAVAILABLE_MESSAGE = "Could not read whether a private request to this " + diff --git a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/ContactRequestsScreen.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/ContactRequestsScreen.kt index 976b7f91435..ce8ec14b110 100644 --- a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/ContactRequestsScreen.kt +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/ContactRequestsScreen.kt @@ -194,13 +194,18 @@ fun ContactRequestsScreen(identityIdHex: String, navController: NavHostControlle } } catch (e: Exception) { when (val outcome = ContactRequestSubmissionOutcome.of(e)) { - ContactRequestSubmissionOutcome.MayHaveGoneThrough, - ContactRequestSubmissionOutcome.PrivateRequestPending, - -> { + ContactRequestSubmissionOutcome.MayHaveGoneThrough -> { // As the send screen treats an unconfirmed spend: not // a failure to retry. The wallet keeps the reply - // pending, and the row reads that; a sync (pull to - // refresh reads the private store) settles it. + // pending, and the row reads that. + kickDashPaySync(scope, m) + } + ContactRequestSubmissionOutcome.PrivateRequestPending -> { + // Nothing was sent, and the wallet may hold nothing + // for it (the store was not read to its end): the row + // stays pending until a private sync (pull to + // refresh) settles it. + walletPending = walletPending.withRefusal(hex) kickDashPaySync(scope, m) } is ContactRequestSubmissionOutcome.Failed -> @@ -249,7 +254,10 @@ fun ContactRequestsScreen(identityIdHex: String, navController: NavHostControlle isRefreshing = true manager?.let { m -> attachOrStartSync(m) - wallet?.let { privateRefreshNotice = syncPrivateContactRequests(it, m) } + wallet?.let { + privateRefreshNotice = syncPrivateContactRequests(it, m) + if (privateRefreshNotice == null) walletPending = walletPending.afterPrivateSync() + } pendingReadVersion++ } isRefreshing = false diff --git a/packages/kotlin-sdk/KotlinExampleApp/app/src/test/java/org/dashfoundation/example/ui/dashpay/ContactRequestOutcomesTest.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/test/java/org/dashfoundation/example/ui/dashpay/ContactRequestOutcomesTest.kt index 1d1cffae97f..f017c665153 100644 --- a/packages/kotlin-sdk/KotlinExampleApp/app/src/test/java/org/dashfoundation/example/ui/dashpay/ContactRequestOutcomesTest.kt +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/test/java/org/dashfoundation/example/ui/dashpay/ContactRequestOutcomesTest.kt @@ -82,6 +82,30 @@ class ContactRequestOutcomesTest { assertTrue(snapshot.allowsSubmission("01")) } + /** + * A send refused as pending (code 71) when the wallet reserved nothing + * (its store was not read to its end): the following read finds nothing + * pending, yet the contact stays pending, with the private sync offered, + * until a private sync completes. + */ + @Test + fun shouldKeepARefusedContactPendingAfterAnEmptyReadUntilAPrivateSync() { + var snapshot = PrivateRepliesPendingSnapshot() + .withRefusal("01") + .recorded(emptySet(), listOf("01")) + assertFalse(snapshot.isUnavailable) + assertTrue(snapshot.isPending("01")) + assertFalse(snapshot.allowsSubmission("01")) + assertEquals( + ContactRequestSubmissionOutcome.PRIVATE_REQUEST_PENDING_MESSAGE, + ContactRequestSubmissionOutcome.rowMessage(pending = snapshot.isPending("01"), error = null), + ) + + snapshot = snapshot.afterPrivateSync().recorded(emptySet(), listOf("01")) + assertFalse(snapshot.isPending("01")) + assertTrue(snapshot.allowsSubmission("01")) + } + /** * A successful read that found nothing pending offers every contact it * covered, and nothing it did not. From 4763d418550841783f732b6545b0e738594310ac Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 9 Nov 2026 12:12:47 +0700 Subject: [PATCH 132/269] docs(sdk): note the scan-state load and contact dependency in the parity manifest Co-Authored-By: Claude Opus 5.5 --- docs/sdk/sdk-parity-manifest.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/sdk/sdk-parity-manifest.json b/docs/sdk/sdk-parity-manifest.json index 5c0277b3646..775e27c0987 100644 --- a/docs/sdk/sdk-parity-manifest.json +++ b/docs/sdk/sdk-parity-manifest.json @@ -936,13 +936,13 @@ "sdk": "partial", "example_app": "partial", "restart": "required", - "reason": "SwiftData schema V5 stores each identity's read position and its private contacts' shielded addresses (PersistentPrivateContactRequestsPosition, PersistentPrivateShieldedAddress) inside the changeset round, clears the state of a removed identity, purges it with the wallet, hands it back through the load/free shims and declares the capability; the example app's Storage Explorer lists both models. No test restarts a process and resumes a private sync from the stored position." + "reason": "SwiftData schema V5 stores each identity's read position and its private contacts' shielded addresses (PersistentPrivateContactRequestsPosition, PersistentPrivateShieldedAddress) inside the changeset round, clears the state of a removed identity, purges it with the wallet, hands it back through the load/free shims (a stored row that cannot cross fails the load) and declares the capability; the example app's Storage Explorer lists both models. No test restarts a process and resumes a private sync from the stored position." }, "kotlin": { "sdk": "partial", "example_app": "not-applicable", "restart": "required", - "reason": "Room v16 (migration 15 to 16) stores the same rows in the round, clears a removed identity's state, purges it with the wallet and declares the capability; JNI wires the persist/load/free slots only for a bridge that overrides both callbacks, and rows cross as packed byte arrays. The example app has no storage explorer. No test restarts a process and resumes a private sync from the stored position." + "reason": "Room v16 (migration 15 to 16) stores the same rows in the round, clears a removed identity's state, purges it with the wallet and declares the capability; JNI wires the persist/load/free slots only for a bridge that overrides both callbacks and the contact upsert (the capability also needs contact persistence on the Rust side), rows cross as packed byte arrays, and a stored row that cannot cross fails the load. The example app has no storage explorer. No test restarts a process and resumes a private sync from the stored position." } }, "verification": [ From f9d669fa23c6669932d6e78542d29f630f897d60 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 9 Nov 2026 12:35:15 +0700 Subject: [PATCH 133/269] 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 134/269] 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 135/269] 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 136/269] 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 137/269] 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 138/269] 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 139/269] 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 140/269] 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 141/269] 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 142/269] 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 143/269] 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 0b28e29d7eb31d65a7bb7c1b64bc489f277707b2 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 9 Nov 2026 14:05:29 +0700 Subject: [PATCH 144/269] feat(platform-wallet-ffi): carry the unresolved private payment marker on the scan-state rows `PrivateContactRequestsPositionFFI` gains `private_payments_unresolved` (80 bytes, alignment 8): the store callback writes the wallet's marker and the load hands it back to `apply_private_contact_requests_position`, replacing the merge's set-on-load placeholder. Loaded set, the wallet holds the identity's public contact requests until its private payments are restored from the shielded store. Co-Authored-By: Claude Opus 5.5 --- .../src/contact_persistence.rs | 23 +++++++++++++++---- .../rs-platform-wallet-ffi/src/persistence.rs | 16 +++++++++---- 2 files changed, 29 insertions(+), 10 deletions(-) diff --git a/packages/rs-platform-wallet-ffi/src/contact_persistence.rs b/packages/rs-platform-wallet-ffi/src/contact_persistence.rs index ada1d0cdb79..2c23c31439d 100644 --- a/packages/rs-platform-wallet-ffi/src/contact_persistence.rs +++ b/packages/rs-platform-wallet-ffi/src/contact_persistence.rs @@ -234,6 +234,11 @@ pub struct PrivateContactRequestsPositionFFI { /// The fingerprint of the scanning keys `position` holds for. Opaque /// to the host: store it and return it as it came. pub key_fingerprint: [u8; 32], + /// Whether a private contact request the identity paid for may still + /// be unresolved. Store it and return it as it came: loaded set, it + /// holds the identity's public contact requests until its private + /// payments are restored from the shielded store. + pub private_payments_unresolved: bool, } /// The shielded address `contact_id`'s tracked private request to @@ -310,16 +315,24 @@ const _: [u8; 1] = [0u8; std::mem::align_of::()]; // Expected `PrivateContactRequestsPositionFFI` layout on 64-bit targets: // -// 0..=31 identity_id [u8; 32] -// 32..=39 position u64 -// 40..=71 key_fingerprint [u8; 32] +// 0..=31 identity_id [u8; 32] +// 32..=39 position u64 +// 40..=71 key_fingerprint [u8; 32] +// 72 private_payments_unresolved bool +// 73..=79 (tail padding to 8) // -// Total size = 72, alignment = 8 (from the u64). -const _: [u8; 72] = [0u8; std::mem::size_of::()]; +// Total size = 80, alignment = 8 (from the u64). +const _: [u8; 80] = [0u8; std::mem::size_of::()]; const _: [u8; 8] = [0u8; std::mem::align_of::()]; const _: () = assert!(std::mem::offset_of!(PrivateContactRequestsPositionFFI, position) == 32); const _: () = assert!(std::mem::offset_of!(PrivateContactRequestsPositionFFI, key_fingerprint) == 40); +const _: () = assert!( + std::mem::offset_of!( + PrivateContactRequestsPositionFFI, + private_payments_unresolved + ) == 72 +); // Expected `PrivateShieldedAddressFFI` layout on all targets: // diff --git a/packages/rs-platform-wallet-ffi/src/persistence.rs b/packages/rs-platform-wallet-ffi/src/persistence.rs index 6f05d0a8230..aa70789d325 100644 --- a/packages/rs-platform-wallet-ffi/src/persistence.rs +++ b/packages/rs-platform-wallet-ffi/src/persistence.rs @@ -6453,6 +6453,7 @@ impl PrivateContactScanStateRows { identity_id: identity_id.to_buffer(), position: position.position, key_fingerprint: position.key_fingerprint, + private_payments_unresolved: position.private_payments_unresolved, }, ) .collect(); @@ -6605,10 +6606,7 @@ fn load_private_contact_scan_state( managed.apply_private_contact_requests_position(PrivateContactRequestsPosition { position: row.position, key_fingerprint: row.key_fingerprint, - // The row does not carry the marker yet: restore it set, - // which holds the identity until its private payments are - // restored from the shielded store, rather than clear. - private_payments_unresolved: true, + private_payments_unresolved: row.private_payments_unresolved, }); } } @@ -8268,7 +8266,7 @@ mod tests { PrivateContactRequestsPosition { position: 12, key_fingerprint: [3; 32], - private_payments_unresolved: false, + private_payments_unresolved: true, }, ); contacts @@ -8298,6 +8296,7 @@ mod tests { identity_id: [7; 32], position: 12, key_fingerprint: [3; 32], + private_payments_unresolved: true, }] ); assert_eq!( @@ -8327,11 +8326,13 @@ mod tests { identity_id: [7; 32], position: 12, key_fingerprint: [3; 32], + private_payments_unresolved: true, }, PrivateContactRequestsPositionFFI { identity_id: [8; 32], position: 99, key_fingerprint: [4; 32], + private_payments_unresolved: false, }, ]); host.addresses.lock().unwrap().extend([ @@ -8359,6 +8360,10 @@ mod tests { .and_then(|identities| identities.get_mut(&0)) .expect("identity"); assert_eq!(managed.dashpay().private_contact_requests_position(), 12); + assert!( + managed.dashpay().private_payments_unresolved(), + "the unresolved-payment marker is restored with the position" + ); // The address shows once the contact's request to us came privately // (a private request carries no core height). let owner = Identifier::from([7; 32]); @@ -8409,6 +8414,7 @@ mod tests { identity_id: [7; 32], position: 12, key_fingerprint: [3; 32], + private_payments_unresolved: true, }); let undeclared = scan_state_persister(&host, PersistenceCapabilities::NONE, scan_state_trio()); From f75b945facb3126784551f219f4ed7b4d1e00eac Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 9 Nov 2026 14:23:58 +0700 Subject: [PATCH 145/269] feat(swift-sdk): store the unresolved private payment marker with the read position `PersistentPrivateContactRequestsPosition` (live schema V5, unreleased, so edited in place) gains `privatePaymentsUnresolved`, false by default. The handler stores the wallet's marker with the identity's read position and hands it back at load, so a relaunch holds the identity until its private payments are restored; Storage Explorer shows it. Co-Authored-By: Claude Opus 5.5 --- .../PersistentPrivateContactRequestsPosition.swift | 8 +++++++- .../PlatformWalletPersistenceHandler.swift | 13 ++++++++++--- .../Views/StorageRecordDetailViews.swift | 1 + .../PrivateContactScanStatePersistenceTests.swift | 14 ++++++++++++++ 4 files changed, 32 insertions(+), 4 deletions(-) diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentPrivateContactRequestsPosition.swift b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentPrivateContactRequestsPosition.swift index 9227c0c9d7b..789db14d2cb 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentPrivateContactRequestsPosition.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/Persistence/Models/PersistentPrivateContactRequestsPosition.swift @@ -16,13 +16,19 @@ public final class PersistentPrivateContactRequestsPosition { /// The fingerprint of the scanning keys `position` holds for; opaque /// here, handed back to the native wallet as it came. public var keyFingerprint: Data + /// Whether a private contact request the identity paid for may still be + /// unresolved; handed back as it came. Loaded set, Rust holds the + /// identity's public contact requests until its private payments are + /// restored from the shielded store. + public var privatePaymentsUnresolved: Bool = false public init(networkRaw: UInt32, walletId: Data, identityId: Data, - position: UInt64, keyFingerprint: Data) { + position: UInt64, keyFingerprint: Data, privatePaymentsUnresolved: Bool = false) { self.networkRaw = networkRaw self.walletId = walletId self.identityId = identityId self.position = Int64(bitPattern: position) self.keyFingerprint = keyFingerprint + self.privatePaymentsUnresolved = privatePaymentsUnresolved } } diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift index f25a88d4d02..53040d476ed 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift @@ -5127,6 +5127,8 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { let position: UInt64 /// Opaque to this side: stored and handed back as it came. let keyFingerprint: Data + /// Stored and handed back as it came. + var privatePaymentsUnresolved: Bool = false } /// The shielded address `contactId`'s private request to `ownerId` carries. @@ -5186,10 +5188,12 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { if let existing { existing.position = Int64(bitPattern: row.position) existing.keyFingerprint = row.keyFingerprint + existing.privatePaymentsUnresolved = row.privatePaymentsUnresolved } else { backgroundContext.insert(PersistentPrivateContactRequestsPosition( networkRaw: networkRaw, walletId: walletId, identityId: identityId, - position: row.position, keyFingerprint: row.keyFingerprint)) + position: row.position, keyFingerprint: row.keyFingerprint, + privatePaymentsUnresolved: row.privatePaymentsUnresolved)) } } for row in addresses { @@ -5237,7 +5241,8 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { positions.map { PrivateContactRequestsPositionRow( identityId: $0.identityId, position: UInt64(bitPattern: $0.position), - keyFingerprint: $0.keyFingerprint) + keyFingerprint: $0.keyFingerprint, + privatePaymentsUnresolved: $0.privatePaymentsUnresolved) }, addresses.map { PrivateShieldedAddressRow(ownerId: $0.ownerId, contactId: $0.contactId, address: $0.address) @@ -12092,7 +12097,8 @@ private func persistPrivateContactScanStateCallback( walletId: Data(bytes: walletId, count: 32), positions: positionRows.map { .init(identityId: dataFromTuple32($0.identity_id), position: $0.position, - keyFingerprint: dataFromTuple32($0.key_fingerprint)) + keyFingerprint: dataFromTuple32($0.key_fingerprint), + privatePaymentsUnresolved: $0.private_payments_unresolved) }, addresses: addressRows.map { var address = $0.address @@ -12144,6 +12150,7 @@ private func loadPrivateContactScanStateCallback( Swift.withUnsafeMutableBytes(of: &entry.identity_id) { _ = row.identityId.copyBytes(to: $0) } entry.position = row.position Swift.withUnsafeMutableBytes(of: &entry.key_fingerprint) { _ = row.keyFingerprint.copyBytes(to: $0) } + entry.private_payments_unresolved = row.privatePaymentsUnresolved (buffer + index).initialize(to: entry) } outPositions.pointee = UnsafePointer(buffer) diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageRecordDetailViews.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageRecordDetailViews.swift index 9425366cd32..29431a3565a 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageRecordDetailViews.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageRecordDetailViews.swift @@ -101,6 +101,7 @@ struct PrivateContactRequestsPositionStorageDetailView: View { Section("Private Contact Request Store") { FieldRow(label: "Read Position", value: String(UInt64(bitPattern: record.position))) FieldRow(label: "Key Fingerprint", value: hexString(record.keyFingerprint)) + FieldRow(label: "Private Payments Unresolved", value: record.privatePaymentsUnresolved ? "Yes" : "No") } } .navigationTitle("Private Position") diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/PrivateContactScanStatePersistenceTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/PrivateContactScanStatePersistenceTests.swift index 6bea09bc492..e8405ee0b7f 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/PrivateContactScanStatePersistenceTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/PrivateContactScanStatePersistenceTests.swift @@ -71,6 +71,18 @@ final class PrivateContactScanStatePersistenceTests: XCTestCase { success: false) XCTAssertEqual(try handler.loadPrivateContactScanState(walletId: walletId).positions.map(\.position), [0]) + // The unresolved-payment marker is stored with the position and + // cleared by a later round. + try round(handler, positions: [.init( + identityId: owner, position: 0, keyFingerprint: fingerprint, privatePaymentsUnresolved: true)]) + XCTAssertEqual( + try handler.loadPrivateContactScanState(walletId: walletId).positions.map(\.privatePaymentsUnresolved), + [true]) + try round(handler, positions: [.init(identityId: owner, position: 0, keyFingerprint: fingerprint)]) + XCTAssertEqual( + try handler.loadPrivateContactScanState(walletId: walletId).positions.map(\.privatePaymentsUnresolved), + [false]) + // A full-width position survives the signed SQLite column. try round(handler, positions: [.init(identityId: owner, position: .max, keyFingerprint: fingerprint)]) XCTAssertEqual(try handler.loadPrivateContactScanState(walletId: walletId).positions.map(\.position), [.max]) @@ -169,6 +181,7 @@ final class PrivateContactScanStatePersistenceTests: XCTestCase { var position = PrivateContactRequestsPositionFFI() Swift.withUnsafeMutableBytes(of: &position.identity_id) { _ = owner.copyBytes(to: $0) } position.position = 77 + position.private_payments_unresolved = true Swift.withUnsafeMutableBytes(of: &position.key_fingerprint) { _ = Data(repeating: 3, count: 32).copyBytes(to: $0) } @@ -198,6 +211,7 @@ final class PrivateContactScanStatePersistenceTests: XCTestCase { XCTAssertEqual(addressesCount, 1) let loadedPosition = try XCTUnwrap(positions).pointee XCTAssertEqual(loadedPosition.position, 77) + XCTAssertTrue(loadedPosition.private_payments_unresolved, "the unresolved marker round-trips") var loadedOwner = loadedPosition.identity_id XCTAssertEqual(Swift.withUnsafeBytes(of: &loadedOwner) { Data($0) }, owner) var loadedAddress = try XCTUnwrap(addresses).pointee.address From afbe1d6a232b05069f208672e49a99f244e756d6 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 9 Nov 2026 14:23:58 +0700 Subject: [PATCH 146/269] feat(swift-sdk): type code 72 and read the wallet's private contact readiness `PlatformWalletError.privateContactPaymentsNotRestored` binds `ErrorPrivateContactPaymentsNotRestored` (72): the wallet's private payments from an earlier process are not restored yet, apart from a contact's pending request (71). `ManagedPlatformWallet.privateContactReadiness(identityId:)` binds `platform_wallet_get_private_contact_readiness`, so a host reads whether the payments are restored, whether the private store was read to its end, and whether a private payment may be unresolved. Co-Authored-By: Claude Opus 5.5 --- .../ERROR_CODE_REGISTRY.md | 2 +- .../PlatformWallet/PlatformWalletResult.swift | 20 ++++++- .../PrivateContactRequests.swift | 52 +++++++++++++++++++ .../ErrorHandlingTests.swift | 27 ++++++++++ 4 files changed, 99 insertions(+), 2 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..397aac4e358 100644 --- a/packages/rs-platform-wallet-ffi/ERROR_CODE_REGISTRY.md +++ b/packages/rs-platform-wallet-ffi/ERROR_CODE_REGISTRY.md @@ -185,7 +185,7 @@ Fork-era numbers remain in the collision history, which is immutable record. | 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 | -| 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 | +| 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 types it (`PlatformWalletError.privateContactPaymentsNotRestored`, `ErrorHandlingTests` pin) and binds the readiness (`ManagedPlatformWallet.privateContactReadiness`); SwiftExampleApp shows "restoring private payments" apart from a pending request (71) until the readiness reads restored. Kotlin must type it, as for 71 | **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/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift index 6374d5e1d85..22963ef38a3 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift @@ -282,6 +282,12 @@ public enum PlatformWalletResultCode: Int32, Sendable { /// and not yet read back from the store. Nothing new was paid; a later /// private contact request sync settles it. Do not submit it again. case errorPrivateContactRequestPending = 71 + /// The wallet, not one contact, is held: its private payments from an + /// earlier process are not restored from the shielded store yet, so no + /// public contact request is sent or accepted. Configuring the shielded + /// store or binding the wallet's shielded account restores them; a + /// private sync does not. + case errorPrivateContactPaymentsNotRestored = 72 /// The named thing does not exist. Besides the handle/lookup failures this /// has always covered, BOTH deferred-send paths report the /// wallet-was-REMOVED case here. @@ -421,6 +427,8 @@ public enum PlatformWalletResultCode: Int32, Sendable { self = .errorProtocolVersionNotActive case PLATFORM_WALLET_FFI_RESULT_CODE_ERROR_PRIVATE_CONTACT_REQUEST_PENDING: self = .errorPrivateContactRequestPending + case PLATFORM_WALLET_FFI_RESULT_CODE_ERROR_PRIVATE_CONTACT_PAYMENTS_NOT_RESTORED: + self = .errorPrivateContactPaymentsNotRestored case PLATFORM_WALLET_FFI_RESULT_CODE_NOT_FOUND: self = .notFound case PLATFORM_WALLET_FFI_RESULT_CODE_ERROR_UNKNOWN: @@ -639,6 +647,14 @@ public enum PlatformWalletError: LocalizedError { /// `ManagedPlatformWallet.syncPrivateContactRequests`, or a retry, settles /// it. Pending, not failed: do not submit it again. case privateContactRequestPending(String) + /// The wallet's private payments from an earlier process are not + /// restored from the shielded store yet, so its public contact requests, + /// sent or accepted, are held (code 72). Nothing was signed or sent. + /// `PlatformWalletManager.configureShielded` or binding the wallet's + /// shielded account restores them; a private sync does not. + /// `ManagedPlatformWallet.privateContactReadiness(identityId:)` reads + /// whether they are restored. + case privateContactPaymentsNotRestored(String) case walletAlreadyExists(String) /// Definitive shielded-broadcast failure: the shielded transition /// (identity-create or a spend — unshield / transfer / withdrawal) was @@ -866,7 +882,7 @@ public enum PlatformWalletError: LocalizedError { .assetLockTooManyInputs(let m), .privateContactRequestNeedsPrivateReply(let m), .shieldedBuildRequired(let m), .protocolVersionNotActive(let m), - .privateContactRequestPending(let m), + .privateContactRequestPending(let m), .privateContactPaymentsNotRestored(let m), .walletAlreadyExists(let m), .shieldedBroadcastFailed(let m), .shieldedBroadcastUnconfirmed(let m), .shieldedSpendUnconfirmed(let m), .shieldedNoRecordedAnchor(let m), .shieldedInsufficientBalance(let m), @@ -993,6 +1009,8 @@ public enum PlatformWalletError: LocalizedError { self = .protocolVersionNotActive(detail) case .errorPrivateContactRequestPending: self = .privateContactRequestPending(detail) + case .errorPrivateContactPaymentsNotRestored: + self = .privateContactPaymentsNotRestored(detail) case .errorWalletAlreadyExists: self = .walletAlreadyExists(detail) case .errorShieldedBroadcastFailed: self = .shieldedBroadcastFailed(detail) case .errorShieldedBroadcastUnconfirmed: self = .shieldedBroadcastUnconfirmed(detail) diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PrivateContactRequests.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PrivateContactRequests.swift index 80e6bafd0af..3a40d504f8a 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PrivateContactRequests.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PrivateContactRequests.swift @@ -44,6 +44,44 @@ public struct PrivateContactInfo: Sendable, Equatable { } } +/// What a contact request from one of the wallet's identities waits for, as +/// the wallet decides it (`platform_wallet_get_private_contact_readiness`), +/// so a host shows "restoring private payments" apart from "reading the +/// private store" and from a contact's pending request rather than inferring +/// them from the refusals it got. +public struct PrivateContactReadiness: Sendable, Equatable { + /// 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 + /// `PlatformWalletError.privateContactPaymentsNotRestored` (72) until the + /// shielded store is configured or the wallet's shielded account bound; + /// no private sync lifts it. + public let privatePaymentsRestored: 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` (71) while that + /// read cannot finish. + public let privateStoreRead: Bool + /// Whether a private contact request the identity paid for may still be + /// unresolved. A contact's own pending request is + /// `ManagedIdentity.privateReplyPending(contactId:)`. + public let unresolvedPrivatePayment: Bool + + public init(privatePaymentsRestored: Bool, privateStoreRead: Bool, unresolvedPrivatePayment: Bool) { + self.privatePaymentsRestored = privatePaymentsRestored + self.privateStoreRead = privateStoreRead + self.unresolvedPrivatePayment = unresolvedPrivatePayment + } + + init(ffi: PrivateContactReadinessFFI) { + self.init( + privatePaymentsRestored: ffi.private_payments_restored, + privateStoreRead: ffi.private_store_read, + unresolvedPrivatePayment: ffi.unresolved_private_payment) + } +} + /// What one pass over DashPay's private contact request store did. public struct PrivateContactSyncResult: Sendable { /// The requests the pass ingested, received and sent, including ones the @@ -102,6 +140,20 @@ extension ManagedIdentity { } extension ManagedPlatformWallet { + /// What a contact request from `identityId`, one of this wallet's + /// identities, waits for, as the wallet decides it. A pure read of live + /// wallet state; read it again after the shielded store is configured or + /// the wallet binds its shielded account to see the restore. + public func privateContactReadiness(identityId: Identifier) throws -> PrivateContactReadiness { + var ffi = PrivateContactReadinessFFI() + let handle = self.handle + let result = identityId.withFFIBytes { idPtr in + platform_wallet_get_private_contact_readiness(handle, idPtr, &ffi) + } + try result.check() + return PrivateContactReadiness(ffi: ffi) + } + /// Read DashPay's private contact request store for every identity of /// this wallet, at most `maxPages` pages (the default reads to the end), /// and ingest what it holds for them: requests to them, each sender diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/ErrorHandlingTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/ErrorHandlingTests.swift index df94cd5b6f5..e5c8c605a6c 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/ErrorHandlingTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/ErrorHandlingTests.swift @@ -521,6 +521,33 @@ final class ErrorHandlingTests: XCTestCase { XCTAssertEqual(message, detail) } + /// Unrestored private payments are the wallet's hold, not one contact's + /// pending request: their own code and typed case, apart from 71. + func testShouldReportUnrestoredPrivatePaymentsByTheirOwnCode() { + let code = PlatformWalletResultCode( + ffi: PLATFORM_WALLET_FFI_RESULT_CODE_ERROR_PRIVATE_CONTACT_PAYMENTS_NOT_RESTORED + ) + XCTAssertEqual(code, .errorPrivateContactPaymentsNotRestored) + XCTAssertEqual(code.rawValue, 72) + let detail = "the wallet's private payments are not restored from the shielded store yet" + let error = PlatformWalletError(code: code, message: detail) + guard case .privateContactPaymentsNotRestored(let message) = error else { + return XCTFail("lost typed not-restored error") + } + XCTAssertEqual(message, detail) + } + + func testShouldReadEveryFieldOfThePrivateContactReadiness() { + var ffi = PrivateContactReadinessFFI() + ffi.private_payments_restored = false + ffi.private_store_read = true + ffi.unresolved_private_payment = true + XCTAssertEqual( + PrivateContactReadiness(ffi: ffi), + PrivateContactReadiness( + privatePaymentsRestored: false, privateStoreRead: true, unresolvedPrivatePayment: true)) + } + func testShouldReadEveryFieldOfAPrivateContact() { var ffi = PrivateContactFFI() ffi.received_privately = true From 7405033aa555a198c86b91176b74372126b5744a Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 9 Nov 2026 14:23:58 +0700 Subject: [PATCH 147/269] fix(swift-example-app): show restoring private payments from the wallet's readiness The views cleared every code 71 refusal once a private sync finished, though a sync releases neither a contact's reservation nor the wallet's unrestored payments. They now render the wallet's own state: - code 72 shows "restoring private payments" for every contact, apart from a pending request (71), and only a readiness read that says restored lifts it; a private sync is not offered for it; - a code 71 refusal stays pending until the readiness says the private store was read to its end, after which the pending read decides; - the readiness is read with the pending state and again when the shielded account binds. Co-Authored-By: Claude Opus 5.5 --- .../Views/DashPay/AddContactView.swift | 48 +++++++---- .../DashPay/ContactRequestOutcomes.swift | 81 +++++++++++++++---- .../Views/DashPay/ContactRequestsView.swift | 21 +++-- .../Views/DashPay/ContactsView.swift | 11 +++ .../ContactRequestOutcomesTests.swift | 65 ++++++++++++++- 5 files changed, 188 insertions(+), 38 deletions(-) diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/AddContactView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/AddContactView.swift index 2956cc698ec..be26d80415c 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/AddContactView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/AddContactView.swift @@ -18,6 +18,9 @@ struct AddContactView: View { let onSent: (Identifier, String?) -> Void @EnvironmentObject var walletManager: PlatformWalletManager + /// Observed for its bind: binding the shielded account restores the + /// wallet's private payments, so the readiness is read again then. + @EnvironmentObject var shieldedService: ShieldedService @Environment(\.dismiss) private var dismiss @Environment(\.modelContext) private var modelContext @@ -113,11 +116,13 @@ struct AddContactView: View { && resolvedRecipient.map(recipientPending.allowsSubmission(to:)) == true } - /// Whether the footer shows the recipient's pending state: a private - /// request to them is pending, or the wallet could not be read. + /// Whether the footer shows the recipient's pending state: the wallet's + /// private payments are being restored, a private request to them is + /// pending, or the wallet could not be read. private var recipientBlocked: Bool { guard let recipient = resolvedRecipient else { return false } - return recipientPending.isPending(recipient) || recipientPending.isUnavailable + return recipientPending.isRestoringPrivatePayments + || recipientPending.isPending(recipient) || recipientPending.isUnavailable } var body: some View { @@ -157,6 +162,7 @@ struct AddContactView: View { } .navigationTitle("Add Contact") .task(id: resolvedRecipient) { reloadRecipientPending() } + .onChange(of: shieldedService.isBound) { _, _ in reloadRecipientPending() } .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .navigationBarLeading) { @@ -428,21 +434,26 @@ struct AddContactView: View { VStack(alignment: .leading, spacing: 6) { Text(ContactRequestSubmissionOutcome.rowMessage( pending: recipientPending.isPending(recipient), + restoring: recipientPending.isRestoringPrivatePayments, unavailable: recipientPending.isUnavailable, error: nil ) ?? "") .accessibilityIdentifier("dashpay.addContact.pending") - Button { - syncPrivateRequests() - } label: { - if isSyncingPrivate { - ProgressView() - } else { - Text("Sync private requests") + // A private sync settles a pending request; it does not + // restore private payments, so it is not offered for that. + if !recipientPending.isRestoringPrivatePayments { + Button { + syncPrivateRequests() + } label: { + if isSyncingPrivate { + ProgressView() + } else { + Text("Sync private requests") + } } + .disabled(isSyncingPrivate) + .accessibilityIdentifier("dashpay.addContact.syncPrivate") } - .disabled(isSyncingPrivate) - .accessibilityIdentifier("dashpay.addContact.syncPrivate") } } } @@ -452,14 +463,17 @@ struct AddContactView: View { /// recipient is pending. private func reloadRecipientPending() { let contactIds = resolvedRecipient.map { [$0] } ?? [] + let wallet = try? requireWallet() recipientPending.record( privateRepliesPending( - wallet: try? requireWallet(), + wallet: wallet, identityId: identity.identityId, contactIds: contactIds ), for: contactIds ) + recipientPending.recordReadiness( + privateContactReadiness(wallet: wallet, identityId: identity.identityId)) } /// Read the private store, which settles a pending private request, then @@ -472,8 +486,6 @@ struct AddContactView: View { if let wallet = try? requireWallet() { if let notice = await syncPrivateContactRequests(wallet) { errorMessage = notice - } else { - recipientPending.recordPrivateSync() } } reloadRecipientPending() @@ -589,6 +601,10 @@ struct AddContactView: View { // it: the footer shows it as pending and offers the // private sync that settles it. recipientPending.recordRefusal(of: recipient) + case .privatePaymentsNotRestored: + // Nothing was sent: the footer shows the wallet restoring + // its private payments until its readiness says restored. + recipientPending.recordRestoringRefusal() case .failed(let message): errorMessage = message } @@ -650,6 +666,8 @@ struct AddContactView: View { case .privateRequestPending: // Nothing was sent: the footer offers the private sync. recipientPending.recordRefusal(of: recipient) + case .privatePaymentsNotRestored: + recipientPending.recordRestoringRefusal() case .failed(let message): errorMessage = "Accept failed: \(message)" } diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ContactRequestOutcomes.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ContactRequestOutcomes.swift index 88859c6328c..2289a163dbd 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ContactRequestOutcomes.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ContactRequestOutcomes.swift @@ -9,13 +9,20 @@ import SwiftDashSDK /// contact in flight, or paid and not yet read back), which a public send or /// accept can also meet, sent nothing new: the view shows it as pending and /// offers a private sync, which settles it, after which the user may try -/// again. Every other error is an ordinary failure the user may retry. +/// again. Unrestored private payments (code 72) hold the wallet, not one +/// contact: the view shows "restoring private payments" until the wallet's +/// readiness reads restored; no private sync lifts it. Every other error is +/// an ordinary failure the user may retry. enum ContactRequestSubmissionOutcome: Equatable { /// The private send or reply may have gone through: do not submit again. case mayHaveGoneThrough /// A private request to the contact is pending in the wallet; nothing /// was sent. A private sync settles it. case privateRequestPending + /// The wallet's private payments from an earlier process are not + /// restored yet; nothing was sent. Restoring them, not a private sync, + /// lifts it. + case privatePaymentsNotRestored /// It failed; the message is the error's. case failed(String) @@ -26,11 +33,18 @@ enum ContactRequestSubmissionOutcome: Equatable { + "pending. Sync private requests to settle it, then try again if it did not " + "go through." - /// The message a request row shows: the pending notice while the wallet - /// has a private request to the contact pending, the unavailable notice - /// while the wallet could not be read, whatever error an earlier attempt - /// left, and that error otherwise. - static func rowMessage(pending: Bool, unavailable: Bool = false, error: String?) -> String? { + static let restoringPrivatePaymentsMessage = "Restoring this wallet's private " + + "payments. Contact requests wait until they are restored." + + /// The message a request row shows: the restoring notice while the + /// wallet's private payments are not restored, the pending notice while + /// the wallet has a private request to the contact pending, the + /// unavailable notice while the wallet could not be read, and whatever + /// error an earlier attempt left otherwise. + static func rowMessage( + pending: Bool, restoring: Bool = false, unavailable: Bool = false, error: String? + ) -> String? { + if restoring { return restoringPrivatePaymentsMessage } if pending { return privateRequestPendingMessage } if unavailable { return PrivateRepliesPendingSnapshot.unavailableMessage } return error @@ -43,6 +57,8 @@ enum ContactRequestSubmissionOutcome: Equatable { return .mayHaveGoneThrough case .privateContactRequestPending: return .privateRequestPending + case .privateContactPaymentsNotRestored: + return .privatePaymentsNotRestored default: break } @@ -62,7 +78,15 @@ enum ContactRequestSubmissionOutcome: Equatable { /// read: code 71 also means the private store could not be read to its end /// before the first send or accept since load, with nothing reserved, so the /// read may find nothing pending. That contact stays pending, with the -/// private sync offered, until a private sync completes. +/// private sync offered, until the wallet's readiness says the store was +/// read to its end. +/// +/// Whether the wallet's private payments are restored is the wallet's +/// readiness (`ManagedPlatformWallet.privateContactReadiness`), not +/// anything the views conclude: a code 72 refusal, or a readiness read that +/// says not restored, shows "restoring private payments" for every contact, +/// and only a readiness read that says restored lifts it. A private sync +/// changes neither on its own. struct PrivateRepliesPendingSnapshot: Equatable { static let unavailableMessage = "Could not read whether a private request to " + "this contact is pending. Sync private requests to check again." @@ -73,9 +97,15 @@ struct PrivateRepliesPendingSnapshot: Equatable { private(set) var covered: Set = [] /// False after a failed read, until a read succeeds. private(set) var available = true - /// Contacts a send or accept was refused for as pending (code 71) since - /// the last completed private sync. + /// Contacts a send or accept was refused for as pending (code 71), until + /// the wallet's readiness says the private store was read to its end. private(set) var refused: Set = [] + /// The wallet's readiness for the identity, as last read; nil until a + /// read succeeds. + private(set) var readiness: PrivateContactReadiness? + /// Set by a code 72 refusal; cleared only by a readiness read that says + /// the private payments are restored. + private(set) var restoringRefused = false /// Record a read of `contactIds`: the pending ones, or nil when the read /// failed. @@ -95,15 +125,37 @@ struct PrivateRepliesPendingSnapshot: Equatable { refused.insert(contactId) } - /// Record a completed private sync, which settles every refusal: the - /// next read says what is still pending. - mutating func recordPrivateSync() { - refused.removeAll() + /// Record a send or accept refused because the wallet's private payments + /// are not restored (code 72). + mutating func recordRestoringRefusal() { + restoringRefused = true + } + + /// Record a read of the wallet's readiness, or nil when it failed, which + /// keeps what the last read said. A readiness that says restored lifts a + /// code 72 refusal; one that says the private store was read to its end + /// leaves a contact's pending state to the pending read. + mutating func recordReadiness(_ read: PrivateContactReadiness?) { + guard let read else { return } + readiness = read + if read.privatePaymentsRestored { + restoringRefused = false + } + if read.privateStoreRead { + refused.removeAll() + } + } + + /// Whether the wallet's private payments are being restored: a send or + /// accept was refused with code 72, or the last readiness read says they + /// are not restored. Every contact waits while they are. + var isRestoringPrivatePayments: Bool { + restoringRefused || readiness?.privatePaymentsRestored == false } /// Whether `contactId` is pending: the last successful read found a /// private request to it, or a send or accept to it was refused as - /// pending since the last completed private sync. + /// pending and the store has not been read to its end since. func isPending(_ contactId: Data) -> Bool { pending.contains(contactId) || refused.contains(contactId) } @@ -115,6 +167,7 @@ struct PrivateRepliesPendingSnapshot: Equatable { /// read covered it and found nothing pending. func allowsSubmission(to contactId: Data) -> Bool { available && covered.contains(contactId) && !isPending(contactId) + && !isRestoringPrivatePayments } } diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ContactRequestsView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ContactRequestsView.swift index 6709ba9a1c6..070dc4a1fcf 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ContactRequestsView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ContactRequestsView.swift @@ -20,6 +20,9 @@ struct ContactRequestsView: View { @Binding var optimisticSentIds: Set @EnvironmentObject var walletManager: PlatformWalletManager + /// Observed for its bind: binding the shielded account restores the + /// wallet's private payments, so the readiness is read again then. + @EnvironmentObject var shieldedService: ShieldedService @EnvironmentObject var contactMeta: DashPayContactMetaStore @Environment(\.modelContext) private var modelContext @@ -144,6 +147,7 @@ struct ContactRequestsView: View { || !walletPending.allowsSubmission(to: row.contactIdentityId), errorMessage: ContactRequestSubmissionOutcome.rowMessage( pending: walletPending.isPending(row.contactIdentityId), + restoring: walletPending.isRestoringPrivatePayments, unavailable: walletPending.isUnavailable, error: rowErrors[row.contactIdentityId] ), @@ -186,13 +190,11 @@ struct ContactRequestsView: View { await attachOrStartSync(walletManager) if let wallet = try? requireWallet() { privateRefreshNotice = await syncPrivateContactRequests(wallet) - if privateRefreshNotice == nil { - walletPending.recordPrivateSync() - } } reloadPendingReplies() } .task { reloadPendingReplies() } + .onChange(of: shieldedService.isBound) { _, _ in reloadPendingReplies() } .onChange(of: rowSignature) { _, _ in pruneOverlays() reloadPendingReplies() @@ -227,14 +229,17 @@ struct ContactRequestsView: View { /// wallet. private func reloadPendingReplies() { let contactIds = incomingPending.map(\.contactIdentityId) + let wallet = try? requireWallet() walletPending.record( privateRepliesPending( - wallet: try? requireWallet(), + wallet: wallet, identityId: identity.identityId, contactIds: contactIds ), for: contactIds ) + walletPending.recordReadiness( + privateContactReadiness(wallet: wallet, identityId: identity.identityId)) } // MARK: - Overlay maintenance @@ -303,10 +308,14 @@ struct ContactRequestsView: View { case .privateRequestPending: // Nothing was sent, and the wallet may hold nothing for // it (the store was not read to its end): the row stays - // pending until a private sync (pull to refresh) settles - // it. + // pending until the wallet's readiness says the store was + // read (pull to refresh reads it). walletPending.recordRefusal(of: contactId) kickDashPaySync(walletManager) + case .privatePaymentsNotRestored: + // The wallet is held until its private payments are + // restored; every row waits for the readiness to say so. + walletPending.recordRestoringRefusal() case .failed(let message): rowErrors[contactId] = "Accept failed: \(message)" } diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ContactsView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ContactsView.swift index 6a16d4e1c18..2a241112dcc 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ContactsView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ContactsView.swift @@ -261,6 +261,17 @@ func privateRepliesPending( } } +/// The wallet's readiness for `identityId`'s contact requests, or nil when +/// it could not be read (`PrivateRepliesPendingSnapshot.recordReadiness` +/// keeps the last one then). +@MainActor +func privateContactReadiness( + wallet: ManagedPlatformWallet?, + identityId: Data +) -> PrivateContactReadiness? { + try? wallet?.privateContactReadiness(identityId: identityId) +} + /// Read DashPay's private contact request store (protocol version 15) for /// `wallet`, returning what the refresh should tell the user, or nil. /// Private requests reach the wallet only through this read, so it runs with diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/ContactRequestOutcomesTests.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/ContactRequestOutcomesTests.swift index 01b787ac509..5de5dd8e489 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/ContactRequestOutcomesTests.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/ContactRequestOutcomesTests.swift @@ -69,8 +69,8 @@ final class ContactRequestOutcomesTests: XCTestCase { /// A send refused as pending (code 71) when the wallet reserved /// nothing (its store was not read to its end): the following read finds /// nothing pending, yet the contact stays pending, with the private sync - /// offered, until a private sync completes. - func testShouldKeepARefusedContactPendingAfterAnEmptyReadUntilAPrivateSync() { + /// offered, until the wallet's readiness says the store was read. + func testShouldKeepARefusedContactPendingUntilTheReadinessSaysTheStoreWasRead() { let contact = Data(repeating: 1, count: 32) var snapshot = PrivateRepliesPendingSnapshot() snapshot.recordRefusal(of: contact) @@ -83,12 +83,71 @@ final class ContactRequestOutcomesTests: XCTestCase { pending: snapshot.isPending(contact), error: nil), ContactRequestSubmissionOutcome.privateRequestPendingMessage) - snapshot.recordPrivateSync() + // A pending read that finds nothing does not lift it; the wallet's + // readiness saying the store was read to its end does. snapshot.record([], for: [contact]) + XCTAssertTrue(snapshot.isPending(contact)) + snapshot.recordReadiness(PrivateContactReadiness( + privatePaymentsRestored: true, privateStoreRead: true, unresolvedPrivatePayment: false)) XCTAssertFalse(snapshot.isPending(contact)) XCTAssertTrue(snapshot.allowsSubmission(to: contact)) } + /// Unrestored private payments (code 72) are their own outcome, apart + /// from a pending request (71), with their own message. + func testShouldTellUnrestoredPrivatePaymentsFromAPendingRequest() { + XCTAssertEqual( + ContactRequestSubmissionOutcome.of( + PlatformWalletError.privateContactPaymentsNotRestored("not restored")), + .privatePaymentsNotRestored) + XCTAssertEqual( + ContactRequestSubmissionOutcome.rowMessage(pending: true, restoring: true, error: nil), + ContactRequestSubmissionOutcome.restoringPrivatePaymentsMessage) + XCTAssertNotEqual( + ContactRequestSubmissionOutcome.restoringPrivatePaymentsMessage, + ContactRequestSubmissionOutcome.privateRequestPendingMessage) + } + + /// A code 72 hold survives a successful pending read that finds nothing, + /// and a readiness read after a private sync that still says not + /// restored; only a readiness read that says restored lifts it. + func testShouldHoldOnCode72UntilTheReadinessSaysRestored() { + let contact = Data(repeating: 1, count: 32) + var snapshot = PrivateRepliesPendingSnapshot() + snapshot.recordRestoringRefusal() + snapshot.record([], for: [contact]) + XCTAssertTrue(snapshot.isRestoringPrivatePayments) + XCTAssertFalse(snapshot.allowsSubmission(to: contact)) + + // A private sync read the store to its end; the payments are still + // not restored. + snapshot.recordReadiness(PrivateContactReadiness( + privatePaymentsRestored: false, privateStoreRead: true, unresolvedPrivatePayment: true)) + XCTAssertTrue(snapshot.isRestoringPrivatePayments) + XCTAssertFalse(snapshot.allowsSubmission(to: contact)) + + // A failed readiness read keeps what the last one said. + snapshot.recordReadiness(nil) + XCTAssertTrue(snapshot.isRestoringPrivatePayments) + + snapshot.recordReadiness(PrivateContactReadiness( + privatePaymentsRestored: true, privateStoreRead: true, unresolvedPrivatePayment: false)) + XCTAssertFalse(snapshot.isRestoringPrivatePayments) + XCTAssertTrue(snapshot.allowsSubmission(to: contact)) + } + + /// A readiness that says not restored holds every contact without any + /// refusal, as the wallet would refuse them. + func testShouldShowRestoringFromTheReadinessAlone() { + let contact = Data(repeating: 1, count: 32) + var snapshot = PrivateRepliesPendingSnapshot() + snapshot.record([], for: [contact]) + snapshot.recordReadiness(PrivateContactReadiness( + privatePaymentsRestored: false, privateStoreRead: false, unresolvedPrivatePayment: false)) + XCTAssertTrue(snapshot.isRestoringPrivatePayments) + XCTAssertFalse(snapshot.allowsSubmission(to: contact)) + } + /// A successful read that found nothing pending offers every contact it /// covered, and nothing it did not. func testShouldOfferEveryContactASuccessfulEmptyReadCovered() { From 27f1a690628929a469b1e36b390835d9ca3fb43b Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 9 Nov 2026 14:23:58 +0700 Subject: [PATCH 148/269] feat(platform-wallet-ffi): compute a host's effective persistence capabilities without a manager `effective_persistence_capabilities` intersects a declaration with what a vtable and its extension structurally carry, as manager creation does, and releases the context. It lets a host adapter test which callbacks keep a capability; the JNI layer uses it to check its bridge overrides. Co-Authored-By: Claude Opus 5.5 --- .../rs-platform-wallet-ffi/src/manager.rs | 65 +++++++++++++++++++ 1 file changed, 65 insertions(+) diff --git a/packages/rs-platform-wallet-ffi/src/manager.rs b/packages/rs-platform-wallet-ffi/src/manager.rs index 222ebdc2422..b93dda6a0b7 100644 --- a/packages/rs-platform-wallet-ffi/src/manager.rs +++ b/packages/rs-platform-wallet-ffi/src/manager.rs @@ -24,6 +24,7 @@ use crate::{unwrap_option_or_return, unwrap_result_or_return}; use dash_sdk::Sdk; use key_wallet::wallet::initialization::WalletAccountCreationOptions; +use platform_wallet::changeset::PlatformWalletPersistence; use platform_wallet::PlatformWalletManager; use platform_wallet::{PersistenceCapabilities, PERSISTENCE_CAPABILITIES_VERSION}; use std::os::raw::c_void; @@ -177,6 +178,32 @@ pub unsafe extern "C" fn platform_wallet_manager_create_with_extensions( ) } +/// The persistence capabilities a manager created from these callbacks +/// would attest, without creating one: the declaration intersected with what +/// the vtable and the extension structurally carry, as +/// [`platform_wallet_manager_create_with_extensions`] computes them. For a +/// host adapter's own tests, such as the JNI layer checking which bridge +/// overrides keep a capability. `persistence` is consumed: its `release_fn` +/// runs before this returns. +/// +/// # Safety +/// As for [`platform_wallet_manager_create_with_extensions`]: `extension` +/// must point to a readable [`PersistenceCallbacksExtension`] of at least +/// its declared `struct_size`, and `persistence`'s context must be valid +/// for its `release_fn`. +pub unsafe fn effective_persistence_capabilities( + persistence: PersistenceCallbacks, + declared: &PersistenceCapabilitiesFFI, + extension: *const PersistenceCallbacksExtension, +) -> PersistenceCapabilities { + FFIPersister::new_with_persistence_capabilities_and_extensions( + persistence, + persistence_capabilities_declaration(declared), + persistence_extension_callbacks(extension), + ) + .persistence_capabilities() +} + /// Read one negotiated slot out of a size/version-tagged extension struct /// — the single authority for the gate every reader below applies. A slot /// is read only when the host's declared `struct_size` proves it was @@ -1389,6 +1416,44 @@ mod tests { assert!(parsed.load_identity_balance_block_time.is_none()); } + /// The effective capabilities are the declaration intersected with what + /// the callbacks carry, and the vtable's `release_fn` runs once. + #[test] + fn should_compute_the_effective_persistence_capabilities_without_a_manager() { + use std::sync::atomic::{AtomicUsize, Ordering}; + static RELEASES: AtomicUsize = AtomicUsize::new(0); + unsafe extern "C" fn release(_: *mut c_void) { + RELEASES.fetch_add(1, Ordering::SeqCst); + } + let declared = PersistenceCapabilitiesFFI { + version: PERSISTENCE_CAPABILITIES_VERSION, + reserved: 0, + bits: PersistenceCapabilities::ATOMIC_CHANGESETS + .union(PersistenceCapabilities::PRIVATE_CONTACT_REQUEST_SCAN_STATE) + .bits(), + }; + let extension = PersistenceCallbacksExtension::default(); + let effective = unsafe { + effective_persistence_capabilities( + PersistenceCallbacks { + context: std::ptr::NonNull::::dangling().as_ptr().cast(), + release_fn: Some(release), + on_changeset_begin_fn: Some(begin_changeset), + on_changeset_end_fn: Some(end_changeset), + ..PersistenceCallbacks::default() + }, + &declared, + &extension, + ) + }; + assert!(effective.contains(PersistenceCapabilities::ATOMIC_CHANGESETS)); + assert!( + !effective.contains(PersistenceCapabilities::PRIVATE_CONTACT_REQUEST_SCAN_STATE), + "declared without the callbacks it needs" + ); + assert_eq!(RELEASES.load(Ordering::SeqCst), 1); + } + #[test] fn should_size_gate_private_contact_scan_state_slots_independently() { #[allow(clippy::too_many_arguments)] From c69b54985fa3e657ed622e12305f4e56507f0d3c Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 9 Nov 2026 14:29:56 +0700 Subject: [PATCH 149/269] feat(kotlin-sdk,unified-sdk-jni): store the unresolved private payment marker with the read position `private_contact_requests_positions` (Room 16, unreleased, so edited in place with its migration and exported schema) gains `privatePaymentsUnresolved`, 0 by default. A packed position row grows by one byte (0 or 1; any other value is not a row), JNI carries it to and from `PrivateContactRequestsPositionFFI`, and the handler stores it with the position and hands it back at load. Co-Authored-By: Claude Opus 5.5 --- .../16.json | 13 +++++++++--- .../persistence/DashDatabaseMigrationTest.kt | 5 ++++- .../dashsdk/ffi/NativePersistenceBridge.kt | 11 +++++----- .../dashsdk/persistence/DashDatabase.kt | 1 + .../PlatformWalletPersistenceHandler.kt | 6 ++++++ .../PrivateContactScanStateEntities.kt | 9 ++++++++ .../PrivateContactScanStatePersistenceTest.kt | 16 +++++++++++++- .../rs-unified-sdk-jni/src/persistence.rs | 21 ++++++++++++++++--- 8 files changed, 69 insertions(+), 13 deletions(-) diff --git a/packages/kotlin-sdk/sdk/schemas/org.dashfoundation.dashsdk.persistence.DashDatabase/16.json b/packages/kotlin-sdk/sdk/schemas/org.dashfoundation.dashsdk.persistence.DashDatabase/16.json index 8fc2d4e5725..fcb3c307fa0 100644 --- a/packages/kotlin-sdk/sdk/schemas/org.dashfoundation.dashsdk.persistence.DashDatabase/16.json +++ b/packages/kotlin-sdk/sdk/schemas/org.dashfoundation.dashsdk.persistence.DashDatabase/16.json @@ -2,7 +2,7 @@ "formatVersion": 1, "database": { "version": 16, - "identityHash": "cfad080e6fde9c3bc142ef0829b3af4b", + "identityHash": "df09e705e8b63a04af564a1257b4a2cd", "entities": [ { "tableName": "wallets", @@ -4195,7 +4195,7 @@ }, { "tableName": "private_contact_requests_positions", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`walletId` BLOB NOT NULL, `identityId` BLOB NOT NULL, `position` INTEGER NOT NULL, `keyFingerprint` BLOB NOT NULL, PRIMARY KEY(`walletId`, `identityId`))", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`walletId` BLOB NOT NULL, `identityId` BLOB NOT NULL, `position` INTEGER NOT NULL, `keyFingerprint` BLOB NOT NULL, `privatePaymentsUnresolved` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`walletId`, `identityId`))", "fields": [ { "fieldPath": "walletId", @@ -4220,6 +4220,13 @@ "columnName": "keyFingerprint", "affinity": "BLOB", "notNull": true + }, + { + "fieldPath": "privatePaymentsUnresolved", + "columnName": "privatePaymentsUnresolved", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" } ], "primaryKey": { @@ -4271,7 +4278,7 @@ ], "setupQueries": [ "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", - "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'cfad080e6fde9c3bc142ef0829b3af4b')" + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'df09e705e8b63a04af564a1257b4a2cd')" ] } } \ No newline at end of file diff --git a/packages/kotlin-sdk/sdk/src/androidTest/kotlin/org/dashfoundation/dashsdk/persistence/DashDatabaseMigrationTest.kt b/packages/kotlin-sdk/sdk/src/androidTest/kotlin/org/dashfoundation/dashsdk/persistence/DashDatabaseMigrationTest.kt index 7fb787bbf26..19797f2678a 100644 --- a/packages/kotlin-sdk/sdk/src/androidTest/kotlin/org/dashfoundation/dashsdk/persistence/DashDatabaseMigrationTest.kt +++ b/packages/kotlin-sdk/sdk/src/androidTest/kotlin/org/dashfoundation/dashsdk/persistence/DashDatabaseMigrationTest.kt @@ -725,10 +725,13 @@ class DashDatabaseMigrationTest { "INSERT INTO private_shielded_addresses (walletId, ownerId, contactId, address) " + "VALUES (x'01', x'0A', x'0B', x'09')", ) - db.query("SELECT position, keyFingerprint FROM private_contact_requests_positions").use { c -> + db.query( + "SELECT position, keyFingerprint, privatePaymentsUnresolved FROM private_contact_requests_positions", + ).use { c -> assertTrue(c.moveToFirst()) assertEquals("the u64 bit pattern survives", -1L, c.getLong(0)) assertTrue(byteArrayOf(3).contentEquals(c.getBlob(1))) + assertEquals("the unresolved marker defaults to clear", 0, c.getInt(2)) } db.query("SELECT address FROM private_shielded_addresses WHERE contactId = x'0B'").use { c -> assertTrue(c.moveToFirst()) diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/NativePersistenceBridge.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/NativePersistenceBridge.kt index f55707de2b9..8f81f004a4e 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/NativePersistenceBridge.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/NativePersistenceBridge.kt @@ -111,12 +111,13 @@ abstract class NativePersistenceBridge { /** * Packed row widths of [onPersistPrivateContactScanState] and * [onLoadPrivateContactScanState]: a read position is the identity - * id (32), the position (u64, little-endian) and the key - * fingerprint (32); a shielded address the owner id (32), the - * contact id (32) and the address (43); an address removal the - * owner and contact ids; a cleared identity its id. + * id (32), the position (u64, little-endian), the key fingerprint + * (32) and the unresolved private payment marker (1: 0 or 1); a + * shielded address the owner id (32), the contact id (32) and the + * address (43); an address removal the owner and contact ids; a + * cleared identity its id. */ - const val PRIVATE_POSITION_ENTRY_SIZE: Int = 32 + 8 + 32 + const val PRIVATE_POSITION_ENTRY_SIZE: Int = 32 + 8 + 32 + 1 const val PRIVATE_ADDRESS_ENTRY_SIZE: Int = 32 + 32 + 43 const val PRIVATE_ADDRESS_REMOVAL_ENTRY_SIZE: Int = 32 + 32 const val PRIVATE_CLEARED_IDENTITY_ENTRY_SIZE: Int = 32 diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/DashDatabase.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/DashDatabase.kt index 5ef6dfb6a7c..0b3445a5397 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/DashDatabase.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/DashDatabase.kt @@ -740,6 +740,7 @@ abstract class DashDatabase : RoomDatabase() { "CREATE TABLE IF NOT EXISTS `private_contact_requests_positions` (" + "`walletId` BLOB NOT NULL, `identityId` BLOB NOT NULL, " + "`position` INTEGER NOT NULL, `keyFingerprint` BLOB NOT NULL, " + + "`privatePaymentsUnresolved` INTEGER NOT NULL DEFAULT 0, " + "PRIMARY KEY(`walletId`, `identityId`))", ) db.execSQL( diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandler.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandler.kt index 5703d67f6d4..9a83af98e4d 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandler.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandler.kt @@ -4627,6 +4627,11 @@ internal fun unpackPrivatePositions( position = java.nio.ByteBuffer.wrap(row, 32, 8) .order(java.nio.ByteOrder.LITTLE_ENDIAN).long, keyFingerprint = row.copyOfRange(40, 72), + privatePaymentsUnresolved = when (row[72].toInt()) { + 0 -> false + 1 -> true + else -> throw IllegalArgumentException("the unresolved private payment marker must be 0 or 1") + }, ) } @@ -4660,6 +4665,7 @@ internal fun packPrivatePositions(rows: List Vec packed.extend_from_slice(&row.identity_id); packed.extend_from_slice(&row.position.to_le_bytes()); packed.extend_from_slice(&row.key_fingerprint); + packed.push(u8::from(row.private_payments_unresolved)); } packed } @@ -1164,7 +1166,12 @@ fn unpack_private_positions(packed: &[u8]) -> Option false, + 1 => true, + _ => return None, + }, }) }) .collect() @@ -5454,11 +5461,13 @@ mod tests { identity_id: [7; 32], position: u64::MAX, key_fingerprint: [3; 32], + private_payments_unresolved: true, }, PrivateContactRequestsPositionFFI { identity_id: [8; 32], position: 12, key_fingerprint: [4; 32], + private_payments_unresolved: false, }, ]; let addresses = [PrivateShieldedAddressFFI { @@ -5480,6 +5489,12 @@ mod tests { ); assert_eq!(unpack_private_positions(&[]).as_deref(), Some(&[][..])); assert!(unpack_private_positions(&[0; PRIVATE_POSITION_ENTRY_LEN + 1]).is_none()); + let mut bad_marker = pack_private_positions(&positions[..1]); + bad_marker[72] = 2; + assert!( + unpack_private_positions(&bad_marker).is_none(), + "a marker byte other than 0 or 1 is not a row" + ); assert!(unpack_private_addresses(&[0; PRIVATE_ADDRESS_ENTRY_LEN - 1]).is_none()); } From ab7751098c01572e064042d1b7eb6f8cb7b193a2 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 9 Nov 2026 14:29:56 +0700 Subject: [PATCH 150/269] feat(kotlin-sdk,unified-sdk-jni): type code 72 and read the wallet's private contact readiness `DashSdkError.PlatformWallet.PrivateContactPaymentsNotRestored` binds `ErrorPrivateContactPaymentsNotRestored` (72), apart from a contact's pending request (71). `Dashpay.privateContactReadiness` binds `platform_wallet_get_private_contact_readiness` through a new JNI export returning its three flags. Port of the Swift bindings on #5812. Co-Authored-By: Claude Opus 5.5 --- .../dashsdk/errors/DashSdkError.kt | 14 ++++ .../dashsdk/ffi/DashpayNative.kt | 11 +++ .../dashfoundation/dashsdk/tokens/Dashpay.kt | 52 +++++++++++++ .../dashsdk/errors/DashSdkErrorTest.kt | 11 +++ .../ERROR_CODE_REGISTRY.md | 2 +- packages/rs-unified-sdk-jni/src/dashpay.rs | 74 ++++++++++++++++++- 6 files changed, 162 insertions(+), 2 deletions(-) diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/errors/DashSdkError.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/errors/DashSdkError.kt index e8dca35cecb..dfd4d170ca8 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/errors/DashSdkError.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/errors/DashSdkError.kt @@ -700,6 +700,19 @@ sealed class DashSdkError( class PrivateContactRequestPending(message: String, cause: Throwable? = null) : PlatformWallet(message, cause) + /** + * `ErrorPrivateContactPaymentsNotRestored` (native code 72). The + * wallet, not one contact, is held: its private payments from an + * earlier process are not restored from the shielded store yet, so + * no public contact request is sent or accepted, nothing signed or + * broadcast. `PlatformWalletManager.configureShielded` or binding + * the wallet's shielded account restores them; a private sync does + * not. `Dashpay.privateContactReadiness` reads whether they are. + * ← Swift `PlatformWalletError.privateContactPaymentsNotRestored`. + */ + class PrivateContactPaymentsNotRestored(message: String, cause: Throwable? = null) : + PlatformWallet(message, cause) + /** * Any other `PlatformWalletFFIResultCode` without a dedicated type. * Carries the platform-wallet [nativeCode] (already de-offset) and @@ -902,6 +915,7 @@ sealed class DashSdkError( 69 -> PlatformWallet.ShieldedBuildRequired(message, cause) // ErrorShieldedBuildRequired 70 -> PlatformWallet.ProtocolVersionNotActive(message, cause) // ErrorProtocolVersionNotActive 71 -> PlatformWallet.PrivateContactRequestPending(message, cause) // ErrorPrivateContactRequestPending + 72 -> PlatformWallet.PrivateContactPaymentsNotRestored(message, cause) // ErrorPrivateContactPaymentsNotRestored else -> // @Deprecated fallback — see the code-6 arm; code 31 is the // real discriminator. diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/DashpayNative.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/DashpayNative.kt index 90583e0f03f..229eb35db2f 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/DashpayNative.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/DashpayNative.kt @@ -345,6 +345,17 @@ internal object DashpayNative { */ external fun managedIdentityPrivateReplyPending(identityHandle: Long, contactId: ByteArray): Boolean + /** + * What a contact request from [identityId], one of the wallet's + * identities, waits for, as the wallet decides it: three flags, + * `[privatePaymentsRestored, privateStoreRead, unresolvedPrivatePayment]` + * (`platform_wallet_get_private_contact_readiness`). An unknown wallet + * handle throws (NotFound), as does an identity the wallet does not + * hold. + * ← Swift `ManagedPlatformWallet.privateContactReadiness(identityId:)`. + */ + external fun privateContactReadiness(walletHandle: Long, identityId: ByteArray): BooleanArray? + /** * Accept the incoming contact request [requestHandle] replying the way * it came: privately, paid from [account]'s shielded notes, when it came diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/tokens/Dashpay.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/tokens/Dashpay.kt index fcc43fb9c49..4686b9f5458 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/tokens/Dashpay.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/tokens/Dashpay.kt @@ -326,6 +326,38 @@ class Dashpay internal constructor(private val walletHandle: Long, } } + /** + * What a contact request from one of the wallet's identities waits for, + * as the wallet decides it, so a host shows "restoring private payments" + * apart from "reading the private store" and from a contact's pending + * request rather than inferring them from the refusals it got. + * ← Swift `PrivateContactReadiness`. + */ + data class PrivateContactReadiness( + /** + * Whether the wallet's private payments from an earlier process are + * restored for the identity. While false, its public sends and + * accepts are refused with + * [org.dashfoundation.dashsdk.errors.DashSdkError.PlatformWallet.PrivateContactPaymentsNotRestored] + * until the shielded store is configured or the wallet's shielded + * account bound; no private sync lifts it. + */ + val privatePaymentsRestored: Boolean, + /** + * 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 + * [org.dashfoundation.dashsdk.errors.DashSdkError.PlatformWallet.PrivateContactRequestPending] + * while that read cannot finish. + */ + val privateStoreRead: Boolean, + /** + * Whether a private contact request the identity paid for may still + * be unresolved. A contact's own pending request is [privateRepliesPending]. + */ + val unresolvedPrivatePayment: Boolean, + ) + /** What one pass over DashPay's private contact request store did. */ data class PrivateContactSyncResult( /** The requests the pass ingested, received and sent, including ones already held. */ @@ -413,6 +445,26 @@ class Dashpay internal constructor(private val walletHandle: Long, * ← Swift `ManagedIdentity.privateReplyPending(contactId:)` * (packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PrivateContactRequests.swift). */ + /** + * What a contact request from [ourIdentityId] waits for, as the wallet + * decides it ([PrivateContactReadiness]). A pure read of live wallet + * state; read it again after the shielded store is configured or the + * wallet binds its shielded account to see the restore. Throws for an + * identity the wallet does not hold. + * ← Swift `ManagedPlatformWallet.privateContactReadiness(identityId:)`. + */ + suspend fun privateContactReadiness(ourIdentityId: ByteArray): PrivateContactReadiness = + withContext(Dispatchers.IO) { + val flags = mapNativeErrors { DashpayNative.privateContactReadiness(walletHandle, ourIdentityId) } + checkNotNull(flags) { "native privateContactReadiness returned no flags" } + check(flags.size == 3) { "expected 3 readiness flags, got ${flags.size}" } + PrivateContactReadiness( + privatePaymentsRestored = flags[0], + privateStoreRead = flags[1], + unresolvedPrivatePayment = flags[2], + ) + } + suspend fun privateRepliesPending( ourIdentityId: ByteArray, contactIds: List, diff --git a/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/errors/DashSdkErrorTest.kt b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/errors/DashSdkErrorTest.kt index a4a39f7f994..66e08d7dcd7 100644 --- a/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/errors/DashSdkErrorTest.kt +++ b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/errors/DashSdkErrorTest.kt @@ -307,6 +307,17 @@ class DashSdkErrorTest { assertFalse("pending, not a failure to retry", mapped.isRetryable) } + /** Unrestored private payments hold the wallet: their own code, apart from 71. */ + @Test + fun shouldReportUnrestoredPrivatePaymentsByTheirOwnCode() { + val detail = "the wallet's private payments are not restored from the shielded store yet" + val native = DashSDKException(DashSdkError.PLATFORM_WALLET_CODE_OFFSET + 72, detail) + val mapped = DashSdkError.fromNative(native) + assertTrue(mapped is DashSdkError.PlatformWallet.PrivateContactPaymentsNotRestored) + assertEquals(detail, mapped.message) + assertFalse("held, not a failure to retry", mapped.isRetryable) + } + @Test fun platformShieldCapacityCode41MapsTyped() { val message = diff --git a/packages/rs-platform-wallet-ffi/ERROR_CODE_REGISTRY.md b/packages/rs-platform-wallet-ffi/ERROR_CODE_REGISTRY.md index 397aac4e358..dc292ed1afb 100644 --- a/packages/rs-platform-wallet-ffi/ERROR_CODE_REGISTRY.md +++ b/packages/rs-platform-wallet-ffi/ERROR_CODE_REGISTRY.md @@ -185,7 +185,7 @@ Fork-era numbers remain in the collision history, which is immutable record. | 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 | -| 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 types it (`PlatformWalletError.privateContactPaymentsNotRestored`, `ErrorHandlingTests` pin) and binds the readiness (`ManagedPlatformWallet.privateContactReadiness`); SwiftExampleApp shows "restoring private payments" apart from a pending request (71) until the readiness reads restored. Kotlin must type it, as for 71 | +| 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 types it (`PlatformWalletError.privateContactPaymentsNotRestored`, `ErrorHandlingTests` pin) and binds the readiness (`ManagedPlatformWallet.privateContactReadiness`); Kotlin types it (`DashSdkError.PlatformWallet.PrivateContactPaymentsNotRestored`, `DashSdkErrorTest` pin) and binds the readiness (`Dashpay.privateContactReadiness`); both example apps show "restoring private payments" apart from a pending request (71) 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-unified-sdk-jni/src/dashpay.rs b/packages/rs-unified-sdk-jni/src/dashpay.rs index e2d51979e07..8cc319d48c2 100644 --- a/packages/rs-unified-sdk-jni/src/dashpay.rs +++ b/packages/rs-unified-sdk-jni/src/dashpay.rs @@ -23,7 +23,9 @@ use crate::identity::ManagedIdentityHandleGuard; use crate::pubkey_rows::decode_registration_pubkeys_blob; use crate::support::{guard, take_pwffi_error, throw_sdk_exception}; use jni::objects::{JByteArray, JClass, JString, JValue}; -use jni::sys::{jboolean, jint, jlong, jlongArray, jobject, jstring, JNI_FALSE, JNI_TRUE}; +use jni::sys::{ + jboolean, jbooleanArray, jint, jlong, jlongArray, jobject, jstring, JNI_FALSE, JNI_TRUE, +}; use jni::JNIEnv; use platform_wallet_ffi::core_wallet_types::OutPointFFI; use platform_wallet_ffi::dashpay_profile::DashPayProfileFFI; @@ -31,6 +33,7 @@ use platform_wallet_ffi::error::{platform_wallet_ffi_result_free, PlatformWallet use platform_wallet_ffi::handle::Handle; use platform_wallet_ffi::identity_registration_with_signer::IdentityPubkeyFFI; use platform_wallet_ffi::invitation::InvitationPreviewFFI; +use platform_wallet_ffi::PrivateContactReadinessFFI; use platform_wallet_ffi::{ ContactRequestHandleArray, PrivateContactFFI, PrivateContactSyncReportFFI, }; @@ -1411,6 +1414,61 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_DashpayNative_managed }) } +/// `DashpayNative.privateContactReadiness`: the wallet's readiness for +/// `identity_id`'s contact requests +/// (`platform_wallet_get_private_contact_readiness`) as three flags, +/// `[privatePaymentsRestored, privateStoreRead, unresolvedPrivatePayment]`. +/// Throws on an unknown wallet or identity, returning null. +#[no_mangle] +pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_DashpayNative_privateContactReadiness( + mut env: JNIEnv, + _class: JClass, + wallet_handle: jlong, + identity_id: JByteArray, +) -> jbooleanArray { + guard(&mut env, std::ptr::null_mut(), |env| { + let Some(id) = read_id32(env, &identity_id, "identityId") else { + return std::ptr::null_mut(); + }; + let mut readiness = PrivateContactReadinessFFI::default(); + let result = unsafe { + platform_wallet_ffi::platform_wallet_get_private_contact_readiness( + wallet_handle as Handle, + id.as_ptr(), + &mut readiness, + ) + }; + if take_pwffi_error(env, result) { + return std::ptr::null_mut(); + } + let flags = readiness_flags(&readiness); + let array = match env.new_boolean_array(flags.len() as i32) { + Ok(array) => array, + Err(_) => { + let _ = env.exception_clear(); + throw_sdk_exception(env, 99, "allocating the readiness flags failed"); + return std::ptr::null_mut(); + } + }; + if env.set_boolean_array_region(&array, 0, &flags).is_err() { + let _ = env.exception_clear(); + throw_sdk_exception(env, 99, "writing the readiness flags failed"); + return std::ptr::null_mut(); + } + array.into_raw() + }) +} + +/// The readiness flags in the order Kotlin reads them. +fn readiness_flags(readiness: &PrivateContactReadinessFFI) -> [jboolean; 3] { + let flag = |set: bool| if set { JNI_TRUE } else { JNI_FALSE }; + [ + flag(readiness.private_payments_restored), + flag(readiness.private_store_read), + flag(readiness.unresolved_private_payment), + ] +} + /// Copy an optional Java `byte[]`; JVM null is `None`. Throws and returns /// `Err` on a JNI error. `field` names the argument in the message. fn read_opt_bytes(env: &mut JNIEnv, arr: &JByteArray, field: &str) -> Result>, ()> { @@ -1703,6 +1761,20 @@ mod tests { assert_eq!(private_contact_sync_longs(0, &incomplete)[5], 0); } + #[test] + fn should_lay_out_the_readiness_flags_in_the_order_kotlin_reads() { + let readiness = PrivateContactReadinessFFI { + private_payments_restored: true, + private_store_read: false, + unresolved_private_payment: true, + }; + assert_eq!(readiness_flags(&readiness), [JNI_TRUE, JNI_FALSE, JNI_TRUE]); + assert_eq!( + readiness_flags(&PrivateContactReadinessFFI::default()), + [JNI_FALSE; 3] + ); + } + #[test] fn should_saturate_a_store_position_past_the_long_range() { let report = PrivateContactSyncReportFFI { From c67ac29a993e9a798833a1f74516c4dca3a7eca2 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 9 Nov 2026 14:29:56 +0700 Subject: [PATCH 151/269] fix(kotlin-example-app): show restoring private payments from the wallet's readiness The screens cleared every code 71 refusal once a private sync finished, though a sync releases neither a contact's reservation nor the wallet's unrestored payments. They now render the wallet's own state: - code 72 shows "restoring private payments" for every contact, apart from a pending request (71), and only a readiness read that says restored lifts it; a private sync is not offered for it; - a code 71 refusal stays pending until the readiness says the private store was read to its end, after which the pending read decides; - the readiness is read with the pending state and again when the shielded account binds. Port of the Swift change on #5812. Co-Authored-By: Claude Opus 5.5 --- .../example/ui/dashpay/AddContactScreen.kt | 41 +++++++--- .../ui/dashpay/ContactRequestOutcomes.kt | 80 ++++++++++++++++--- .../ui/dashpay/ContactRequestsScreen.kt | 21 +++-- .../example/ui/dashpay/DashPaySyncHelpers.kt | 20 +++++ .../ui/dashpay/ContactRequestOutcomesTest.kt | 67 +++++++++++++++- 5 files changed, 194 insertions(+), 35 deletions(-) diff --git a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/AddContactScreen.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/AddContactScreen.kt index e051ae7bc7a..a7a72bc985c 100644 --- a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/AddContactScreen.kt +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/AddContactScreen.kt @@ -122,7 +122,11 @@ fun AddContactScreen(identityIdHex: String, navController: NavHostController) { val canSend = resolvedRecipient != null && !recipientIsSelf && !isSending && unconfirmedNotice == null && recipientPending.allowsSubmission(resolvedRecipient.toHex()) val recipientBlocked = resolvedRecipient != null && - (recipientPending.isPending(resolvedRecipient.toHex()) || recipientPending.isUnavailable) + ( + recipientPending.isRestoringPrivatePayments || + recipientPending.isPending(resolvedRecipient.toHex()) || + recipientPending.isUnavailable + ) // Scan-to-send: consume the QR string handed back by the scanner screen // (the house pattern — observe the saved-state flow, then null it out). @@ -188,7 +192,12 @@ fun AddContactScreen(identityIdHex: String, navController: NavHostController) { recipientPending = recipientPending.recorded( privateRepliesPending(wallet, idBytes, contactIds), contactIds.map { it.toHex() }, - ) + ).withReadiness(privateContactReadiness(wallet, idBytes)) + } + // Binding the shielded account restores the wallet's private payments: + // read the readiness again then. + LaunchedEffect(container.shieldedService) { + container.shieldedService.isBound.collect { pendingReadVersion++ } } LaunchedEffect(recipientHex) { val recipient = resolvedRecipient @@ -232,6 +241,10 @@ fun AddContactScreen(identityIdHex: String, navController: NavHostController) { // private sync that settles it. ContactRequestSubmissionOutcome.PrivateRequestPending -> recipientPending = recipientPending.withRefusal(recipient.toHex()) + // Nothing was sent: the screen shows the wallet restoring + // its private payments until its readiness says restored. + ContactRequestSubmissionOutcome.PrivatePaymentsNotRestored -> + recipientPending = recipientPending.withRestoringRefusal() ContactRequestSubmissionOutcome.MayHaveGoneThrough -> { kickDashPaySync(scope, m) unconfirmedNotice = ContactRequestSubmissionOutcome.MAY_HAVE_GONE_THROUGH_MESSAGE @@ -256,12 +269,7 @@ fun AddContactScreen(identityIdHex: String, navController: NavHostController) { isSyncingPrivate = true scope.launch { try { - val notice = syncPrivateContactRequests(w, m) - if (notice == null) { - recipientPending = recipientPending.afterPrivateSync() - } else { - errorMessage = notice - } + syncPrivateContactRequests(w, m)?.let { errorMessage = it } } finally { isSyncingPrivate = false pendingReadVersion++ @@ -301,6 +309,8 @@ fun AddContactScreen(identityIdHex: String, navController: NavHostController) { // Nothing was sent: the screen offers the private sync. ContactRequestSubmissionOutcome.PrivateRequestPending -> recipientPending = recipientPending.withRefusal(recipient.toHex()) + ContactRequestSubmissionOutcome.PrivatePaymentsNotRestored -> + recipientPending = recipientPending.withRestoringRefusal() is ContactRequestSubmissionOutcome.Failed -> errorMessage = "Accept failed: ${outcome.message}" } @@ -410,17 +420,22 @@ fun AddContactScreen(identityIdHex: String, navController: NavHostController) { Text( ContactRequestSubmissionOutcome.rowMessage( pending = recipientPending.isPending(recipient.toHex()), + restoring = recipientPending.isRestoringPrivatePayments, unavailable = recipientPending.isUnavailable, error = null, ).orEmpty(), style = MaterialTheme.typography.bodySmall, modifier = Modifier.testTag("dashpay.addContact.pending"), ) - TextButton( - onClick = { syncPrivateRequests() }, - enabled = !isSyncingPrivate, - modifier = Modifier.testTag("dashpay.addContact.syncPrivate"), - ) { Text(if (isSyncingPrivate) "Syncing private requests…" else "Sync private requests") } + // A private sync settles a pending request; it does not + // restore private payments, so it is not offered for that. + if (!recipientPending.isRestoringPrivatePayments) { + TextButton( + onClick = { syncPrivateRequests() }, + enabled = !isSyncingPrivate, + modifier = Modifier.testTag("dashpay.addContact.syncPrivate"), + ) { Text(if (isSyncingPrivate) "Syncing private requests…" else "Sync private requests") } + } } } diff --git a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/ContactRequestOutcomes.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/ContactRequestOutcomes.kt index b89bb838a35..b6e634b8617 100644 --- a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/ContactRequestOutcomes.kt +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/ContactRequestOutcomes.kt @@ -1,6 +1,7 @@ package org.dashfoundation.example.ui.dashpay import org.dashfoundation.dashsdk.errors.DashSdkError +import org.dashfoundation.dashsdk.tokens.Dashpay /** * How a contact request send or accept that threw ended, for the DashPay @@ -11,8 +12,10 @@ import org.dashfoundation.dashsdk.errors.DashSdkError * as pending (one to the same contact in flight, or paid and not yet read * back), which a public send or accept can also meet, sent nothing new: the * screen shows it as pending and offers a private sync, which settles it, - * after which the user may try again. Every other error is an ordinary - * failure the user may retry. + * after which the user may try again. Unrestored private payments (code 72) + * hold the wallet, not one contact: the screen shows "restoring private + * payments" until the wallet's readiness reads restored; no private sync + * lifts it. Every other error is an ordinary failure the user may retry. * ← Swift `ContactRequestSubmissionOutcome` (`ContactRequestOutcomes.swift`). */ sealed interface ContactRequestSubmissionOutcome { @@ -22,6 +25,9 @@ sealed interface ContactRequestSubmissionOutcome { /** A private request to the contact is pending in the wallet; nothing was sent. */ data object PrivateRequestPending : ContactRequestSubmissionOutcome + /** The wallet's private payments are not restored yet; nothing was sent. */ + data object PrivatePaymentsNotRestored : ContactRequestSubmissionOutcome + /** It failed; the message is the error's. */ data class Failed(val message: String) : ContactRequestSubmissionOutcome @@ -33,19 +39,30 @@ sealed interface ContactRequestSubmissionOutcome { "pending. Sync private requests to settle it, then try again if it did not " + "go through." + const val RESTORING_PRIVATE_PAYMENTS_MESSAGE = "Restoring this wallet's private " + + "payments. Contact requests wait until they are restored." + fun of(error: Throwable): ContactRequestSubmissionOutcome = when (error) { is DashSdkError.PlatformWallet.ShieldedSpendUnconfirmed -> MayHaveGoneThrough is DashSdkError.PlatformWallet.PrivateContactRequestPending -> PrivateRequestPending + is DashSdkError.PlatformWallet.PrivateContactPaymentsNotRestored -> PrivatePaymentsNotRestored else -> Failed(error.message ?: "unknown error") } /** - * The message a request row shows: the pending notice while the - * wallet has a private request to the contact pending, the - * unavailable notice while the wallet could not be read, whatever - * error an earlier attempt left, and that error otherwise. + * The message a request row shows: the restoring notice while the + * wallet's private payments are not restored, the pending notice + * while the wallet has a private request to the contact pending, the + * unavailable notice while the wallet could not be read, and + * whatever error an earlier attempt left otherwise. */ - fun rowMessage(pending: Boolean, unavailable: Boolean = false, error: String?): String? = when { + fun rowMessage( + pending: Boolean, + restoring: Boolean = false, + unavailable: Boolean = false, + error: String?, + ): String? = when { + restoring -> RESTORING_PRIVATE_PAYMENTS_MESSAGE pending -> PRIVATE_REQUEST_PENDING_MESSAGE unavailable -> PrivateRepliesPendingSnapshot.UNAVAILABLE_MESSAGE else -> error @@ -65,7 +82,15 @@ sealed interface ContactRequestSubmissionOutcome { * read: code 71 also means the private store could not be read to its end * before the first send or accept since load, with nothing reserved, so the * read may find nothing pending. That contact stays pending, with the - * private sync offered, until a private sync completes. + * private sync offered, until the wallet's readiness says the store was + * read to its end. + * + * Whether the wallet's private payments are restored is the wallet's + * readiness (`Dashpay.privateContactReadiness`), not anything the screens + * conclude: a code 72 refusal, or a readiness read that says not restored, + * shows "restoring private payments" for every contact, and only a + * readiness read that says restored lifts it. A private sync changes + * neither on its own. * ← Swift `PrivateRepliesPendingSnapshot` (`ContactRequestOutcomes.swift`). */ data class PrivateRepliesPendingSnapshot( @@ -75,8 +100,12 @@ data class PrivateRepliesPendingSnapshot( val covered: Set = emptySet(), /** False after a failed read, until a read succeeds. */ val available: Boolean = true, - /** Contacts a send or accept was refused for as pending since the last completed private sync. */ + /** Contacts a send or accept was refused for as pending (code 71), until the store is read to its end. */ val refused: Set = emptySet(), + /** The wallet's readiness for the identity, as last read; null until a read succeeds. */ + val readiness: Dashpay.PrivateContactReadiness? = null, + /** Set by a code 72 refusal; cleared only by a readiness read that says restored. */ + val restoringRefused: Boolean = false, ) { /** Whether the last read failed. */ val isUnavailable: Boolean get() = !available @@ -95,13 +124,38 @@ data class PrivateRepliesPendingSnapshot( /** This snapshot after a send or accept to [contactId] was refused as pending (code 71). */ fun withRefusal(contactId: String): PrivateRepliesPendingSnapshot = copy(refused = refused + contactId) - /** This snapshot after a completed private sync, which settles every refusal. */ - fun afterPrivateSync(): PrivateRepliesPendingSnapshot = copy(refused = emptySet()) + /** This snapshot after a send or accept refused because the private payments are not restored (code 72). */ + fun withRestoringRefusal(): PrivateRepliesPendingSnapshot = copy(restoringRefused = true) + + /** + * This snapshot after a read of the wallet's readiness, or null when it + * failed, which keeps what the last read said. A readiness that says + * restored lifts a code 72 refusal; one that says the private store was + * read to its end leaves a contact's pending state to the pending read. + */ + fun withReadiness(read: Dashpay.PrivateContactReadiness?): PrivateRepliesPendingSnapshot = + if (read == null) { + this + } else { + copy( + readiness = read, + restoringRefused = restoringRefused && !read.privatePaymentsRestored, + refused = if (read.privateStoreRead) emptySet() else refused, + ) + } + + /** + * Whether the wallet's private payments are being restored: a send or + * accept was refused with code 72, or the last readiness read says they + * are not restored. Every contact waits while they are. + */ + val isRestoringPrivatePayments: Boolean + get() = restoringRefused || readiness?.privatePaymentsRestored == false /** * Whether [contactId] is pending: the last successful read found a * private request to it, or a send or accept to it was refused as - * pending since the last completed private sync. + * pending and the store has not been read to its end since. */ fun isPending(contactId: String): Boolean = contactId in pending || contactId in refused @@ -110,7 +164,7 @@ data class PrivateRepliesPendingSnapshot( * read covered it and found nothing pending. */ fun allowsSubmission(contactId: String): Boolean = - available && contactId in covered && !isPending(contactId) + available && contactId in covered && !isPending(contactId) && !isRestoringPrivatePayments companion object { const val UNAVAILABLE_MESSAGE = "Could not read whether a private request to this " + diff --git a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/ContactRequestsScreen.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/ContactRequestsScreen.kt index ce8ec14b110..addfee36b81 100644 --- a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/ContactRequestsScreen.kt +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/ContactRequestsScreen.kt @@ -167,6 +167,12 @@ fun ContactRequestsScreen(identityIdHex: String, navController: NavHostControlle contactIds = contactIds, ) walletPending = walletPending.recorded(read, contactIds.map { it.toHex() }) + .withReadiness(privateContactReadiness(wallet, idBytes)) + } + // Binding the shielded account restores the wallet's private payments: + // read the readiness again then. + LaunchedEffect(container.shieldedService) { + container.shieldedService.isBound.collect { pendingReadVersion++ } } LaunchedEffect(manager) { manager?.dashPaySyncIsSyncing?.collect { syncing -> if (!syncing) pendingReadVersion++ } @@ -203,11 +209,16 @@ fun ContactRequestsScreen(identityIdHex: String, navController: NavHostControlle ContactRequestSubmissionOutcome.PrivateRequestPending -> { // Nothing was sent, and the wallet may hold nothing // for it (the store was not read to its end): the row - // stays pending until a private sync (pull to - // refresh) settles it. + // stays pending until the wallet's readiness says the + // store was read (pull to refresh reads it). walletPending = walletPending.withRefusal(hex) kickDashPaySync(scope, m) } + ContactRequestSubmissionOutcome.PrivatePaymentsNotRestored -> { + // The wallet is held until its private payments are + // restored; every row waits for the readiness to say so. + walletPending = walletPending.withRestoringRefusal() + } is ContactRequestSubmissionOutcome.Failed -> rowErrors = rowErrors + (hex to "Accept failed: ${outcome.message}") } @@ -254,10 +265,7 @@ fun ContactRequestsScreen(identityIdHex: String, navController: NavHostControlle isRefreshing = true manager?.let { m -> attachOrStartSync(m) - wallet?.let { - privateRefreshNotice = syncPrivateContactRequests(it, m) - if (privateRefreshNotice == null) walletPending = walletPending.afterPrivateSync() - } + wallet?.let { privateRefreshNotice = syncPrivateContactRequests(it, m) } pendingReadVersion++ } isRefreshing = false @@ -298,6 +306,7 @@ fun ContactRequestsScreen(identityIdHex: String, navController: NavHostControlle isInFlight = inFlightIds.contains(hex) || !walletPending.allowsSubmission(hex), errorMessage = ContactRequestSubmissionOutcome.rowMessage( pending = walletPending.isPending(hex), + restoring = walletPending.isRestoringPrivatePayments, unavailable = walletPending.isUnavailable, error = rowErrors[hex], ), diff --git a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/DashPaySyncHelpers.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/DashPaySyncHelpers.kt index 2b83428721e..4214772e305 100644 --- a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/DashPaySyncHelpers.kt +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/DashPaySyncHelpers.kt @@ -63,6 +63,26 @@ suspend fun privateRepliesPending( } } +/** + * The wallet's readiness for [ourIdentityId]'s contact requests, or null + * when it could not be read ([PrivateRepliesPendingSnapshot.withReadiness] + * keeps the last one then). + * ← Swift `privateContactReadiness(wallet:identityId:)` (`ContactsView.swift`). + */ +suspend fun privateContactReadiness( + wallet: ManagedPlatformWallet?, + ourIdentityId: ByteArray, +): org.dashfoundation.dashsdk.tokens.Dashpay.PrivateContactReadiness? { + if (wallet == null) return null + return try { + wallet.dashpay.privateContactReadiness(ourIdentityId) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + null + } +} + /** * Read DashPay's private contact request store (protocol version 15) for * [wallet], returning what the refresh should tell the user, or null. diff --git a/packages/kotlin-sdk/KotlinExampleApp/app/src/test/java/org/dashfoundation/example/ui/dashpay/ContactRequestOutcomesTest.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/test/java/org/dashfoundation/example/ui/dashpay/ContactRequestOutcomesTest.kt index f017c665153..a442884a53c 100644 --- a/packages/kotlin-sdk/KotlinExampleApp/app/src/test/java/org/dashfoundation/example/ui/dashpay/ContactRequestOutcomesTest.kt +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/test/java/org/dashfoundation/example/ui/dashpay/ContactRequestOutcomesTest.kt @@ -86,10 +86,10 @@ class ContactRequestOutcomesTest { * A send refused as pending (code 71) when the wallet reserved nothing * (its store was not read to its end): the following read finds nothing * pending, yet the contact stays pending, with the private sync offered, - * until a private sync completes. + * until the wallet's readiness says the store was read. */ @Test - fun shouldKeepARefusedContactPendingAfterAnEmptyReadUntilAPrivateSync() { + fun shouldKeepARefusedContactPendingUntilTheReadinessSaysTheStoreWasRead() { var snapshot = PrivateRepliesPendingSnapshot() .withRefusal("01") .recorded(emptySet(), listOf("01")) @@ -101,11 +101,72 @@ class ContactRequestOutcomesTest { ContactRequestSubmissionOutcome.rowMessage(pending = snapshot.isPending("01"), error = null), ) - snapshot = snapshot.afterPrivateSync().recorded(emptySet(), listOf("01")) + // A pending read that finds nothing does not lift it; the wallet's + // readiness saying the store was read to its end does. + snapshot = snapshot.recorded(emptySet(), listOf("01")) + assertTrue(snapshot.isPending("01")) + snapshot = snapshot.withReadiness(readiness(restored = true, storeRead = true)) assertFalse(snapshot.isPending("01")) assertTrue(snapshot.allowsSubmission("01")) } + private fun readiness(restored: Boolean, storeRead: Boolean, unresolved: Boolean = false) = + org.dashfoundation.dashsdk.tokens.Dashpay.PrivateContactReadiness( + privatePaymentsRestored = restored, + privateStoreRead = storeRead, + unresolvedPrivatePayment = unresolved, + ) + + /** Unrestored private payments (code 72) are their own outcome, apart from a pending request (71). */ + @Test + fun shouldTellUnrestoredPrivatePaymentsFromAPendingRequest() { + assertEquals( + ContactRequestSubmissionOutcome.PrivatePaymentsNotRestored, + ContactRequestSubmissionOutcome.of( + DashSdkError.PlatformWallet.PrivateContactPaymentsNotRestored("not restored"), + ), + ) + assertEquals( + ContactRequestSubmissionOutcome.RESTORING_PRIVATE_PAYMENTS_MESSAGE, + ContactRequestSubmissionOutcome.rowMessage(pending = true, restoring = true, error = null), + ) + } + + /** + * A code 72 hold survives a successful pending read that finds nothing, + * and a readiness read after a private sync that still says not + * restored; only a readiness read that says restored lifts it. + */ + @Test + fun shouldHoldOnCode72UntilTheReadinessSaysRestored() { + var snapshot = PrivateRepliesPendingSnapshot().withRestoringRefusal().recorded(emptySet(), listOf("01")) + assertTrue(snapshot.isRestoringPrivatePayments) + assertFalse(snapshot.allowsSubmission("01")) + + // A private sync read the store to its end; the payments are still not restored. + snapshot = snapshot.withReadiness(readiness(restored = false, storeRead = true, unresolved = true)) + assertTrue(snapshot.isRestoringPrivatePayments) + assertFalse(snapshot.allowsSubmission("01")) + + // A failed readiness read keeps what the last one said. + snapshot = snapshot.withReadiness(null) + assertTrue(snapshot.isRestoringPrivatePayments) + + snapshot = snapshot.withReadiness(readiness(restored = true, storeRead = true)) + assertFalse(snapshot.isRestoringPrivatePayments) + assertTrue(snapshot.allowsSubmission("01")) + } + + /** A readiness that says not restored holds every contact without any refusal. */ + @Test + fun shouldShowRestoringFromTheReadinessAlone() { + val snapshot = PrivateRepliesPendingSnapshot() + .recorded(emptySet(), listOf("01")) + .withReadiness(readiness(restored = false, storeRead = false)) + assertTrue(snapshot.isRestoringPrivatePayments) + assertFalse(snapshot.allowsSubmission("01")) + } + /** * A successful read that found nothing pending offers every contact it * covered, and nothing it did not. From c5ccf424e366aaa84c982ee5ada879bc8a401ef0 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 9 Nov 2026 14:29:56 +0700 Subject: [PATCH 152/269] test(kotlin-sdk): exercise the scan-state overrides and gate shielded-only probes The scan-state slot test supplied the override decision to `extension_for` itself. A new JNI hook, `nativeEffectivePersistenceCapabilityBits`, builds a bridge's vtable and extension as manager creation does and returns the effective capability bits, and an instrumented test declares the scan-state capability with each required override (contact upsert, scan-state store, scan-state load) left out in turn: each loses it, while a bridge with all three and the Room handler keep it. The private contact symbol probe now calls the explicit private send and private-reply accept only in a shielded build (`Sdk.hasShielded()`), as `build_android.sh --no-shielded` does not export them; the sync, the reads, the new readiness read and the in-kind accept are probed in every build. The syncFaultDetected KDoc moves back onto its own test. Co-Authored-By: Claude Opus 5.5 --- .../dashfoundation/dashsdk/FfiSmokeTest.kt | 215 ++++++++++++++++-- .../dashsdk/ffi/WalletManagerNative.kt | 10 + .../rs-unified-sdk-jni/src/wallet_manager.rs | 71 ++++++ 3 files changed, 276 insertions(+), 20 deletions(-) diff --git a/packages/kotlin-sdk/sdk/src/androidTest/kotlin/org/dashfoundation/dashsdk/FfiSmokeTest.kt b/packages/kotlin-sdk/sdk/src/androidTest/kotlin/org/dashfoundation/dashsdk/FfiSmokeTest.kt index 1285795cc2f..bc4c9c40391 100644 --- a/packages/kotlin-sdk/sdk/src/androidTest/kotlin/org/dashfoundation/dashsdk/FfiSmokeTest.kt +++ b/packages/kotlin-sdk/sdk/src/androidTest/kotlin/org/dashfoundation/dashsdk/FfiSmokeTest.kt @@ -5,6 +5,7 @@ import androidx.test.ext.junit.runners.AndroidJUnit4 import kotlinx.coroutines.Dispatchers import org.dashfoundation.dashsdk.ffi.DashSDKException import org.dashfoundation.dashsdk.ffi.DashpayNative +import org.dashfoundation.dashsdk.ffi.NativePersistenceBridge import org.dashfoundation.dashsdk.ffi.NativeLoader import org.dashfoundation.dashsdk.ffi.SdkNative import org.dashfoundation.dashsdk.ffi.SignerNative @@ -12,6 +13,7 @@ import org.dashfoundation.dashsdk.ffi.WalletManagerNative import org.dashfoundation.dashsdk.persistence.DashDatabase import org.dashfoundation.dashsdk.persistence.PlatformWalletPersistenceHandler import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse import org.junit.Assert.assertNotEquals import org.junit.Assert.assertNotNull import org.junit.Assert.assertNull @@ -88,22 +90,17 @@ class FfiSmokeTest { } /** - * The #4069 watermark-freeze fault latch surfaced by PR #4314: the - * `syncFaultDetected` JNI symbol binds, and an invalid manager handle - * surfaces as the mapped [DashSDKException] rather than a crash. The - * latch behavior itself (freeze on dropped events / rejected store) is - * covered by the shared Rust tests in - * `packages/rs-platform-wallet/src/changeset/core_bridge.rs`. - */ - /** - * DashPay private contact requests (protocol version 15): the six + * DashPay private contact requests (protocol version 15): the * `DashpayNative` symbols bind with the descriptors Kotlin declares, and * bad input surfaces as a mapped [DashSDKException] rather than a crash. * A null resolver is refused before any wallet lookup, and a shielded * address that is not 43 bytes never reaches the FFI (which reads - * exactly 43). The flows themselves need a protocol version 15 network - * with shielded funds; their Rust side is covered by the - * platform-wallet and platform-wallet-ffi tests. + * exactly 43). The explicit private send and private-reply accept are + * exported only by a shielded build, so they are probed only there; the + * sync, the reads and the in-kind accept are exported by every build. + * The flows themselves need a protocol version 15 network with shielded + * funds; their Rust side is covered by the platform-wallet and + * platform-wallet-ffi tests. */ @Test fun privateContactRequestSymbolsBindAndRejectInvalidInput() { @@ -111,16 +108,22 @@ class FfiSmokeTest { assertThrows(DashSDKException::class.java) { DashpayNative.syncPrivateContactRequests(0L, 0L, 1) } - assertThrows(DashSDKException::class.java) { - DashpayNative.sendPrivateContactRequest( - 0L, ByteArray(32), 0L, 0, ByteArray(32), ByteArray(32), - null, null, null, 0L, - ) + if (Sdk.hasShielded()) { + assertThrows(DashSDKException::class.java) { + DashpayNative.sendPrivateContactRequest( + 0L, ByteArray(32), 0L, 0, ByteArray(32), ByteArray(32), + null, null, null, 0L, + ) + } + assertThrows(DashSDKException::class.java) { + DashpayNative.acceptPrivateContactRequest( + 0L, ByteArray(32), 0L, 0, 0L, ByteArray(42), 0L, + ) + } } + // An unknown wallet handle is NotFound. assertThrows(DashSDKException::class.java) { - DashpayNative.acceptPrivateContactRequest( - 0L, ByteArray(32), 0L, 0, 0L, ByteArray(42), 0L, - ) + DashpayNative.privateContactReadiness(0L, ByteArray(32)) } assertThrows(DashSDKException::class.java) { DashpayNative.acceptContactRequestReplyingInKind( @@ -135,6 +138,14 @@ class FfiSmokeTest { } } + /** + * The #4069 watermark-freeze fault latch surfaced by PR #4314: the + * `syncFaultDetected` JNI symbol binds, and an invalid manager handle + * surfaces as the mapped [DashSDKException] rather than a crash. The + * latch behavior itself (freeze on dropped events / rejected store) is + * covered by the shared Rust tests in + * `packages/rs-platform-wallet/src/changeset/core_bridge.rs`. + */ @Test fun syncFaultDetectedSymbolBindsAndRejectsInvalidHandle() { NativeLoader.ensureLoaded() @@ -150,6 +161,44 @@ class FfiSmokeTest { * live, funded `create_invitation`. Resolving the whole table up front * turns a descriptor typo into a CI failure instead. */ + /** + * The private contact request scan state needs every override it + * depends on: JNI wires its slots only for a bridge that overrides the + * contact upsert native calls, the scan-state store and the scan-state + * load (a read position vouches for the requests an inherited no-op + * contact writer never stored). A bridge declaring the capability but + * leaving any one of them inherited is not attested; one implementing + * all three, and the Room handler, keep it. + */ + @Test + fun privateContactScanStateCapabilityNeedsEveryOverride() { + NativeLoader.ensureLoaded() + val bit = NativePersistenceBridge.CAPABILITY_PRIVATE_CONTACT_REQUEST_SCAN_STATE + fun effective(bridge: NativePersistenceBridge): Boolean = + WalletManagerNative.nativeEffectivePersistenceCapabilityBits(bridge) and bit != 0L + + assertTrue("every override", effective(ScanStateBridge.Full())) + assertFalse("no contact upsert", effective(ScanStateBridge.WithoutContactUpsert())) + assertFalse("no scan-state store", effective(ScanStateBridge.WithoutScanStateStore())) + assertFalse("no scan-state load", effective(ScanStateBridge.WithoutScanStateLoad())) + assertFalse( + "every override, not declared", + effective(object : ScanStateBridge.Full() { + override fun persistenceCapabilitiesBits(): Long = 0L + }), + ) + + val db = DashDatabase.createInMemory(ApplicationProvider.getApplicationContext()) + try { + assertTrue( + "the Room handler", + effective(PlatformWalletPersistenceHandler(db, Dispatchers.Unconfined)), + ) + } finally { + db.close() + } + } + @Test fun persistenceBridgeDescriptorsAllResolve() { NativeLoader.ensureLoaded() @@ -168,3 +217,129 @@ class FfiSmokeTest { } } } + +/** + * Bridges declaring the private contact request scan state with some of the + * overrides it depends on, for + * [FfiSmokeTest.privateContactScanStateCapabilityNeedsEveryOverride]. Each + * concrete class declares exactly the overrides its name says. + */ +private abstract class ScanStateBridge : NativePersistenceBridge() { + override fun persistenceCapabilitiesVersion(): Int = 1 + + override fun persistenceCapabilitiesBits(): Long = + CAPABILITY_PRIVATE_CONTACT_REQUEST_SCAN_STATE + + open class Full : ScanStateBridge() { + @Suppress("LongParameterList") + override fun onPersistContactUpsert( + walletId: ByteArray, + ownerId: ByteArray, + contactId: ByteArray, + isOutgoing: Boolean, + senderKeyIndex: Int, + recipientKeyIndex: Int, + accountReference: Int, + encryptedPublicKey: ByteArray, + encryptedAccountLabel: ByteArray?, + autoAcceptProof: ByteArray?, + coreHeightCreatedAt: Int, + createdAt: Long, + paymentChannelBroken: Boolean, + alias: String?, + note: String?, + isHidden: Boolean, + contactAccountLabel: String?, + acceptedAccounts: IntArray, + hasExternalAccountReference: Boolean, + externalAccountReference: Int, + ): Int = 0 + + override fun onPersistPrivateContactScanState( + walletId: ByteArray, + positions: ByteArray, + addresses: ByteArray, + removedAddresses: ByteArray, + clearedIdentities: ByteArray, + ): Int = 0 + + override fun onLoadPrivateContactScanState(walletId: ByteArray): Array = + arrayOf(ByteArray(0), ByteArray(0)) + } + + class WithoutContactUpsert : ScanStateBridge() { + override fun onPersistPrivateContactScanState( + walletId: ByteArray, + positions: ByteArray, + addresses: ByteArray, + removedAddresses: ByteArray, + clearedIdentities: ByteArray, + ): Int = 0 + + override fun onLoadPrivateContactScanState(walletId: ByteArray): Array = + arrayOf(ByteArray(0), ByteArray(0)) + } + + class WithoutScanStateStore : ScanStateBridge() { + @Suppress("LongParameterList") + override fun onPersistContactUpsert( + walletId: ByteArray, + ownerId: ByteArray, + contactId: ByteArray, + isOutgoing: Boolean, + senderKeyIndex: Int, + recipientKeyIndex: Int, + accountReference: Int, + encryptedPublicKey: ByteArray, + encryptedAccountLabel: ByteArray?, + autoAcceptProof: ByteArray?, + coreHeightCreatedAt: Int, + createdAt: Long, + paymentChannelBroken: Boolean, + alias: String?, + note: String?, + isHidden: Boolean, + contactAccountLabel: String?, + acceptedAccounts: IntArray, + hasExternalAccountReference: Boolean, + externalAccountReference: Int, + ): Int = 0 + + override fun onLoadPrivateContactScanState(walletId: ByteArray): Array = + arrayOf(ByteArray(0), ByteArray(0)) + } + + class WithoutScanStateLoad : ScanStateBridge() { + @Suppress("LongParameterList") + override fun onPersistContactUpsert( + walletId: ByteArray, + ownerId: ByteArray, + contactId: ByteArray, + isOutgoing: Boolean, + senderKeyIndex: Int, + recipientKeyIndex: Int, + accountReference: Int, + encryptedPublicKey: ByteArray, + encryptedAccountLabel: ByteArray?, + autoAcceptProof: ByteArray?, + coreHeightCreatedAt: Int, + createdAt: Long, + paymentChannelBroken: Boolean, + alias: String?, + note: String?, + isHidden: Boolean, + contactAccountLabel: String?, + acceptedAccounts: IntArray, + hasExternalAccountReference: Boolean, + externalAccountReference: Int, + ): Int = 0 + + override fun onPersistPrivateContactScanState( + walletId: ByteArray, + positions: ByteArray, + addresses: ByteArray, + removedAddresses: ByteArray, + clearedIdentities: ByteArray, + ): Int = 0 + } +} diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/WalletManagerNative.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/WalletManagerNative.kt index 58351c59fbb..1b887c5599b 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/WalletManagerNative.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/WalletManagerNative.kt @@ -63,6 +63,16 @@ internal object WalletManagerNative { bridge: NativePersistenceBridge, ): String? + /** + * The persistence capability bits a manager created with [bridge] would + * attest, without creating one: its declaration intersected with the + * callbacks JNI wires for it, built exactly as [nativeCreate] builds + * them (which methods the bridge overrides decides some of those). The + * instrumented suite checks with it that a capability needs every + * override it depends on. + */ + external fun nativeEffectivePersistenceCapabilityBits(bridge: NativePersistenceBridge): Long + // ── Wallet creation / restore ───────────────────────────────────── /** diff --git a/packages/rs-unified-sdk-jni/src/wallet_manager.rs b/packages/rs-unified-sdk-jni/src/wallet_manager.rs index 4dfc8cbadef..7fccc2d99e6 100644 --- a/packages/rs-unified-sdk-jni/src/wallet_manager.rs +++ b/packages/rs-unified-sdk-jni/src/wallet_manager.rs @@ -56,6 +56,7 @@ use platform_wallet_ffi::error::{ }; use platform_wallet_ffi::event_handler::{EventHandlerCallbacks, EventHandlerCallbacksExtension}; use platform_wallet_ffi::handle::Handle; +use platform_wallet_ffi::manager::effective_persistence_capabilities; use platform_wallet_ffi::persistence::{PersistenceCallbacks, PersistenceCapabilitiesFFI}; use platform_wallet_ffi::types::IdentifierArray; use std::ffi::{c_void, CStr}; @@ -211,6 +212,76 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_WalletManagerNative_n }) } +/// `WalletManagerNative.nativeEffectivePersistenceCapabilityBits`: the +/// persistence capabilities a manager created with `persistence_bridge` +/// would attest, without creating one. The declaration, the vtable, the +/// account-writer restriction and the extension (with its `bridge_overrides` +/// probes) are built exactly as `nativeCreate` builds them, so an +/// instrumented test can check which bridge overrides keep a capability. +/// Throws and returns 0 when the declaration cannot be read. +#[no_mangle] +pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_WalletManagerNative_nativeEffectivePersistenceCapabilityBits( + mut env: JNIEnv, + _class: JClass, + persistence_bridge: JObject, +) -> jlong { + guard(&mut env, 0, |env| { + let version = match env + .call_method( + &persistence_bridge, + "persistenceCapabilitiesVersion", + "()I", + &[], + ) + .and_then(|value| value.i()) + { + Ok(value) => value as u32, + Err(_) => { + let _ = env.exception_clear(); + throw_sdk_exception(env, 99, "reading persistence capability version failed"); + return 0; + } + }; + let bits = match env + .call_method( + &persistence_bridge, + "persistenceCapabilitiesBits", + "()J", + &[], + ) + .and_then(|value| value.j()) + { + Ok(value) => value as u64, + Err(_) => { + let _ = env.exception_clear(); + throw_sdk_exception(env, 99, "reading persistence capability mask failed"); + return 0; + } + }; + let global = match env.new_global_ref(&persistence_bridge) { + Ok(global) => global, + Err(_) => { + throw_sdk_exception(env, 99, "NewGlobalRef(persistence bridge) failed"); + return 0; + } + }; + let context = Box::into_raw(Box::new(KotlinPersistenceCtx::new(global))); + let mut persistence: PersistenceCallbacks = build_vtable(context as *mut c_void); + restrict_account_writers(env, &persistence_bridge, &mut persistence); + let extension = build_extension(env, &persistence_bridge); + let declared = PersistenceCapabilitiesFFI { + version, + reserved: 0, + bits, + }; + // SAFETY: `extension` is a live, fully sized extension; the vtable's + // `release_fn` frees `context` before this returns. + let effective = + unsafe { effective_persistence_capabilities(persistence, &declared, &extension) }; + effective.bits() as jlong + }) +} + /// The raw manager `Handle` for a bundle, as jlong (for the FFI sync / /// wallet-accessor calls). 0 if the bundle pointer is 0. #[no_mangle] From 7bc3a030da3e65f25ffd150dd03559a61503b446 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 9 Nov 2026 14:29:56 +0700 Subject: [PATCH 153/269] docs(sdk): record code 72 and the readiness getter in the parity manifest Co-Authored-By: Claude Opus 5.5 --- docs/sdk/sdk-parity-manifest.json | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/docs/sdk/sdk-parity-manifest.json b/docs/sdk/sdk-parity-manifest.json index 775e27c0987..37d0a2b510d 100644 --- a/docs/sdk/sdk-parity-manifest.json +++ b/docs/sdk/sdk-parity-manifest.json @@ -46,6 +46,7 @@ "platform_wallet_create_document_with_signer": "packages/rs-platform-wallet-ffi/src/document.rs", "platform_wallet_create_invitation": "packages/rs-platform-wallet-ffi/src/invitation.rs", "platform_wallet_drain_pending_contact_crypto": "packages/rs-platform-wallet-ffi/src/dashpay.rs", + "platform_wallet_get_private_contact_readiness": "packages/rs-platform-wallet-ffi/src/contact_request.rs", "platform_wallet_manager_accept_contact_request_replying_in_kind": "packages/rs-platform-wallet-ffi/src/private_contact_request.rs", "platform_wallet_manager_accept_private_contact_request": "packages/rs-platform-wallet-ffi/src/private_contact_request.rs", "platform_wallet_manager_create_with_persistence_capabilities": "packages/rs-platform-wallet-ffi/src/manager.rs", @@ -782,7 +783,7 @@ }, { "id": "dashpay.private_contact_requests", - "title": "DashPay private contact requests: store sync, shielded send, accept replying in kind, private-reply accept, private contact and pending reads, codes 68 to 71", + "title": "DashPay private contact requests: store sync, shielded send, accept replying in kind, private-reply accept, private contact and pending reads, codes 68 to 72, readiness", "area": "correctness", "shared_apis": [ "platform_wallet_sync_private_contact_requests", @@ -790,7 +791,8 @@ "platform_wallet_manager_accept_contact_request_replying_in_kind", "platform_wallet_manager_accept_private_contact_request", "managed_identity_get_private_contact", - "managed_identity_get_private_reply_pending" + "managed_identity_get_private_reply_pending", + "platform_wallet_get_private_contact_readiness" ], "required_persistence_capabilities": [ "atomic_changesets", @@ -801,13 +803,13 @@ "sdk": "partial", "example_app": "partial", "restart": "required", - "reason": "PrivateContactRequests.swift wraps the store sync, the shielded send, the accept replying in kind (the wallet replies privately to a request that came privately, and with a document otherwise; a public or adopted reply needs no shielded support or payment mnemonic read, and a build without shielded support refuses only a private reply, with code 69), the explicit private-reply accept, PrivateContactInfo and the wallet's pending-reply read, all signed with the identity signer the public calls take and counted as native manager operations; codes 68 to 71 are typed and pinned in ErrorHandlingTests. The sync, send and accepts have no automated or recorded gate: a private reply needs a protocol version 15 network with shielded funds. The private marker (core height 0) round-trips through the persisted coreHeightCreatedAt, but no test covers a restart, and the wallet keeps a pending reply in memory only. The example app has the send-privately toggle, accepts with acceptContactRequestReplyingInKind, keeps a pending or unconfirmed private send or reply pending by reading the wallet's flag (a failed read keeps the last snapshot and the controls off), offers a private sync for a pending request a public send or accept meets (code 71), and reports private refresh failures (quiet before protocol version 15), with no recorded gate." + "reason": "PrivateContactRequests.swift wraps the store sync, the shielded send, the accept replying in kind (the wallet replies privately to a request that came privately, and with a document otherwise; a public or adopted reply needs no shielded support or payment mnemonic read, and a build without shielded support refuses only a private reply, with code 69), the explicit private-reply accept, PrivateContactInfo and the wallet's pending-reply read, all signed with the identity signer the public calls take and counted as native manager operations; codes 68 to 72 are typed and pinned in ErrorHandlingTests. The sync, send and accepts have no automated or recorded gate: a private reply needs a protocol version 15 network with shielded funds. The private marker (core height 0) round-trips through the persisted coreHeightCreatedAt, but no test covers a restart, and the wallet keeps a pending reply in memory only. The example app has the send-privately toggle, accepts with acceptContactRequestReplyingInKind, keeps a pending or unconfirmed private send or reply pending by reading the wallet's flag (a failed read keeps the last snapshot and the controls off), offers a private sync for a pending request a public send or accept meets (code 71), and reports private refresh failures (quiet before protocol version 15), with no recorded gate. Code 72 (unrestored private payments) is typed apart from 71, and the readiness getter is bound; the example app shows \"restoring private payments\" from 72 or the readiness, lifted only by a readiness read that says restored, never by a private sync." }, "kotlin": { "sdk": "partial", "example_app": "partial", "restart": "required", - "reason": "Dashpay.syncPrivateContactRequests, privateContact and privateRepliesPending (fresh managed-identity snapshots) and PlatformWalletManager.sendPrivateContactRequest / acceptContactRequestReplyingInKind / acceptPrivateContactRequest bridge the same FFI (signed with the manager's identity signer, behind the manager's teardown gate); the in-kind accept is exported in every build, and codes 68 to 71 map to typed PlatformWallet errors (DashSdkErrorTest). JNI descriptors are covered only by the FfiSmokeTest symbol test; the flows need a protocol version 15 network with shielded funds and have no recorded gate, no test covers a restart, and the wallet keeps a pending reply in memory only. The example app accepts with acceptContactRequestReplyingInKind, keeps a pending or unconfirmed reply pending by reading the wallet's flag on its Requests rows and Add Contact (a failed read keeps the last snapshot and the controls off), offers a private sync for a pending request a public send or accept meets (code 71), and reports private refresh failures (quiet before protocol version 15); it has no send-privately toggle." + "reason": "Dashpay.syncPrivateContactRequests, privateContact and privateRepliesPending (fresh managed-identity snapshots) and PlatformWalletManager.sendPrivateContactRequest / acceptContactRequestReplyingInKind / acceptPrivateContactRequest bridge the same FFI (signed with the manager's identity signer, behind the manager's teardown gate); the in-kind accept is exported in every build, and codes 68 to 72 map to typed PlatformWallet errors (DashSdkErrorTest). JNI descriptors are covered only by the FfiSmokeTest symbol test; the flows need a protocol version 15 network with shielded funds and have no recorded gate, no test covers a restart, and the wallet keeps a pending reply in memory only. The example app accepts with acceptContactRequestReplyingInKind, keeps a pending or unconfirmed reply pending by reading the wallet's flag on its Requests rows and Add Contact (a failed read keeps the last snapshot and the controls off), offers a private sync for a pending request a public send or accept meets (code 71), and reports private refresh failures (quiet before protocol version 15); it has no send-privately toggle. Code 72 (unrestored private payments) is typed apart from 71, and the readiness getter is bound; the example app shows \"restoring private payments\" from 72 or the readiness, lifted only by a readiness read that says restored, never by a private sync." } }, "verification": [ From 3deebd50a5db7488f2047204948761d591f38045 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 9 Nov 2026 15:34:14 +0700 Subject: [PATCH 154/269] 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 155/269] 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 156/269] 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 5007b7b8509c1270bf8c2009a57cbf4e87bdddcb Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 9 Nov 2026 15:59:16 +0700 Subject: [PATCH 157/269] fix(platform-wallet-ffi): keep the private scan state only under the wallet's atomic rule The wallet keeps the private contact request scan state only with atomic changesets (`PRIVATE_CONTACT_SCAN_STATE_RESTART`): an un-ignore stores the sender's removal and the read-position rewind it brings in one changeset. The FFI already derives `ATOMIC_CHANGESETS` only from the declaration with the begin and end callbacks; its load now reads the scan state only for a host meeting the wallet's composite rule rather than bit 14 alone, so the decision stays the wallet's. Tests cover a host declaring both with no begin and end callbacks, and one not declaring atomic rounds: neither has the state read. Another covers a contact request the host cannot stage: the round carrying it and the read position fails and ends unsuccessfully. Co-Authored-By: Claude Opus 5.5 --- .../rs-platform-wallet-ffi/src/persistence.rs | 151 ++++++++++++++++-- 1 file changed, 137 insertions(+), 14 deletions(-) diff --git a/packages/rs-platform-wallet-ffi/src/persistence.rs b/packages/rs-platform-wallet-ffi/src/persistence.rs index aa70789d325..19ae046db96 100644 --- a/packages/rs-platform-wallet-ffi/src/persistence.rs +++ b/packages/rs-platform-wallet-ffi/src/persistence.rs @@ -170,7 +170,13 @@ pub const PLATFORM_WALLET_PERSISTENCE_CAPABILITY_ASSET_LOCK_OPERATION_IDS: u64 = /// `on_load_private_contact_scan_state_fn` + /// `on_load_private_contact_scan_state_free_fn`, the wallet-list restore /// callbacks, `on_persist_contacts_fn` (a read position vouches for the -/// contact requests read before it), and the host declaring the bit. +/// contact requests read before it), and the host declaring the bit. The +/// wallet keeps the state only together with +/// [`PLATFORM_WALLET_PERSISTENCE_CAPABILITY_ATOMIC_CHANGESETS`] +/// (`PersistenceCapabilities::PRIVATE_CONTACT_SCAN_STATE_RESTART`), which +/// needs the declaration and the changeset begin and end callbacks: an +/// un-ignore stores the sender's removal and the read-position rewind it +/// brings in one changeset. pub const PLATFORM_WALLET_PERSISTENCE_CAPABILITY_PRIVATE_CONTACT_REQUEST_SCAN_STATE: u64 = 1 << 14; /// Version of [`PersistenceCallbacksExtension`]. The extension is deliberately @@ -3642,11 +3648,13 @@ impl PlatformWalletPersistence for FFIPersister { self.tracked_masternodes_callbacks .load_private_contact_scan_state_free, ) { - // Read only for a host that attests the state: the manager - // drops any other host's copy at load. + // Read only for a host whose capabilities meet the wallet's + // rule for keeping the state (`PRIVATE_CONTACT_SCAN_STATE_RESTART`: + // the state with atomic changesets); the manager drops any + // other host's copy at load. if self .persistence_capabilities() - .contains(PersistenceCapabilities::PRIVATE_CONTACT_REQUEST_SCAN_STATE) + .contains(PersistenceCapabilities::PRIVATE_CONTACT_SCAN_STATE_RESTART) { load_private_contact_scan_state( load, @@ -8217,12 +8225,22 @@ mod tests { declared: PersistenceCapabilities, extensions: PersistenceExtensionCallbacks, persists_contacts: bool, + ) -> FFIPersister { + scan_state_persister_with(host, declared, extensions, persists_contacts, true) + } + + fn scan_state_persister_with( + host: &ScanStateHost, + declared: PersistenceCapabilities, + extensions: PersistenceExtensionCallbacks, + persists_contacts: bool, + brackets_rounds: bool, ) -> FFIPersister { FFIPersister::new_with_persistence_capabilities_and_extensions( PersistenceCallbacks { context: (host as *const ScanStateHost).cast_mut().cast(), - on_changeset_begin_fn: Some(noop_begin), - on_changeset_end_fn: Some(noop_end), + on_changeset_begin_fn: brackets_rounds.then_some(noop_begin), + on_changeset_end_fn: brackets_rounds.then_some(noop_end), on_load_wallet_list_fn: Some(load_one_identity), on_load_wallet_list_free_fn: Some(free_one_identity), on_persist_contacts_fn: persists_contacts.then_some(keep_scan_state_contacts), @@ -8257,7 +8275,7 @@ mod tests { ); assert!(persister .persistence_capabilities() - .contains(PersistenceCapabilities::PRIVATE_CONTACT_REQUEST_SCAN_STATE)); + .contains(PersistenceCapabilities::PRIVATE_CONTACT_SCAN_STATE_RESTART)); let owner = Identifier::from([7; 32]); let mut contacts = ContactChangeSet::default(); @@ -8311,6 +8329,87 @@ mod tests { assert_eq!(*host.cleared.lock().unwrap(), vec![[8; 32]]); } + /// A contact request the host cannot stage fails the whole round: the + /// round carrying it and the read position read past it ends + /// unsuccessfully, so an atomic host keeps neither and the wallet's + /// ingest, which advances only once its round reached disk, leaves the + /// position where it was. + #[test] + fn should_fail_the_round_when_the_host_cannot_stage_a_contact_request() { + use platform_wallet::changeset::SentContactRequestKey; + use platform_wallet::wallet::identity::ContactRequest; + use std::sync::atomic::{AtomicBool, Ordering}; + + static ENDED_SUCCESSFULLY: AtomicBool = AtomicBool::new(true); + unsafe extern "C" fn record_end(_: *mut c_void, _: *const u8, success: bool) -> i32 { + ENDED_SUCCESSFULLY.store(success, Ordering::SeqCst); + 0 + } + #[allow(clippy::too_many_arguments)] + unsafe extern "C" fn refuse_contacts( + _: *mut c_void, + _: *const u8, + _: *const ContactRequestFFI, + _: usize, + _: *const ContactRequestRemovalFFI, + _: usize, + _: *const ContactRequestRemovalFFI, + _: usize, + _: *const ContactIgnoredSenderFFI, + _: usize, + ) -> i32 { + -1 + } + + let host = ScanStateHost::default(); + let persister = FFIPersister::new_with_persistence_capabilities_and_extensions( + PersistenceCallbacks { + context: (&host as *const ScanStateHost).cast_mut().cast(), + on_changeset_begin_fn: Some(noop_begin), + on_changeset_end_fn: Some(record_end), + on_load_wallet_list_fn: Some(load_one_identity), + on_load_wallet_list_free_fn: Some(free_one_identity), + on_persist_contacts_fn: Some(refuse_contacts), + ..Default::default() + }, + PersistenceCapabilities::PRIVATE_CONTACT_SCAN_STATE_RESTART, + scan_state_trio(), + ); + let owner = Identifier::from([7; 32]); + let sender = Identifier::from([5; 32]); + let mut contacts = ContactChangeSet::default(); + contacts.sent_requests.insert( + SentContactRequestKey { + owner_id: owner, + recipient_id: sender, + }, + platform_wallet::changeset::ContactRequestEntry { + request: ContactRequest::new(owner, sender, 0, 0, 0, vec![0u8; 96], 0, 0), + }, + ); + contacts.private_contact_requests_positions.insert( + owner, + PrivateContactRequestsPosition { + position: 12, + key_fingerprint: [3; 32], + private_payments_unresolved: false, + }, + ); + assert!(persister + .store( + [42; 32], + PlatformWalletChangeSet { + contacts: Some(contacts), + ..Default::default() + }, + ) + .is_err()); + assert!( + !ENDED_SUCCESSFULLY.load(Ordering::SeqCst), + "the round ends unsuccessfully, so an atomic host keeps none of it" + ); + } + /// A load hands the stored position and address back to the identity /// they belong to, passes over rows of an identity it did not restore, /// returns the host's arrays, and fails on a host error or a count @@ -8319,7 +8418,7 @@ mod tests { fn should_restore_the_private_contact_request_scan_state_onto_its_identity() { use platform_wallet::wallet::identity::ContactRequest; - let declared = PersistenceCapabilities::PRIVATE_CONTACT_REQUEST_SCAN_STATE; + let declared = PersistenceCapabilities::PRIVATE_CONTACT_SCAN_STATE_RESTART; let host = ScanStateHost::default(); host.positions.lock().unwrap().extend([ PrivateContactRequestsPositionFFI { @@ -8401,9 +8500,11 @@ mod tests { } /// A host that does not declare the capability, wires only part of the - /// trio, or keeps no contact requests, is not attested, has nothing read - /// at load, and still stores and loads everything else. A host without - /// the slots stores a round carrying scan state without error. + /// trio, keeps no contact requests, or has no atomic changesets (no + /// begin and end callbacks, or no declaration) does not meet the + /// wallet's rule for keeping the scan state, has nothing read at load, + /// and still stores and loads everything else. A host without the slots + /// stores a round carrying scan state without error. #[test] fn should_leave_hosts_without_the_scan_state_capability_unchanged() { let host = ScanStateHost::default(); @@ -8436,14 +8537,36 @@ mod tests { // host without the contacts callback never keeps. let without_contacts = scan_state_persister_with_contacts( &host, - PersistenceCapabilities::PRIVATE_CONTACT_REQUEST_SCAN_STATE, + PersistenceCapabilities::PRIVATE_CONTACT_SCAN_STATE_RESTART, scan_state_trio(), false, ); - for persister in [&undeclared, &half_wired, &unwired, &without_contacts] { + // The state is kept only with atomic changesets: a host declaring + // both, with the trio, but no changeset begin and end callbacks, and + // a host bracketing its rounds but not declaring them atomic. + let without_brackets = scan_state_persister_with( + &host, + PersistenceCapabilities::PRIVATE_CONTACT_SCAN_STATE_RESTART, + scan_state_trio(), + true, + false, + ); + let not_atomic = scan_state_persister( + &host, + PersistenceCapabilities::PRIVATE_CONTACT_REQUEST_SCAN_STATE, + scan_state_trio(), + ); + for persister in [ + &undeclared, + &half_wired, + &unwired, + &without_contacts, + &without_brackets, + ¬_atomic, + ] { assert!(!persister .persistence_capabilities() - .contains(PersistenceCapabilities::PRIVATE_CONTACT_REQUEST_SCAN_STATE)); + .contains(PersistenceCapabilities::PRIVATE_CONTACT_SCAN_STATE_RESTART)); let restored = persister.load().expect("the rest still restores"); assert_eq!( restored.wallets[&[42; 32]] From 530b5c7fa80bca67ebf1b2d4187ebae53c0ca529 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 9 Nov 2026 15:59:16 +0700 Subject: [PATCH 158/269] test(platform-wallet-ffi): run the accept replying in kind for a managed identity The lazy-payment regression addressed an identity the wallet does not manage, so it stopped at `IdentityNotFound` before the wallet routed the reply. A new case manages the recipient over a mock SDK, on a manager whose shielded support was never configured, in both builds: a request already replied to is adopted and handed out established with nothing signed, and a fresh one gets a public reply that reaches the identity signer (whose refusal ends it) without asking for the shielded coordinator or the spend seed. The resolver is still read, for the DashPay crypto the seed check and the reply's encryption need. Co-Authored-By: Claude Opus 5.5 --- .../src/private_contact_request.rs | 421 ++++++++++++++++++ 1 file changed, 421 insertions(+) diff --git a/packages/rs-platform-wallet-ffi/src/private_contact_request.rs b/packages/rs-platform-wallet-ffi/src/private_contact_request.rs index 28c785b24c3..2ca99108beb 100644 --- a/packages/rs-platform-wallet-ffi/src/private_contact_request.rs +++ b/packages/rs-platform-wallet-ffi/src/private_contact_request.rs @@ -1246,6 +1246,427 @@ mod tests { let _ = unsafe { platform_wallet_manager_destroy(manager) }; } + /// The accept replying in kind, for an identity the wallet manages, + /// routes a request that came publicly without asking for shielded + /// payment resources, on a manager whose shielded support was never + /// configured (and in a build without it): a request already replied to + /// is adopted, and a fresh one gets a public reply that reaches the + /// identity signer. Payment resources (the shielded coordinator and the + /// spend seed) would fail here with "not configured"; the resolver is + /// still read, for the DashPay crypto (the seed check and the reply's + /// encryption), which is what a public reply legitimately needs. + #[test] + fn should_adopt_or_reply_publicly_for_a_managed_identity_without_payment_resources() { + use crate::contact_request::contact_request_destroy; + use crate::event_handler::EventHandlerCallbacks; + use crate::manager::{ + platform_wallet_manager_create, platform_wallet_manager_create_wallet_from_mnemonic, + platform_wallet_manager_destroy, + }; + use crate::persistence::PersistenceCallbacks; + use crate::runtime::runtime; + use dash_sdk::error::ContextProviderError; + use dash_sdk::platform::dashpay::PRIVATE_CONTACT_REQUEST_DOCUMENT_TYPE_NAME; + use dash_sdk::platform::private_document_entries::{ + PrivateDocumentEntries, PrivateDocumentEntriesQuery, + }; + use dash_sdk::platform::ContextProvider; + use dpp::data_contract::accessors::v0::DataContractV0Getters; + use dpp::data_contract::{DataContract, TokenConfiguration}; + use dpp::identity::accessors::IdentityGettersV0; + use dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; + use dpp::identity::identity_public_key::v0::IdentityPublicKeyV0; + use dpp::identity::v0::IdentityV0; + use dpp::identity::{Identity, IdentityPublicKey, KeyID, KeyType, Purpose, SecurityLevel}; + use dpp::platform_value::BinaryData; + use dpp::prelude::Identifier; + use dpp::version::PlatformVersion; + use key_wallet::bip32::KeyDerivationType; + use key_wallet::mnemonic::Mnemonic; + use key_wallet::wallet::initialization::WalletAccountCreationOptions; + use platform_wallet::wallet::identity::network::identity_auth_derivation_path_for_type; + use platform_wallet::ContactRequest; + use rs_sdk_ffi::{ + dash_sdk_mnemonic_resolver_create, dash_sdk_mnemonic_resolver_destroy, + dash_sdk_signer_create, dash_sdk_signer_destroy, mnemonic_resolver_result, + SignCompletionCallback, + }; + use std::ffi::{c_void, CString}; + use std::os::raw::c_char; + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::sync::Arc; + + const BOB_PHRASE: &str = "abandon abandon abandon abandon abandon abandon abandon \ + abandon abandon abandon abandon about"; + const ALICE_PHRASE: &str = + "legal winner thank year wave sausage worth useful legal winner thank yellow"; + const SIGNING_KEY_ID: KeyID = 0; + const ENCRYPTION_KEY_ID: KeyID = 1; + const DECRYPTION_KEY_ID: KeyID = 2; + + struct DashpayProvider(Arc); + impl ContextProvider for DashpayProvider { + 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) + } + } + + /// Identity index 0's ECDSA keys of `phrase`, as the wallet derives them. + fn identity(phrase: &str, id: [u8; 32]) -> Identity { + let seed = *Mnemonic::from_phrase(phrase) + .expect("test mnemonic") + .to_seed(""); + let wallet = key_wallet::wallet::Wallet::from_seed_bytes( + seed, + key_wallet::Network::Testnet, + WalletAccountCreationOptions::None, + ) + .expect("test wallet"); + let key = |key_id: KeyID, purpose: Purpose, security_level: SecurityLevel| { + let path = identity_auth_derivation_path_for_type( + key_wallet::Network::Testnet, + KeyDerivationType::ECDSA, + 0, + key_id, + ) + .expect("identity key path"); + let xprv = wallet + .derive_extended_private_key(&path) + .expect("identity key"); + let secret = + dpp::secp256k1::SecretKey::from_secret_bytes(*xprv.private_key.to_bytes()) + .expect("secret key"); + IdentityPublicKey::V0(IdentityPublicKeyV0 { + id: key_id, + purpose, + security_level, + contract_bounds: None, + key_type: KeyType::ECDSA_SECP256K1, + read_only: false, + data: BinaryData::new( + dpp::secp256k1::PublicKey::from_secret_key(&secret) + .serialize() + .to_vec(), + ), + disabled_at: None, + }) + }; + let keys = [ + 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), + public_keys: keys.into_iter().map(|key| (key.id(), key)).collect(), + balance: 0, + revision: 0, + }) + } + fn public_request(sender: &Identity, recipient: &Identity, account: u32) -> ContactRequest { + ContactRequest::new( + sender.id(), + recipient.id(), + ENCRYPTION_KEY_ID, + DECRYPTION_KEY_ID, + account, + vec![7; 96], + 100, + 1_700_000_000_000, + ) + } + + unsafe extern "C" fn begin(_: *mut c_void, _: *const u8) -> i32 { + 0 + } + unsafe extern "C" fn end(_: *mut c_void, _: *const u8, _: bool) -> i32 { + 0 + } + /// Bob's mnemonic, counting the reads. + unsafe extern "C" fn bob_resolve( + ctx: *const c_void, + _wallet_id: *const u8, + out: *mut c_char, + capacity: usize, + out_len: *mut usize, + ) -> i32 { + (*(ctx as *const AtomicUsize)).fetch_add(1, Ordering::SeqCst); + let phrase = BOB_PHRASE.as_bytes(); + if phrase.len() + 1 > capacity { + return mnemonic_resolver_result::BUFFER_TOO_SMALL; + } + std::ptr::copy_nonoverlapping(phrase.as_ptr() as *const c_char, out, phrase.len()); + *out.add(phrase.len()) = 0; + *out_len = phrase.len(); + mnemonic_resolver_result::SUCCESS + } + unsafe extern "C" fn noop_destroy(_ctx: *mut c_void) {} + static SIGNATURES_ASKED: AtomicUsize = AtomicUsize::new(0); + /// Counts the signature requests and refuses them: the reply is not + /// sent, so the mock needs no broadcast answer. + unsafe extern "C" fn count_and_refuse( + _signer: *const c_void, + _pubkey: *const u8, + _pubkey_len: usize, + _key_type: u8, + _data: *const u8, + _data_len: usize, + completion_ctx: *mut c_void, + completion: SignCompletionCallback, + ) { + SIGNATURES_ASKED.fetch_add(1, Ordering::SeqCst); + completion( + completion_ctx, + std::ptr::null(), + 0, + 1, + c"no signature in this test".as_ptr(), + ); + } + unsafe extern "C" fn can_sign( + _signer: *const c_void, + _pubkey: *const u8, + _pubkey_len: usize, + _key_type: u8, + ) -> bool { + true + } + + let alice = identity(ALICE_PHRASE, [0xA1; 32]); + let bob = identity(BOB_PHRASE, [0xB1; 32]); + let incoming = public_request(&alice, &bob, 3); + + let mut outcomes = Vec::new(); + for adopt in [true, false] { + let platform_version = PlatformVersion::latest(); + 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(key_wallet::Network::Testnet) + .with_version(platform_version) + .with_context_provider(DashpayProvider(Arc::clone(&contract))) + .build() + .expect("mock sdk"); + runtime().block_on(async { + sdk.mock() + .expect_fetch(contract.id(), Some((*contract).clone())) + .await + .expect("DashPay expectation"); + for known in [&alice, &bob] { + sdk.mock() + .expect_fetch(known.id(), Some((*known).clone())) + .await + .expect("identity expectation"); + } + sdk.mock() + .expect_fetch( + PrivateDocumentEntriesQuery::new( + contract.id(), + PRIVATE_CONTACT_REQUEST_DOCUMENT_TYPE_NAME, + 0, + ), + Some(PrivateDocumentEntries { + chunk_power: None, + entries: Vec::new(), + total_count: 0, + }), + ) + .await + .expect("store page expectation"); + // A public reply takes Bob's DashPay contract nonce before it + // is signed. + sdk.mock() + .expect_fetch::( + (bob.id(), contract.id()), + None, + ) + .await + .expect("contract nonce expectation"); + }); + + let persistence = PersistenceCallbacks { + on_changeset_begin_fn: Some(begin), + on_changeset_end_fn: Some(end), + ..Default::default() + }; + let events = EventHandlerCallbacks { + context: std::ptr::null_mut(), + on_wallet_event_fn: None, + on_error_fn: None, + on_platform_address_sync_completed_fn: None, + on_shielded_sync_completed_fn: None, + on_shielded_sync_progress_fn: None, + on_shielded_tree_progress_fn: None, + release_fn: None, + }; + let mut manager = NULL_HANDLE; + let created = unsafe { + platform_wallet_manager_create( + &sdk as *const dash_sdk::Sdk as *const c_void, + &persistence, + &events, + &mut manager, + ) + }; + assert_eq!(created.code, PlatformWalletFFIResultCode::Success); + let mnemonic = CString::new(BOB_PHRASE).expect("phrase"); + let mut wallet_handle = NULL_HANDLE; + let mut wallet_id = [0u8; 32]; + // Default accounts: the seed check verifies the resolver against + // BIP44 account 0. + let added = unsafe { + platform_wallet_manager_create_wallet_from_mnemonic( + manager, + mnemonic.as_ptr(), + FFINetwork::Testnet, + 1, + &mut wallet_handle, + &mut wallet_id, + ) + }; + assert_eq!(added.code, PlatformWalletFFIResultCode::Success); + + // Bob is the wallet's identity, holding Alice's public request, + // and, to adopt, the reply already sent to her. + let reply = public_request(&bob, &alice, 4); + PLATFORM_WALLET_STORAGE + .with_item(wallet_handle, |wallet| { + runtime().block_on(async { + let mut wm = wallet.wallet_manager().write().await; + let manager = &mut wm + .get_wallet_info_mut(&wallet.wallet_id()) + .expect("the wallet's info") + .identity_manager; + manager + .add_identity(bob.clone(), 0, wallet.wallet_id(), wallet.persister()) + .expect("Bob"); + let managed = manager.managed_identity_mut(&bob.id()).expect("Bob"); + managed + .add_incoming_contact_request(incoming.clone(), wallet.persister()) + .expect("Alice's request"); + if adopt { + managed + .add_sent_contact_request(reply.clone(), wallet.persister()) + .expect("the reply"); + } + }) + }) + .expect("the wallet"); + + let reads = AtomicUsize::new(0); + let resolver = unsafe { + dash_sdk_mnemonic_resolver_create( + &reads as *const AtomicUsize as *mut c_void, + bob_resolve, + noop_destroy, + ) + }; + let signer = unsafe { dash_sdk_signer_create(count_and_refuse, can_sign, None) }; + let request_handle = CONTACT_REQUEST_STORAGE.insert(incoming.clone()); + let signatures_before = SIGNATURES_ASKED.load(Ordering::SeqCst); + let mut established = NULL_HANDLE; + let result = unsafe { + platform_wallet_manager_accept_contact_request_replying_in_kind( + manager, + wallet_id.as_ptr(), + resolver, + 0, + request_handle, + std::ptr::null(), + signer, + &mut established, + ) + }; + let message = if result.message.is_null() { + String::new() + } else { + unsafe { std::ffi::CStr::from_ptr(result.message) } + .to_string_lossy() + .into_owned() + }; + outcomes.push(( + adopt, + result.code, + message, + established, + SIGNATURES_ASKED.load(Ordering::SeqCst) - signatures_before, + reads.load(Ordering::SeqCst), + )); + + if established != NULL_HANDLE { + ESTABLISHED_CONTACT_STORAGE.remove(established); + } + let _ = contact_request_destroy(request_handle); + unsafe { + dash_sdk_signer_destroy(signer); + dash_sdk_mnemonic_resolver_destroy(resolver); + } + let _ = unsafe { platform_wallet_manager_destroy(manager) }; + } + + let (_, adopted_code, adopted_message, adopted, adopted_signatures, _) = &outcomes[0]; + assert_eq!( + *adopted_code, + PlatformWalletFFIResultCode::Success, + "the reply already sent is adopted: {adopted_message}" + ); + assert_ne!( + *adopted, NULL_HANDLE, + "an established contact is handed out" + ); + assert_eq!(*adopted_signatures, 0, "nothing is signed to adopt"); + + let (_, replied_code, replied_message, replied, replied_signatures, replied_reads) = + &outcomes[1]; + assert_ne!(*replied_code, PlatformWalletFFIResultCode::Success); + assert_eq!(*replied, NULL_HANDLE); + assert!( + !replied_message.contains("not configured"), + "no payment resource was asked for: {replied_message}" + ); + assert!( + *replied_signatures > 0, + "the public reply reaches the identity signer: {replied_message}" + ); + assert!(*replied_reads > 0, "the DashPay crypto reads the mnemonic"); + } + /// A managed identity with no private request in flight reports none; an /// unknown handle is not found and leaves the flag false; a null output is /// refused. Reserving a reply is the wallet's own (its tests pin the From 84c2b96abc5728ae42deeb7e8a76c252fefb30bd Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 9 Nov 2026 16:12:53 +0700 Subject: [PATCH 159/269] 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 5af46f3236d8fe3f08c1da34216b191bca08ddcc Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 9 Nov 2026 16:14:38 +0700 Subject: [PATCH 160/269] fix(swift-sdk): fail the round when a contact request cannot be stored `persistContacts` skipped a contact request (or an ignored sender) whose owner identity it could not find, or whose fetch failed, and the callback returned success, so the round committed without the row. This store declares the private contact request scan state, so the wallet then stored a read position past a request that was never saved, and no later scan would read it again. A missing owner, a failed fetch, and a failed tombstone or ignore staging now throw, the callback returns a failure, and the round ends unsuccessfully: the wallet's read position stays where it was, and the next scan stores the request. Identities still apply before contacts in a round, and the wallet stores contact requests only of the identities it manages, so nothing needed the skip. Co-Authored-By: Claude Opus 5.5 --- .../PlatformWalletPersistenceHandler.swift | 118 +++++++++--------- .../DashPayPersistenceTests.swift | 57 +++++++-- 2 files changed, 105 insertions(+), 70 deletions(-) diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift index 53040d476ed..460d6d97f55 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift @@ -4391,54 +4391,42 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { /// /// The owner identity is required to exist in SwiftData before /// the row is inserted — the relationship is non-optional and - /// `networkRaw` is read off it. If a flush carries a contact - /// upsert for an owner identity Swift hasn't seen yet (race with - /// a first-time identity flush), the row is skipped; the next - /// flush will replay it after the identity row lands. In - /// practice the changeset is one round, so this only matters - /// for the very first identity registration where the contact - /// changeset and identity changeset arrive in the same store() - /// call — within a round, identities apply before contacts (see - /// the ordering in `FFIPersister::store`), so the lookup here - /// will normally succeed. + /// `networkRaw` is read off it. Within a round identities apply + /// before contacts (see the ordering in `FFIPersister::store`), and + /// the wallet only stores contact requests of the identities it + /// manages, so the owner is there. When it is not, or a fetch fails, + /// this throws, and the callback fails the round: the store declares + /// the private contact request scan state, so a round acknowledged + /// without its contact requests would let the wallet store a read + /// position past a request that was never saved, and no later scan + /// would read it again. A failed round leaves the wallet's read + /// position where it was, so the next scan stores the request. func persistContacts( walletId: Data, upserts: [ContactRequestSnapshot], removedSent: [ContactRequestRemovalSnapshot], removedIncoming: [ContactRequestRemovalSnapshot], ignored: [ContactIgnoredSenderSnapshot] - ) { - onQueue { + ) throws { + try onQueue { for entry in upserts { let ownerId = entry.ownerIdentityId let ownerDescriptor = FetchDescriptor( predicate: #Predicate { $0.identityId == ownerId } ) - guard let owner = try? backgroundContext.fetch(ownerDescriptor).first else { - // Owner identity hasn't landed yet. Within a - // single round identities apply before contacts, - // so we'd only hit this if the FFI changeset - // surfaces a contact for an identity that isn't - // managed by any wallet locally — there's no - // identity row to hang it off, and the contract's - // `ownerId` invariant means the row would be - // orphaned anyway. The recurring sweep replays it - // once the owner row exists; log so a contact that - // is somehow dropped permanently (e.g. an - // out-of-wallet owner with no PersistentIdentity) - // is at least observable rather than vanishing - // silently. + guard let owner = try backgroundContext.fetch(ownerDescriptor).first else { SDKLogger.event( - "persistence_contact_deferred", + "persistence_contact_refused", category: .persistence, - severity: .warning, + severity: .error, fields: [ "identity_reference": .reference(entry.ownerIdentityId), "operation": .publicText("contact_upsert"), "reason": .publicText("identity_missing"), ] ) - continue + throw PlatformWalletError.walletOperation( + "contact request owner identity is not persisted") } let networkRaw = owner.networkRaw @@ -4452,7 +4440,7 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { && $0.isOutgoing == isOutgoing } ) - if let existing = try? backgroundContext.fetch(descriptor).first { + if let existing = try backgroundContext.fetch(descriptor).first { // Refresh in place — every column is overwritten // because the FFI snapshot is authoritative for // the underlying `ContactRequest` document. This @@ -4505,14 +4493,14 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { } for tomb in removedSent { - deleteContactRow( + try deleteContactRow( ownerId: tomb.ownerIdentityId, contactId: tomb.contactIdentityId, isOutgoing: true ) } for tomb in removedIncoming { - deleteContactRow( + try deleteContactRow( ownerId: tomb.ownerIdentityId, contactId: tomb.contactIdentityId, isOutgoing: false @@ -4527,16 +4515,16 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { // without (2) the ignored sender resurfaces on the // next post-relaunch sweep. Per-sender (no // accountReference): ALL the sender's incoming rows go. - deleteIgnoredSenderIncomingRows( + try deleteIgnoredSenderIncomingRows( ownerId: row.ownerIdentityId, senderId: row.senderIdentityId ) - upsertIgnoredSender(row) + try upsertIgnoredSender(row) } else { // Un-ignore: delete the ignored-sender row so the // sender's requests resurface on the next sweep (the // Rust side rewinds the cursor to re-fetch them). - deleteIgnoredSender( + try deleteIgnoredSender( ownerId: row.ownerIdentityId, senderId: row.senderIdentityId ) @@ -4554,10 +4542,11 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { /// identity belongs to exactly one network — so we don't have to /// fan out the predicate across networks. Silent on miss (no /// existing row): the FFI changeset replays tombstones, and an - /// already-removed row is the success state. + /// already-removed row is the success state. A failed fetch throws, + /// failing the round. /// /// Assumes it's already running on `serialQueue`. - private func deleteContactRow(ownerId: Data, contactId: Data, isOutgoing: Bool) { + private func deleteContactRow(ownerId: Data, contactId: Data, isOutgoing: Bool) throws { let direction = isOutgoing let descriptor = FetchDescriptor( predicate: #Predicate { @@ -4566,7 +4555,7 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { && $0.isOutgoing == direction } ) - if let existing = try? backgroundContext.fetch(descriptor).first { + if let existing = try backgroundContext.fetch(descriptor).first { backgroundContext.delete(existing) } } @@ -4578,7 +4567,7 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { /// miss: an already-removed row is the success state. /// /// Assumes it's already running on `serialQueue`. - private func deleteIgnoredSenderIncomingRows(ownerId: Data, senderId: Data) { + private func deleteIgnoredSenderIncomingRows(ownerId: Data, senderId: Data) throws { let descriptor = FetchDescriptor( predicate: #Predicate { $0.ownerIdentityId == ownerId @@ -4586,10 +4575,8 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { && $0.isOutgoing == false } ) - if let rows = try? backgroundContext.fetch(descriptor) { - for row in rows { - backgroundContext.delete(row) - } + for row in try backgroundContext.fetch(descriptor) { + backgroundContext.delete(row) } } @@ -4603,27 +4590,28 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { /// Upsert keyed `(networkRaw, ownerIdentityId, ignoredSenderId)` — the /// Rust per-sender suppression key. Idempotent: a replay of the same /// ignore is a no-op. Requires the owner `PersistentIdentity` to exist - /// (the row hangs off it); skipped + logged if it hasn't landed yet — - /// the next sync round replays it. + /// (the row hangs off it); throws, failing the round, when it does not + /// or a fetch fails, as `persistContacts` does for a contact row. /// /// Assumes it's already running on `serialQueue`. - private func upsertIgnoredSender(_ row: ContactIgnoredSenderSnapshot) { + private func upsertIgnoredSender(_ row: ContactIgnoredSenderSnapshot) throws { let ownerId = row.ownerIdentityId let ownerDescriptor = FetchDescriptor( predicate: #Predicate { $0.identityId == ownerId } ) - guard let owner = try? backgroundContext.fetch(ownerDescriptor).first else { + guard let owner = try backgroundContext.fetch(ownerDescriptor).first else { SDKLogger.event( - "persistence_contact_deferred", + "persistence_contact_refused", category: .persistence, - severity: .warning, + severity: .error, fields: [ "identity_reference": .reference(row.ownerIdentityId), "operation": .publicText("ignored_sender"), "reason": .publicText("identity_missing"), ] ) - return + throw PlatformWalletError.walletOperation( + "ignored sender owner identity is not persisted") } let networkRaw = owner.networkRaw @@ -4635,7 +4623,7 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { && $0.ignoredSenderId == senderId } ) - if (try? backgroundContext.fetch(descriptor).first) == nil { + if try backgroundContext.fetch(descriptor).first == nil { backgroundContext.insert( PersistentDashpayIgnoredSender( owner: owner, @@ -4650,14 +4638,14 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { /// success state. /// /// Assumes it's already running on `serialQueue`. - private func deleteIgnoredSender(ownerId: Data, senderId: Data) { + private func deleteIgnoredSender(ownerId: Data, senderId: Data) throws { let descriptor = FetchDescriptor( predicate: #Predicate { $0.ownerIdentityId == ownerId && $0.ignoredSenderId == senderId } ) - if let existing = try? backgroundContext.fetch(descriptor).first { + if let existing = try backgroundContext.fetch(descriptor).first { backgroundContext.delete(existing) } } @@ -10818,7 +10806,7 @@ private func persistContactsCallback( ) -> Int32 { guard let context = context, let walletIdPtr = walletIdPtr else { - return 0 + return -1 } let handler = Unmanaged .fromOpaque(context) @@ -10926,14 +10914,20 @@ private func persistContactsCallback( } } - handler.persistContacts( - walletId: walletId, - upserts: upserts, - removedSent: removedSent, - removedIncoming: removedIncoming, - ignored: ignored - ) - return 0 + // A contact row that cannot be staged fails the round rather than + // acknowledging it without the row (see `persistContacts`). + do { + try handler.persistContacts( + walletId: walletId, + upserts: upserts, + removedSent: removedSent, + removedIncoming: removedIncoming, + ignored: ignored + ) + return 0 + } catch { + return -1 + } } /// Copy a fixed 32-byte C tuple into an owned `Data`. Used by the diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashPayPersistenceTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashPayPersistenceTests.swift index 5b509c904e1..84e538c521a 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashPayPersistenceTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/DashPayPersistenceTests.swift @@ -91,13 +91,13 @@ final class DashPayContactPersistenceTests: XCTestCase { ignored: [PlatformWalletPersistenceHandler.ContactIgnoredSenderSnapshot] = [] ) { handler.beginChangeset(walletId: walletId) - handler.persistContacts( + XCTAssertNoThrow(try handler.persistContacts( walletId: walletId, upserts: upserts, removedSent: removedSent, removedIncoming: removedIncoming, ignored: ignored - ) + )) handler.endChangeset(walletId: walletId, success: true) } @@ -283,10 +283,13 @@ final class DashPayContactPersistenceTests: XCTestCase { XCTAssertTrue(try XCTUnwrap(rows.first).paymentChannelBroken) } - /// A contact upsert whose owner identity Swift hasn't seen yet is - /// skipped (next sync round replays it) — it must not crash or - /// insert an orphan row. - func testUpsertSkipsUnknownOwnerIdentity() throws { + /// A contact upsert whose owner identity is not persisted fails the + /// round instead of being skipped: the store declares the private + /// contact request scan state, so an acknowledged round without the + /// row would let the wallet store a read position past it. The callback + /// reports the failure, the rolled-back round leaves nothing, and once + /// the owner is persisted the same upsert (the next scan's) lands. + func testUnknownOwnerIdentityFailsTheRound() throws { let unknownOwner = Data(repeating: 0x77, count: 32) var snapshot = makeSnapshot(isOutgoing: false) snapshot = .init( @@ -308,9 +311,47 @@ final class DashPayContactPersistenceTests: XCTestCase { contactAccountLabel: snapshot.contactAccountLabel, contactAcceptedAccounts: snapshot.contactAcceptedAccounts ) + handler.beginChangeset(walletId: walletId) + XCTAssertThrowsError(try handler.persistContacts( + walletId: walletId, upserts: [snapshot], removedSent: [], removedIncoming: [], ignored: [])) + // The callback's failure makes Rust end the round unsuccessfully. + handler.endChangeset(walletId: walletId, success: false) + XCTAssertEqual(try fetchContactRows().count, 0) + + // The callback shim reports it as a failed callback. + let callbacks = handler.makeCallbacks() + defer { Unmanaged.fromOpaque(callbacks.context!).release() } + let contactsFn = try XCTUnwrap(callbacks.on_persist_contacts_fn) + var row = ContactRequestFFI() + row.owner_id = Self.tuple32(unknownOwner) + row.contact_id = Self.tuple32(contactId) + let rc = walletId.withUnsafeBytes { wid in + withUnsafePointer(to: &row) { rowPtr in + contactsFn(callbacks.context, wid.bindMemory(to: UInt8.self).baseAddress, + rowPtr, 1, nil, 0, nil, 0, nil, 0) + } + } + XCTAssertNotEqual(rc, 0) + + // A later scan, once the owner identity is persisted, stores it. + let context = ModelContext(container) + context.insert(PersistentIdentity(identityId: unknownOwner, isLocal: false, network: .testnet)) + try context.save() applyContacts(upserts: [snapshot]) + XCTAssertEqual(try fetchContactRows().map(\.ownerIdentityId), [unknownOwner]) + } - XCTAssertEqual(try fetchContactRows().count, 0) + /// An ignore whose owner identity is not persisted fails the round too. + func testIgnoreForAnUnknownOwnerFailsTheRound() throws { + handler.beginChangeset(walletId: walletId) + XCTAssertThrowsError(try handler.persistContacts( + walletId: walletId, upserts: [], removedSent: [], removedIncoming: [], + ignored: [.init( + ownerIdentityId: Data(repeating: 0x77, count: 32), + senderIdentityId: otherSenderId, + isIgnored: true)])) + handler.endChangeset(walletId: walletId, success: false) + XCTAssertEqual(try fetchIgnoredRows().count, 0) } // MARK: Ignored senders (per-sender mute, local-only) @@ -665,7 +706,7 @@ final class DashPayContactPersistenceTests: XCTestCase { func testPaymentRefreshDoesNotCommitAnOpenChangesetRound() throws { // Open a round and stage an (uncommitted) contact write. handler.beginChangeset(walletId: walletId) - handler.persistContacts( + try handler.persistContacts( walletId: walletId, upserts: [makeSnapshot(isOutgoing: false)], removedSent: [], From 63e1ca42a158acd62b145e86881c3c21b3977a71 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 9 Nov 2026 16:14:39 +0700 Subject: [PATCH 161/269] feat(swift-sdk): type code 73 and show it as a storage configuration error `PlatformWalletError.privateContactRecoveryUnsupported` binds `ErrorPrivateContactRecoveryUnsupported` (73): the store cannot keep a private payment's recovery state across a restart, so a private send or reply is refused before anything is sealed or paid. The SwiftData handler declares atomic changesets and the scan state with the begin and end callbacks wired, so it should not occur; SwiftExampleApp shows it as "this storage backend can't protect private payments". Co-Authored-By: Claude Opus 5.5 --- .../ERROR_CODE_REGISTRY.md | 2 +- .../PlatformWallet/PlatformWalletResult.swift | 17 +++++++++++++++++ .../Views/DashPay/ContactRequestOutcomes.swift | 7 +++++++ .../ContactRequestOutcomesTests.swift | 9 +++++++++ .../SwiftDashSDKTests/ErrorHandlingTests.swift | 16 ++++++++++++++++ 5 files changed, 50 insertions(+), 1 deletion(-) diff --git a/packages/rs-platform-wallet-ffi/ERROR_CODE_REGISTRY.md b/packages/rs-platform-wallet-ffi/ERROR_CODE_REGISTRY.md index 003db5abb11..6308dd3f3b2 100644 --- a/packages/rs-platform-wallet-ffi/ERROR_CODE_REGISTRY.md +++ b/packages/rs-platform-wallet-ffi/ERROR_CODE_REGISTRY.md @@ -186,7 +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 types it (`PlatformWalletError.privateContactPaymentsNotRestored`, `ErrorHandlingTests` pin) and binds the readiness (`ManagedPlatformWallet.privateContactReadiness`); SwiftExampleApp shows "restoring private payments" apart from a pending request (71) until the readiness reads restored. Kotlin must type it, as for 71 | -| 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 | +| 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 types it (`PlatformWalletError.privateContactRecoveryUnsupported`, `ErrorHandlingTests` pin) and SwiftExampleApp shows it as a storage configuration error; both Swift and Kotlin hosts declare bit 0 and bit 14 with the begin and end callbacks wired. 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/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift index 22963ef38a3..5b70f06a62c 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift @@ -288,6 +288,12 @@ public enum PlatformWalletResultCode: Int32, Sendable { /// store or binding the wallet's shielded account restores them; a /// private sync does not. case errorPrivateContactPaymentsNotRestored = 72 + /// The persister cannot keep a private payment's recovery state across + /// a restart (it does not declare the private contact request scan + /// state with atomic changesets), so a private send or reply is refused + /// before anything is sealed or paid. A configuration error, not + /// retryable as it is. + case errorPrivateContactRecoveryUnsupported = 73 /// The named thing does not exist. Besides the handle/lookup failures this /// has always covered, BOTH deferred-send paths report the /// wallet-was-REMOVED case here. @@ -429,6 +435,8 @@ public enum PlatformWalletResultCode: Int32, Sendable { self = .errorPrivateContactRequestPending case PLATFORM_WALLET_FFI_RESULT_CODE_ERROR_PRIVATE_CONTACT_PAYMENTS_NOT_RESTORED: self = .errorPrivateContactPaymentsNotRestored + case PLATFORM_WALLET_FFI_RESULT_CODE_ERROR_PRIVATE_CONTACT_RECOVERY_UNSUPPORTED: + self = .errorPrivateContactRecoveryUnsupported case PLATFORM_WALLET_FFI_RESULT_CODE_NOT_FOUND: self = .notFound case PLATFORM_WALLET_FFI_RESULT_CODE_ERROR_UNKNOWN: @@ -655,6 +663,12 @@ public enum PlatformWalletError: LocalizedError { /// `ManagedPlatformWallet.privateContactReadiness(identityId:)` reads /// whether they are restored. case privateContactPaymentsNotRestored(String) + /// The wallet's storage cannot keep a private payment's recovery state + /// across a restart (code 73): it does not declare the private contact + /// request scan state together with atomic changesets. A private send + /// or reply is refused before anything is sealed or paid. A + /// configuration error of the store, not something a retry fixes. + case privateContactRecoveryUnsupported(String) case walletAlreadyExists(String) /// Definitive shielded-broadcast failure: the shielded transition /// (identity-create or a spend — unshield / transfer / withdrawal) was @@ -883,6 +897,7 @@ public enum PlatformWalletError: LocalizedError { .privateContactRequestNeedsPrivateReply(let m), .shieldedBuildRequired(let m), .protocolVersionNotActive(let m), .privateContactRequestPending(let m), .privateContactPaymentsNotRestored(let m), + .privateContactRecoveryUnsupported(let m), .walletAlreadyExists(let m), .shieldedBroadcastFailed(let m), .shieldedBroadcastUnconfirmed(let m), .shieldedSpendUnconfirmed(let m), .shieldedNoRecordedAnchor(let m), .shieldedInsufficientBalance(let m), @@ -1011,6 +1026,8 @@ public enum PlatformWalletError: LocalizedError { self = .privateContactRequestPending(detail) case .errorPrivateContactPaymentsNotRestored: self = .privateContactPaymentsNotRestored(detail) + case .errorPrivateContactRecoveryUnsupported: + self = .privateContactRecoveryUnsupported(detail) case .errorWalletAlreadyExists: self = .walletAlreadyExists(detail) case .errorShieldedBroadcastFailed: self = .shieldedBroadcastFailed(detail) case .errorShieldedBroadcastUnconfirmed: self = .shieldedBroadcastUnconfirmed(detail) diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ContactRequestOutcomes.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ContactRequestOutcomes.swift index 2289a163dbd..eb37dd7c1fa 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ContactRequestOutcomes.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ContactRequestOutcomes.swift @@ -36,6 +36,11 @@ enum ContactRequestSubmissionOutcome: Equatable { static let restoringPrivatePaymentsMessage = "Restoring this wallet's private " + "payments. Contact requests wait until they are restored." + /// Code 73 is a configuration error of the app's storage, which both + /// declares what a private payment needs; shown as such. + static let recoveryUnsupportedMessage = "This storage backend can't protect " + + "private payments, so private contact requests are off." + /// The message a request row shows: the restoring notice while the /// wallet's private payments are not restored, the pending notice while /// the wallet has a private request to the contact pending, the @@ -59,6 +64,8 @@ enum ContactRequestSubmissionOutcome: Equatable { return .privateRequestPending case .privateContactPaymentsNotRestored: return .privatePaymentsNotRestored + case .privateContactRecoveryUnsupported: + return .failed(recoveryUnsupportedMessage) default: break } diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/ContactRequestOutcomesTests.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/ContactRequestOutcomesTests.swift index 5de5dd8e489..a60c6c914ca 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/ContactRequestOutcomesTests.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/ContactRequestOutcomesTests.swift @@ -108,6 +108,15 @@ final class ContactRequestOutcomesTests: XCTestCase { ContactRequestSubmissionOutcome.privateRequestPendingMessage) } + /// A store that cannot protect private payments (code 73) is a + /// configuration error, shown as such. + func testShouldShowUnsupportedPrivateRecoveryAsAStorageError() { + XCTAssertEqual( + ContactRequestSubmissionOutcome.of( + PlatformWalletError.privateContactRecoveryUnsupported("no atomic scan state")), + .failed(ContactRequestSubmissionOutcome.recoveryUnsupportedMessage)) + } + /// A code 72 hold survives a successful pending read that finds nothing, /// and a readiness read after a private sync that still says not /// restored; only a readiness read that says restored lifts it. diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/ErrorHandlingTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/ErrorHandlingTests.swift index e5c8c605a6c..ccdc9cc3e60 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/ErrorHandlingTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/ErrorHandlingTests.swift @@ -537,6 +537,22 @@ final class ErrorHandlingTests: XCTestCase { XCTAssertEqual(message, detail) } + /// A store that cannot keep a private payment's recovery state has its + /// own code and typed case. + func testShouldReportUnsupportedPrivateContactRecoveryByItsOwnCode() { + let code = PlatformWalletResultCode( + ffi: PLATFORM_WALLET_FFI_RESULT_CODE_ERROR_PRIVATE_CONTACT_RECOVERY_UNSUPPORTED + ) + XCTAssertEqual(code, .errorPrivateContactRecoveryUnsupported) + XCTAssertEqual(code.rawValue, 73) + let detail = "the persister does not keep the private contact request scan state atomically" + guard case .privateContactRecoveryUnsupported(let message) = + PlatformWalletError(code: code, message: detail) else { + return XCTFail("lost typed recovery-unsupported error") + } + XCTAssertEqual(message, detail) + } + func testShouldReadEveryFieldOfThePrivateContactReadiness() { var ffi = PrivateContactReadinessFFI() ffi.private_payments_restored = false From 46bd798dc23edbc7308144eb68a151d33ff4f5a2 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 9 Nov 2026 16:22:44 +0700 Subject: [PATCH 162/269] fix(kotlin-sdk): fail the round when a contact request cannot be stored The Room handler skipped a contact request (or an ignored sender) whose owner identity was not persisted and acknowledged the round. This store declares the private contact request scan state, so native then stored a read position past a request that was never saved, and no later scan would read it again. The staged write now throws, which fails the round at its end (or the callback outside a round): the read position stays where it was, and the next scan stores the request. Port of the Swift fix on #5812. Co-Authored-By: Claude Opus 5.5 --- .../PlatformWalletPersistenceHandler.kt | 20 ++++++--- .../PrivateContactScanStatePersistenceTest.kt | 42 +++++++++++++++++++ 2 files changed, 56 insertions(+), 6 deletions(-) diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandler.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandler.kt index 9a83af98e4d..b8c895071d6 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandler.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandler.kt @@ -2231,9 +2231,16 @@ class PlatformWalletPersistenceHandler( externalAccountReference: Int, ): Int = guarded { stage(walletId) { db -> - // Owner identity must exist; skip silently otherwise (replayed - // next round), reading networkRaw off the owner. - val owner = db.identityDao().getByIdentityId(ownerId) ?: return@stage + // The owner identity must exist (networkRaw is read off it). + // Within a round identities apply before contacts, and native + // stores contact requests only of identities it manages, so it + // is there; when it is not, the round fails rather than being + // acknowledged without the row: this store declares the private + // contact request scan state, so an acknowledged round would let + // native store a read position past a request that was never + // saved, and no later scan would read it again. + val owner = db.identityDao().getByIdentityId(ownerId) + ?: throw IllegalStateException("contact request owner identity is not persisted") db.dashpayDao().upsertContactRequest( DashpayContactRequestEntity( networkRaw = owner.networkRaw, @@ -2282,9 +2289,10 @@ class PlatformWalletPersistenceHandler( stage(walletId) { db -> if (isIgnored) { db.dashpayDao().deleteContactRequest(ownerId, senderId, isOutgoing = false) - // Owner identity must exist (networkRaw is read off it); - // skip silently otherwise — replayed next round. - val owner = db.identityDao().getByIdentityId(ownerId) ?: return@stage + // The owner identity must exist (networkRaw is read off it); + // the round fails otherwise, as for a contact upsert. + val owner = db.identityDao().getByIdentityId(ownerId) + ?: throw IllegalStateException("ignored sender owner identity is not persisted") db.dashpayDao().upsertIgnoredSender( DashpayIgnoredSenderEntity( networkRaw = owner.networkRaw, diff --git a/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/persistence/PrivateContactScanStatePersistenceTest.kt b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/persistence/PrivateContactScanStatePersistenceTest.kt index a195667339a..3d335775a28 100644 --- a/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/persistence/PrivateContactScanStatePersistenceTest.kt +++ b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/persistence/PrivateContactScanStatePersistenceTest.kt @@ -167,6 +167,48 @@ class PrivateContactScanStatePersistenceTest { } } + private fun upsertContact(ownerId: ByteArray): Int = handler.onPersistContactUpsert( + walletId, ownerId, contact, false, 0, 0, 0, ByteArray(96), null, null, 0, 0L, + false, null, null, false, null, IntArray(0), false, 0, + ) + + /** + * A contact request whose owner identity is not persisted fails the + * round instead of being skipped: this store declares the private + * contact request scan state, so an acknowledged round without the row + * would let native store a read position past it. The rolled-back round + * leaves nothing, and once the owner is persisted the same upsert (the + * next scan's) lands. + */ + @Test + fun shouldFailTheRoundOnAContactRequestWhoseOwnerIsNotPersisted() = runTest { + val unknownOwner = ByteArray(32) { 0x77 } + assertEquals(0, handler.onChangesetBegin(walletId)) + assertEquals(0, upsertContact(unknownOwner)) + assertNotEquals("the round fails", 0, handler.onChangesetEnd(walletId, true)) + assertTrue(db.dashpayDao().getContactRequestsByOwner(unknownOwner).isEmpty()) + + // Outside a round the callback itself fails. + assertNotEquals(0, upsertContact(unknownOwner)) + + // An ignore for an unknown owner fails its round too. + assertEquals(0, handler.onChangesetBegin(walletId)) + assertEquals(0, handler.onPersistContactIgnored(walletId, unknownOwner, contact, true)) + assertNotEquals(0, handler.onChangesetEnd(walletId, true)) + + db.identityDao().upsert( + org.dashfoundation.dashsdk.persistence.entities.IdentityEntity( + identityId = unknownOwner, + networkRaw = 1, + walletId = walletId, + ), + ) + assertEquals(0, handler.onChangesetBegin(walletId)) + assertEquals(0, upsertContact(unknownOwner)) + assertEquals(0, handler.onChangesetEnd(walletId, true)) + assertEquals(1, db.dashpayDao().getContactRequestsByOwner(unknownOwner).size) + } + /** A partial row fails the callback instead of storing a misread row. */ @Test fun shouldRefuseARoundWithAPartialRow() { From 3d82b5f74ea071a81d8429a144f3e87682f41ed6 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 9 Nov 2026 16:22:45 +0700 Subject: [PATCH 163/269] feat(kotlin-sdk): type code 73 and show it as a storage configuration error `DashSdkError.PlatformWallet.PrivateContactRecoveryUnsupported` binds `ErrorPrivateContactRecoveryUnsupported` (73). The Room handler declares atomic changesets and the scan state, and JNI wires the begin and end callbacks, so it should not occur; KotlinExampleApp shows it as "this storage backend can't protect private payments". Port of the Swift binding on #5812. Co-Authored-By: Claude Opus 5.5 --- .../example/ui/dashpay/ContactRequestOutcomes.kt | 9 +++++++++ .../ui/dashpay/ContactRequestOutcomesTest.kt | 11 +++++++++++ .../dashfoundation/dashsdk/errors/DashSdkError.kt | 13 +++++++++++++ .../dashsdk/errors/DashSdkErrorTest.kt | 11 +++++++++++ .../rs-platform-wallet-ffi/ERROR_CODE_REGISTRY.md | 2 +- 5 files changed, 45 insertions(+), 1 deletion(-) diff --git a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/ContactRequestOutcomes.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/ContactRequestOutcomes.kt index b6e634b8617..5afbfc297a6 100644 --- a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/ContactRequestOutcomes.kt +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/ContactRequestOutcomes.kt @@ -42,10 +42,19 @@ sealed interface ContactRequestSubmissionOutcome { const val RESTORING_PRIVATE_PAYMENTS_MESSAGE = "Restoring this wallet's private " + "payments. Contact requests wait until they are restored." + /** + * Code 73 is a configuration error of the app's storage, which + * declares what a private payment needs; shown as such. + */ + const val RECOVERY_UNSUPPORTED_MESSAGE = "This storage backend can't protect " + + "private payments, so private contact requests are off." + fun of(error: Throwable): ContactRequestSubmissionOutcome = when (error) { is DashSdkError.PlatformWallet.ShieldedSpendUnconfirmed -> MayHaveGoneThrough is DashSdkError.PlatformWallet.PrivateContactRequestPending -> PrivateRequestPending is DashSdkError.PlatformWallet.PrivateContactPaymentsNotRestored -> PrivatePaymentsNotRestored + is DashSdkError.PlatformWallet.PrivateContactRecoveryUnsupported -> + Failed(RECOVERY_UNSUPPORTED_MESSAGE) else -> Failed(error.message ?: "unknown error") } diff --git a/packages/kotlin-sdk/KotlinExampleApp/app/src/test/java/org/dashfoundation/example/ui/dashpay/ContactRequestOutcomesTest.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/test/java/org/dashfoundation/example/ui/dashpay/ContactRequestOutcomesTest.kt index a442884a53c..0bac7ae22c4 100644 --- a/packages/kotlin-sdk/KotlinExampleApp/app/src/test/java/org/dashfoundation/example/ui/dashpay/ContactRequestOutcomesTest.kt +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/test/java/org/dashfoundation/example/ui/dashpay/ContactRequestOutcomesTest.kt @@ -117,6 +117,17 @@ class ContactRequestOutcomesTest { unresolvedPrivatePayment = unresolved, ) + /** A store that cannot protect private payments (code 73) is a configuration error, shown as such. */ + @Test + fun shouldShowUnsupportedPrivateRecoveryAsAStorageError() { + assertEquals( + ContactRequestSubmissionOutcome.Failed(ContactRequestSubmissionOutcome.RECOVERY_UNSUPPORTED_MESSAGE), + ContactRequestSubmissionOutcome.of( + DashSdkError.PlatformWallet.PrivateContactRecoveryUnsupported("no atomic scan state"), + ), + ) + } + /** Unrestored private payments (code 72) are their own outcome, apart from a pending request (71). */ @Test fun shouldTellUnrestoredPrivatePaymentsFromAPendingRequest() { diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/errors/DashSdkError.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/errors/DashSdkError.kt index dfd4d170ca8..a929dacbd47 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/errors/DashSdkError.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/errors/DashSdkError.kt @@ -713,6 +713,18 @@ sealed class DashSdkError( class PrivateContactPaymentsNotRestored(message: String, cause: Throwable? = null) : PlatformWallet(message, cause) + /** + * `ErrorPrivateContactRecoveryUnsupported` (native code 73). The + * wallet's storage cannot keep a private payment's recovery state + * across a restart (it does not declare the private contact request + * scan state together with atomic changesets), so a private send or + * reply is refused before anything is sealed or paid. A + * configuration error of the store, not something a retry fixes. + * ← Swift `PlatformWalletError.privateContactRecoveryUnsupported`. + */ + class PrivateContactRecoveryUnsupported(message: String, cause: Throwable? = null) : + PlatformWallet(message, cause) + /** * Any other `PlatformWalletFFIResultCode` without a dedicated type. * Carries the platform-wallet [nativeCode] (already de-offset) and @@ -916,6 +928,7 @@ sealed class DashSdkError( 70 -> PlatformWallet.ProtocolVersionNotActive(message, cause) // ErrorProtocolVersionNotActive 71 -> PlatformWallet.PrivateContactRequestPending(message, cause) // ErrorPrivateContactRequestPending 72 -> PlatformWallet.PrivateContactPaymentsNotRestored(message, cause) // ErrorPrivateContactPaymentsNotRestored + 73 -> PlatformWallet.PrivateContactRecoveryUnsupported(message, cause) // ErrorPrivateContactRecoveryUnsupported else -> // @Deprecated fallback — see the code-6 arm; code 31 is the // real discriminator. diff --git a/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/errors/DashSdkErrorTest.kt b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/errors/DashSdkErrorTest.kt index 66e08d7dcd7..85b9c7fa8ee 100644 --- a/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/errors/DashSdkErrorTest.kt +++ b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/errors/DashSdkErrorTest.kt @@ -318,6 +318,17 @@ class DashSdkErrorTest { assertFalse("held, not a failure to retry", mapped.isRetryable) } + /** A store that cannot keep a private payment's recovery state has its own code. */ + @Test + fun shouldReportUnsupportedPrivateContactRecoveryByItsOwnCode() { + val detail = "the persister does not keep the private contact request scan state atomically" + val native = DashSDKException(DashSdkError.PLATFORM_WALLET_CODE_OFFSET + 73, detail) + val mapped = DashSdkError.fromNative(native) + assertTrue(mapped is DashSdkError.PlatformWallet.PrivateContactRecoveryUnsupported) + assertEquals(detail, mapped.message) + assertFalse("a configuration error, not a retry", mapped.isRetryable) + } + @Test fun platformShieldCapacityCode41MapsTyped() { val message = diff --git a/packages/rs-platform-wallet-ffi/ERROR_CODE_REGISTRY.md b/packages/rs-platform-wallet-ffi/ERROR_CODE_REGISTRY.md index 064bb550bb7..39e0de0cbe1 100644 --- a/packages/rs-platform-wallet-ffi/ERROR_CODE_REGISTRY.md +++ b/packages/rs-platform-wallet-ffi/ERROR_CODE_REGISTRY.md @@ -186,7 +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 types it (`PlatformWalletError.privateContactPaymentsNotRestored`, `ErrorHandlingTests` pin) and binds the readiness (`ManagedPlatformWallet.privateContactReadiness`); Kotlin types it (`DashSdkError.PlatformWallet.PrivateContactPaymentsNotRestored`, `DashSdkErrorTest` pin) and binds the readiness (`Dashpay.privateContactReadiness`); both example apps show "restoring private payments" apart from a pending request (71) 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 types it (`PlatformWalletError.privateContactRecoveryUnsupported`, `ErrorHandlingTests` pin) and SwiftExampleApp shows it as a storage configuration error; both Swift and Kotlin hosts declare bit 0 and bit 14 with the begin and end callbacks wired. Kotlin must type it | +| 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 types it (`PlatformWalletError.privateContactRecoveryUnsupported`, `ErrorHandlingTests` pin) and SwiftExampleApp shows it as a storage configuration error; both Swift and Kotlin hosts declare bit 0 and bit 14 with the begin and end callbacks wired. Kotlin types it (`DashSdkError.PlatformWallet.PrivateContactRecoveryUnsupported`, `DashSdkErrorTest` pin) and KotlinExampleApp shows it the same way | **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 From f0c3ada2f50b211fc742bb001efba31ee8ea495f Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 9 Nov 2026 16:22:45 +0700 Subject: [PATCH 164/269] test(kotlin-sdk): require atomic changesets with the scan state in the override test The JVM-backed capability test now checks the wallet's rule for keeping the private contact request scan state, the scan state with atomic changesets: a bridge declaring the scan state without atomic rounds has neither, beside the cases that leave an override inherited. JNI always wires the changeset begin and end callbacks, so the atomic half is the declaration. The descriptor-lockstep KDoc moves back onto `persistenceBridgeDescriptorsAllResolve`. Co-Authored-By: Claude Opus 5.5 --- .../dashfoundation/dashsdk/FfiSmokeTest.kt | 48 ++++++++++++------- 1 file changed, 32 insertions(+), 16 deletions(-) diff --git a/packages/kotlin-sdk/sdk/src/androidTest/kotlin/org/dashfoundation/dashsdk/FfiSmokeTest.kt b/packages/kotlin-sdk/sdk/src/androidTest/kotlin/org/dashfoundation/dashsdk/FfiSmokeTest.kt index bc4c9c40391..70190a07dd7 100644 --- a/packages/kotlin-sdk/sdk/src/androidTest/kotlin/org/dashfoundation/dashsdk/FfiSmokeTest.kt +++ b/packages/kotlin-sdk/sdk/src/androidTest/kotlin/org/dashfoundation/dashsdk/FfiSmokeTest.kt @@ -154,28 +154,27 @@ class FfiSmokeTest { } } - /** - * Rust↔Kotlin persistence-descriptor lockstep. The trampolines resolve - * each bridge method by (name, descriptor) only when its slot first - * fires — for the invitation upsert that would be mid-way through a - * live, funded `create_invitation`. Resolving the whole table up front - * turns a descriptor typo into a CI failure instead. - */ /** * The private contact request scan state needs every override it - * depends on: JNI wires its slots only for a bridge that overrides the - * contact upsert native calls, the scan-state store and the scan-state - * load (a read position vouches for the requests an inherited no-op - * contact writer never stored). A bridge declaring the capability but - * leaving any one of them inherited is not attested; one implementing - * all three, and the Room handler, keep it. + * depends on, and atomic changesets: JNI wires its slots only for a + * bridge that overrides the contact upsert native calls, the + * scan-state store and the scan-state load (a read position vouches + * for the requests an inherited no-op contact writer never stored), and + * the wallet keeps the state only with `ATOMIC_CHANGESETS` too (an + * un-ignore stores the sender's removal and the position rewind in one + * changeset). JNI always wires the changeset begin and end callbacks, + * so the atomic half is the bridge's declaration. A bridge declaring + * both but leaving any override inherited, or declaring the scan state + * without atomic rounds, has neither; one implementing all three and + * declaring both, and the Room handler, keep both. */ @Test fun privateContactScanStateCapabilityNeedsEveryOverride() { NativeLoader.ensureLoaded() - val bit = NativePersistenceBridge.CAPABILITY_PRIVATE_CONTACT_REQUEST_SCAN_STATE + val bits = NativePersistenceBridge.CAPABILITY_PRIVATE_CONTACT_REQUEST_SCAN_STATE or + ATOMIC_CHANGESETS fun effective(bridge: NativePersistenceBridge): Boolean = - WalletManagerNative.nativeEffectivePersistenceCapabilityBits(bridge) and bit != 0L + WalletManagerNative.nativeEffectivePersistenceCapabilityBits(bridge) and bits == bits assertTrue("every override", effective(ScanStateBridge.Full())) assertFalse("no contact upsert", effective(ScanStateBridge.WithoutContactUpsert())) @@ -187,6 +186,13 @@ class FfiSmokeTest { override fun persistenceCapabilitiesBits(): Long = 0L }), ) + assertFalse( + "every override, no atomic changesets", + effective(object : ScanStateBridge.Full() { + override fun persistenceCapabilitiesBits(): Long = + NativePersistenceBridge.CAPABILITY_PRIVATE_CONTACT_REQUEST_SCAN_STATE + }), + ) val db = DashDatabase.createInMemory(ApplicationProvider.getApplicationContext()) try { @@ -199,6 +205,13 @@ class FfiSmokeTest { } } + /** + * Rust↔Kotlin persistence-descriptor lockstep. The trampolines resolve + * each bridge method by (name, descriptor) only when its slot first + * fires — for the invitation upsert that would be mid-way through a + * live, funded `create_invitation`. Resolving the whole table up front + * turns a descriptor typo into a CI failure instead. + */ @Test fun persistenceBridgeDescriptorsAllResolve() { NativeLoader.ensureLoaded() @@ -228,7 +241,7 @@ private abstract class ScanStateBridge : NativePersistenceBridge() { override fun persistenceCapabilitiesVersion(): Int = 1 override fun persistenceCapabilitiesBits(): Long = - CAPABILITY_PRIVATE_CONTACT_REQUEST_SCAN_STATE + CAPABILITY_PRIVATE_CONTACT_REQUEST_SCAN_STATE or ATOMIC_CHANGESETS open class Full : ScanStateBridge() { @Suppress("LongParameterList") @@ -343,3 +356,6 @@ private abstract class ScanStateBridge : NativePersistenceBridge() { ): Int = 0 } } + +/** `PersistenceCapabilities::ATOMIC_CHANGESETS` (bit 0). */ +private const val ATOMIC_CHANGESETS: Long = 1L From 47c02cb0746787c8c06afd08e0a0e5197660f49d Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 9 Nov 2026 16:22:45 +0700 Subject: [PATCH 165/269] docs(kotlin-sdk): attach the pending-replies KDoc to privateRepliesPending The block documenting `privateRepliesPending` sat above `privateContactReadiness`; it now sits directly above its own declaration. Co-Authored-By: Claude Opus 5.5 --- .../dashfoundation/dashsdk/tokens/Dashpay.kt | 28 +++++++++---------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/tokens/Dashpay.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/tokens/Dashpay.kt index 4686b9f5458..3af96e3fd4b 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/tokens/Dashpay.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/tokens/Dashpay.kt @@ -431,20 +431,6 @@ class Dashpay internal constructor(private val walletHandle: Long, } } - /** - * The contacts among [contactIds] that [ourIdentityId] has a private - * contact request to in flight, or paid for and not yet read back from - * the store, as the wallet records it, read from one fresh - * managed-identity snapshot. While one is, Rust refuses another private - * send or reply to it ([DashSdkError.PlatformWallet.PrivateContactRequestPending]), - * so a host shows it as pending and offers neither; a private sync - * settles it. Empty when the identity isn't managed. After a relaunch, a - * reply paid for in an earlier process is pending again once Rust - * restores it from the shielded store, which it does when the store is - * configured after the load or the wallet binds its shielded account. - * ← Swift `ManagedIdentity.privateReplyPending(contactId:)` - * (packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PrivateContactRequests.swift). - */ /** * What a contact request from [ourIdentityId] waits for, as the wallet * decides it ([PrivateContactReadiness]). A pure read of live wallet @@ -465,6 +451,20 @@ class Dashpay internal constructor(private val walletHandle: Long, ) } + /** + * The contacts among [contactIds] that [ourIdentityId] has a private + * contact request to in flight, or paid for and not yet read back from + * the store, as the wallet records it, read from one fresh + * managed-identity snapshot. While one is, Rust refuses another private + * send or reply to it ([DashSdkError.PlatformWallet.PrivateContactRequestPending]), + * so a host shows it as pending and offers neither; a private sync + * settles it. Empty when the identity isn't managed. After a relaunch, a + * reply paid for in an earlier process is pending again once Rust + * restores it from the shielded store, which it does when the store is + * configured after the load or the wallet binds its shielded account. + * ← Swift `ManagedIdentity.privateReplyPending(contactId:)` + * (packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PrivateContactRequests.swift). + */ suspend fun privateRepliesPending( ourIdentityId: ByteArray, contactIds: List, From 2abc9b594effdce7b4d547f635d1e099aca4296c Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 9 Nov 2026 16:22:45 +0700 Subject: [PATCH 166/269] docs(sdk): record code 73 and the atomic requirement in the parity manifest Co-Authored-By: Claude Opus 5.5 --- docs/sdk/sdk-parity-manifest.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/sdk/sdk-parity-manifest.json b/docs/sdk/sdk-parity-manifest.json index 37d0a2b510d..17e5a54f81c 100644 --- a/docs/sdk/sdk-parity-manifest.json +++ b/docs/sdk/sdk-parity-manifest.json @@ -783,7 +783,7 @@ }, { "id": "dashpay.private_contact_requests", - "title": "DashPay private contact requests: store sync, shielded send, accept replying in kind, private-reply accept, private contact and pending reads, codes 68 to 72, readiness", + "title": "DashPay private contact requests: store sync, shielded send, accept replying in kind, private-reply accept, private contact and pending reads, codes 68 to 73, readiness", "area": "correctness", "shared_apis": [ "platform_wallet_sync_private_contact_requests", @@ -803,13 +803,13 @@ "sdk": "partial", "example_app": "partial", "restart": "required", - "reason": "PrivateContactRequests.swift wraps the store sync, the shielded send, the accept replying in kind (the wallet replies privately to a request that came privately, and with a document otherwise; a public or adopted reply needs no shielded support or payment mnemonic read, and a build without shielded support refuses only a private reply, with code 69), the explicit private-reply accept, PrivateContactInfo and the wallet's pending-reply read, all signed with the identity signer the public calls take and counted as native manager operations; codes 68 to 72 are typed and pinned in ErrorHandlingTests. The sync, send and accepts have no automated or recorded gate: a private reply needs a protocol version 15 network with shielded funds. The private marker (core height 0) round-trips through the persisted coreHeightCreatedAt, but no test covers a restart, and the wallet keeps a pending reply in memory only. The example app has the send-privately toggle, accepts with acceptContactRequestReplyingInKind, keeps a pending or unconfirmed private send or reply pending by reading the wallet's flag (a failed read keeps the last snapshot and the controls off), offers a private sync for a pending request a public send or accept meets (code 71), and reports private refresh failures (quiet before protocol version 15), with no recorded gate. Code 72 (unrestored private payments) is typed apart from 71, and the readiness getter is bound; the example app shows \"restoring private payments\" from 72 or the readiness, lifted only by a readiness read that says restored, never by a private sync." + "reason": "PrivateContactRequests.swift wraps the store sync, the shielded send, the accept replying in kind (the wallet replies privately to a request that came privately, and with a document otherwise; a public or adopted reply needs no shielded support or payment mnemonic read, and a build without shielded support refuses only a private reply, with code 69), the explicit private-reply accept, PrivateContactInfo and the wallet's pending-reply read, all signed with the identity signer the public calls take and counted as native manager operations; codes 68 to 73 are typed and pinned in ErrorHandlingTests. The sync, send and accepts have no automated or recorded gate: a private reply needs a protocol version 15 network with shielded funds. The private marker (core height 0) round-trips through the persisted coreHeightCreatedAt, but no test covers a restart, and the wallet keeps a pending reply in memory only. The example app has the send-privately toggle, accepts with acceptContactRequestReplyingInKind, keeps a pending or unconfirmed private send or reply pending by reading the wallet's flag (a failed read keeps the last snapshot and the controls off), offers a private sync for a pending request a public send or accept meets (code 71), and reports private refresh failures (quiet before protocol version 15), with no recorded gate. Code 72 (unrestored private payments) is typed apart from 71, and the readiness getter is bound; the example app shows \"restoring private payments\" from 72 or the readiness, lifted only by a readiness read that says restored, never by a private sync. Code 73 (a store that cannot keep a private payment's recovery state) is typed and shown as a storage configuration error; the host declares atomic changesets with the scan state, so it should not occur." }, "kotlin": { "sdk": "partial", "example_app": "partial", "restart": "required", - "reason": "Dashpay.syncPrivateContactRequests, privateContact and privateRepliesPending (fresh managed-identity snapshots) and PlatformWalletManager.sendPrivateContactRequest / acceptContactRequestReplyingInKind / acceptPrivateContactRequest bridge the same FFI (signed with the manager's identity signer, behind the manager's teardown gate); the in-kind accept is exported in every build, and codes 68 to 72 map to typed PlatformWallet errors (DashSdkErrorTest). JNI descriptors are covered only by the FfiSmokeTest symbol test; the flows need a protocol version 15 network with shielded funds and have no recorded gate, no test covers a restart, and the wallet keeps a pending reply in memory only. The example app accepts with acceptContactRequestReplyingInKind, keeps a pending or unconfirmed reply pending by reading the wallet's flag on its Requests rows and Add Contact (a failed read keeps the last snapshot and the controls off), offers a private sync for a pending request a public send or accept meets (code 71), and reports private refresh failures (quiet before protocol version 15); it has no send-privately toggle. Code 72 (unrestored private payments) is typed apart from 71, and the readiness getter is bound; the example app shows \"restoring private payments\" from 72 or the readiness, lifted only by a readiness read that says restored, never by a private sync." + "reason": "Dashpay.syncPrivateContactRequests, privateContact and privateRepliesPending (fresh managed-identity snapshots) and PlatformWalletManager.sendPrivateContactRequest / acceptContactRequestReplyingInKind / acceptPrivateContactRequest bridge the same FFI (signed with the manager's identity signer, behind the manager's teardown gate); the in-kind accept is exported in every build, and codes 68 to 73 map to typed PlatformWallet errors (DashSdkErrorTest). JNI descriptors are covered only by the FfiSmokeTest symbol test; the flows need a protocol version 15 network with shielded funds and have no recorded gate, no test covers a restart, and the wallet keeps a pending reply in memory only. The example app accepts with acceptContactRequestReplyingInKind, keeps a pending or unconfirmed reply pending by reading the wallet's flag on its Requests rows and Add Contact (a failed read keeps the last snapshot and the controls off), offers a private sync for a pending request a public send or accept meets (code 71), and reports private refresh failures (quiet before protocol version 15); it has no send-privately toggle. Code 72 (unrestored private payments) is typed apart from 71, and the readiness getter is bound; the example app shows \"restoring private payments\" from 72 or the readiness, lifted only by a readiness read that says restored, never by a private sync. Code 73 (a store that cannot keep a private payment's recovery state) is typed and shown as a storage configuration error; the host declares atomic changesets with the scan state, so it should not occur." } }, "verification": [ From 27e4173d3b0bf852f4e86e32658c6bff63ac7064 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 9 Nov 2026 17:08:53 +0700 Subject: [PATCH 167/269] 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 168/269] 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 169/269] 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 170/269] 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 171/269] 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 47468faf8f1662e0d88382a6c4a5e8cb43c6b244 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 9 Nov 2026 18:30:01 +0700 Subject: [PATCH 172/269] docs(platform-wallet-ffi): the readiness getter consumes rejection notices, read it before the pending snapshot The readiness getter now releases the private requests whose payment was settled as never executing, taking the wallet's write lock briefly to consume its rejection notices. Its FFI and Swift docs called it a pure read. They now say what it does and that it is read before the managed-identity pending snapshot, which does not consume the notices. Co-Authored-By: Claude Opus 5.5 --- .../src/contact_request.rs | 8 +++++++- .../src/private_contact_request.rs | 8 ++++++-- .../PlatformWallet/PrivateContactRequests.swift | 17 +++++++++++++---- 3 files changed, 26 insertions(+), 7 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..cc2e3752fba 100644 --- a/packages/rs-platform-wallet-ffi/src/contact_request.rs +++ b/packages/rs-platform-wallet-ffi/src/contact_request.rs @@ -265,7 +265,13 @@ impl From for PrivateContactReadinessFFI { /// 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. +/// request by the wallet's own state. Not a pure read: it first releases the +/// private requests whose payment was settled as never executing, briefly +/// taking the wallet's write lock to consume the wallet's rejection notices, +/// so a pair rejected for good reads as free with no restart and no other +/// request; then it reads live wallet state. Read it before +/// `managed_identity_get_private_reply_pending`, whose snapshot does not +/// consume the notices. /// /// `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 diff --git a/packages/rs-platform-wallet-ffi/src/private_contact_request.rs b/packages/rs-platform-wallet-ffi/src/private_contact_request.rs index 2ca99108beb..3ca258d32cd 100644 --- a/packages/rs-platform-wallet-ffi/src/private_contact_request.rs +++ b/packages/rs-platform-wallet-ffi/src/private_contact_request.rs @@ -305,8 +305,12 @@ pub unsafe extern "C" fn managed_identity_get_private_contact( /// the wallet refuses another private send or reply to the contact with /// `ErrorPrivateContactRequestPending`, so a host shows the contact as pending /// and offers neither; a later `platform_wallet_sync_private_contact_requests` -/// settles it. A pure read of the managed-identity snapshot. The wallet keeps -/// it in memory only, so it is false after a restart. +/// settles it. A read of the managed-identity snapshot, which does not consume +/// the wallet's rejection notices: read +/// `platform_wallet_get_private_contact_readiness` (or refresh) first, so a +/// pair whose payment was rejected for good is already released. After a +/// restart a reply paid for in an earlier process is pending again once the +/// wallet restores its private payments from the shielded store. /// /// # Safety /// - `contact_id` must point to 32 readable bytes. diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PrivateContactRequests.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PrivateContactRequests.swift index 3a40d504f8a..f87db4a91ff 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PrivateContactRequests.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PrivateContactRequests.swift @@ -124,7 +124,10 @@ extension ManagedIdentity { /// contact (`PlatformWalletError.privateContactRequestPending`), so show /// the contact as pending and offer neither; a private sync settles it. /// Read from this snapshot: take a fresh `ManagedIdentity` to see a - /// change. After a relaunch, a reply paid for in an earlier process is + /// change. The snapshot does not consume the wallet's rejection notices: + /// read `ManagedPlatformWallet.privateContactReadiness(identityId:)` (or + /// refresh) first, so a pair whose payment was rejected for good is + /// already released. After a relaunch, a reply paid for in an earlier process is /// pending again once Rust restores it from the shielded store, which it /// does when the store is configured after the load /// (`PlatformWalletManager.configureShielded`) or the wallet binds its @@ -141,9 +144,15 @@ extension ManagedIdentity { extension ManagedPlatformWallet { /// What a contact request from `identityId`, one of this wallet's - /// identities, waits for, as the wallet decides it. A pure read of live - /// wallet state; read it again after the shielded store is configured or - /// the wallet binds its shielded account to see the restore. + /// identities, waits for, as the wallet decides it. Not a pure read: it + /// first releases the private requests whose payment was settled as never + /// executing, briefly taking the wallet's write lock to consume the + /// wallet's rejection notices, so a pair rejected for good reads as free + /// with no restart and no other request; then it reads live wallet state. + /// Read it before `ManagedIdentity.privateReplyPending(contactId:)`, whose + /// snapshot does not consume the notices, and again after the shielded + /// store is configured or the wallet binds its shielded account to see + /// the restore. public func privateContactReadiness(identityId: Identifier) throws -> PrivateContactReadiness { var ffi = PrivateContactReadinessFFI() let handle = self.handle From 4f3bd84732c79ba3d3a273c36d751b564dc70aff Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 9 Nov 2026 19:48:32 +0700 Subject: [PATCH 173/269] 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 174/269] 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 175/269] 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 176/269] 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 177/269] 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 178/269] 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 179/269] 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 180/269] 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 181/269] 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 182/269] 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 183/269] 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 184/269] 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 185/269] 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 186/269] 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 187/269] 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 188/269] 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 189/269] 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 190/269] 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 191/269] 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 192/269] 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 193/269] 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 194/269] 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(); From 6789d60039fc283982249e10ea8bb7904896589e Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Tue, 10 Nov 2026 01:45:12 +0700 Subject: [PATCH 195/269] fix(platform-wallet): publish a never-delivered payment's retirement before awaiting Once a never-delivered private payment's record and journal records were retired and its notes released, the settlement recorded its activity, which can wait for the store lock, before noting the discharged commitments and the never-delivered notice. A send cancelled during that wait lost both: the pair stayed reserved for an entry nothing could settle, until a restart. The notices are now published right after the retirement, with nothing awaited in between, and the activity row is updated after. The settlement also takes the record's activity id from its pending entry rather than a second argument that could name another record. Co-Authored-By: Claude Opus 5.5 --- .../network/private_contact_requests.rs | 125 +++++++++++++++++- .../src/wallet/shielded/operations.rs | 117 +++++++++++++--- 2 files changed, 223 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 ea32bde680c..f951bb366e7 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 @@ -2307,7 +2307,9 @@ mod tests { private_payment_journal_snapshot, PrunedAnchorEvidence, }; #[cfg(feature = "shielded")] - use crate::wallet::shielded::operations::arm_redrive_before_broadcast; + use crate::wallet::shielded::operations::{ + arm_redrive_before_broadcast, pay_private_document_refused_before_delivery, + }; #[cfg(feature = "shielded")] use crate::wallet::shielded::{ FileBackedShieldedStore, NetworkShieldedCoordinator, OrchardKeySet, PrivateContactPayment, @@ -6948,6 +6950,127 @@ mod tests { } } + /// A private payment refused before anything was delivered is retired, + /// its record and journal records with it, and the retirement is published + /// before the settlement waits for the store to record its activity. So a + /// send cancelled during that wait still leaves its pair free: a retry to + /// the pair pays, with no restart. + #[cfg(feature = "shielded")] + #[tokio::test] + async fn should_free_a_pair_whose_undelivered_settlement_was_cancelled_after_retirement() { + use crate::wallet::shielded::{ + ShieldedActivityEntry, ShieldedActivityKind, ShieldedActivityStatus, ShieldedDirection, + }; + use std::future::Future; + use std::task::{Context, Waker}; + + let fixture = ExpiryFixture::new().await; + let pay = { + let alice = identity(ALICE_PHRASE, ALICE); + let bob = identity(BOB_PHRASE, BOB); + let store = Arc::clone(fixture.coordinator.store()); + let persister = fixture.wallet.persister().clone(); + let wallet_id = fixture.wallet.wallet_id(); + let subwallet = fixture.subwallet; + let input = fixture.input.clone(); + move |entry: BinaryData, started: PaymentStarted| async move { + let account_reference = started + .account_reference() + .expect("the send names its request"); + pay_private_document_refused_before_delivery( + &store, + Some(&persister), + wallet_id, + subwallet, + &[input], + ShieldedActivityEntry { + id: [0x7D; 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![], + }, + private_contact_request_payment(entry), + PrivateContactPayment { + sender: alice.id().to_buffer(), + recipient: bob.id().to_buffer(), + account_reference, + }, + &started, + ) + .await + } + }; + let wallet_id = fixture.wallet.wallet_id(); + let store = fixture.coordinator.store(); + let mut cx = Context::from_waker(Waker::noop()); + let mut send = Box::pin(fixture.send_again(pay)); + // Step the send one store section at a time: each turn a write of the + // test's own is queued behind the send's, so the send runs one + // section and then waits behind it. Stop once the payment was + // journaled and then retired: the send then waits to record its + // activity. + let mut held = store.write().await; + let mut journaled = false; + let mut retired = false; + for _ in 0..64 { + let records = held + .private_payment_journal(wallet_id) + .expect("journal") + .len(); + journaled |= records > 0; + if journaled && records == 0 { + retired = true; + break; + } + let mut next = Box::pin(store.write()); + assert!( + send.as_mut().poll(&mut cx).is_pending(), + "the send waits for the store" + ); + assert!(next.as_mut().poll(&mut cx).is_pending()); + drop(held); + assert!( + send.as_mut().poll(&mut cx).is_pending(), + "the send waits behind the test's write" + ); + held = match next.as_mut().poll(&mut cx) { + std::task::Poll::Ready(guard) => guard, + std::task::Poll::Pending => panic!("the test's write is next"), + }; + } + assert!(retired, "the payment was journaled, then retired"); + assert_eq!( + held.get_activity_by_entry_id(fixture.subwallet, &[0x7D; 32]) + .expect("activity") + .map(|entry| entry.status), + Some(ShieldedActivityStatus::Pending), + "the send waits for the store to record its activity" + ); + // Cancelled there. + drop(send); + drop(held); + + 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"); + } + /// A private payment that never executes is proven expired once its /// anchor is pruned and its entry is absent from the store: the /// coordinator discharges its journal record, the pair is freed, a retry diff --git a/packages/rs-platform-wallet/src/wallet/shielded/operations.rs b/packages/rs-platform-wallet/src/wallet/shielded/operations.rs index bd8a0fb9eba..48358ce6cce 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/operations.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/operations.rs @@ -1832,14 +1832,12 @@ 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, @@ -1865,12 +1863,17 @@ where /// 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. +/// its activity entry's id (`pending_entry`), 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. +/// +/// A retirement is published at once, with nothing awaited in between: the +/// discharged commitments on `persister` and the never-delivered notice on +/// `payment_started`. The activity row is updated after, so a settlement +/// cancelled while it waits for the store still leaves the pair free. #[allow(clippy::too_many_arguments)] async fn settle_private_document_payment( store: &Arc>, @@ -1878,7 +1881,6 @@ 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]]>, @@ -1927,7 +1929,7 @@ async fn settle_private_document_payment( never_delivered: true, }) => { let commitments = journaled.unwrap_or_default(); - let retired = match activity_id { + let retired = match pending_entry.as_ref().map(|entry| entry.id) { Some(activity_id) => { reservation .retire_undelivered(&activity_id, commitments) @@ -1949,6 +1951,15 @@ async fn settle_private_document_payment( ), }); } + // Published before anything is awaited: retired, nothing can + // execute it, and a settlement dropped from here on must still + // leave its pair free. + if journaled.is_some() { + if let Some(persister) = persister { + persister.note_discharged_private_entries(commitments.iter().copied()); + } + payment_started.note_never_delivered(); + } record_activity_status( store, persister, @@ -1959,12 +1970,6 @@ async fn settle_private_document_payment( 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, .. }) => { @@ -1987,6 +1992,63 @@ async fn settle_private_document_payment( } } +/// A private document payment from `notes` whose broadcast is refused before +/// anything was delivered, saved and settled as the private flow does +/// ([`submit_private_document_payment`]): for tests outside this module that +/// drive a settlement through a contact request's pair. +#[cfg(test)] +#[allow(clippy::too_many_arguments)] +pub(crate) async fn pay_private_document_refused_before_delivery( + store: &Arc>, + persister: Option<&WalletPersister>, + wallet_id: WalletId, + id: SubwalletId, + notes: &[ShieldedNote], + pending_entry: super::activity::ShieldedActivityEntry, + state_transition: StateTransition, + private_contact: PrivateContactPayment, + payment_started: &PaymentStarted, +) -> Result { + // Reserved and recorded pending, as the private flow does before it + // submits. + let reservation = { + let mut locked = store.write().await; + locked + .save_activity(id, &pending_entry) + .map_err(|e| PlatformWalletError::ShieldedStoreError(e.to_string()))?; + mark_notes_pending(&mut *locked, store, id, notes)? + }; + let journaled = private_document_commitments(&state_transition).ok_or_else(|| { + PlatformWalletError::ShieldedBuildError("the payment's entries do not read".to_string()) + })?; + submit_private_document_payment( + PrivateDocumentSubmission { + store, + persister, + wallet_id, + id, + pending_entry: Some(pending_entry), + selected_notes: notes, + reservation, + state_transition, + anchor: [7; 32], + anchor_height: 0, + private_contact: Some(private_contact), + journaled: Some(&journaled), + payment_started, + }, + |_| { + std::future::ready(Err(SpendFailure { + error: PlatformWalletError::ShieldedBroadcastFailed( + "refused before delivery".to_string(), + ), + never_delivered: true, + })) + }, + ) + .await +} + // ------------------------------------------------------------------------- // ShieldFromAssetLock: Core L1 asset lock -> shielded pool (Type 18) // (orchestrated entry point lives in `wallet/shielded/fund_from_asset_lock.rs`) @@ -5259,6 +5321,26 @@ mod redrive_tests { } } + /// A pending activity entry keyed `id`, as a spend records before it + /// broadcasts. + fn pending_entry(id: [u8; 32]) -> ShieldedActivityEntry { + ShieldedActivityEntry { + id, + 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![], + } + } + /// 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 @@ -5386,8 +5468,7 @@ mod redrive_tests { None, wallet_id, id, - &None, - Some([0x77; 32]), + &Some(pending_entry([0x77; 32])), reservation, &selected, Some(&[commitment]), From 5ff0302476e2eb0d4d3a60b6c4431c4e0a40fb65 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Tue, 10 Nov 2026 01:49:58 +0700 Subject: [PATCH 196/269] refactor(platform-wallet): keep an unknown journal anchor height as None in memory PrivatePaymentRecord kept an unknown anchor height as u64::MAX, while PendingRedrive and StalePendingSpend keep the same fact as Option, and the expiry passed Some(u64::MAX) into the shared pruned-anchor rule. The record now holds Option; u64::MAX stays only in its bytes, so format 3 reads and writes as before, and the rule gets None, which it never takes as proof. A record migrated from a saved redrive takes that record's anchor height, unknown for one saved before heights were kept. Co-Authored-By: Claude Opus 5.5 --- .../src/wallet/platform_wallet.rs | 7 ++- .../src/wallet/shielded/coordinator.rs | 4 +- .../src/wallet/shielded/file_store.rs | 4 +- .../src/wallet/shielded/store.rs | 58 +++++++++++++++++-- 4 files changed, 59 insertions(+), 14 deletions(-) diff --git a/packages/rs-platform-wallet/src/wallet/platform_wallet.rs b/packages/rs-platform-wallet/src/wallet/platform_wallet.rs index a3ec4a49bbe..5eb1c34f374 100644 --- a/packages/rs-platform-wallet/src/wallet/platform_wallet.rs +++ b/packages/rs-platform-wallet/src/wallet/platform_wallet.rs @@ -3098,9 +3098,10 @@ 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, + // As the record keeps it: unknown for one saved before + // heights were kept, whose expiry is then never proven, + // so its pair stays held. + anchor_height: redrive.anchor_height, 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 2a53ce7ddbe..47495851274 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/coordinator.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/coordinator.rs @@ -1964,9 +1964,7 @@ impl NetworkShieldedCoordinator { let recorded_height = evidence.height; let pruned: Vec<&(WalletId, PrivatePaymentRecord)> = journal .iter() - .filter(|(_, record)| { - evidence.proves_pruned(&record.anchor, Some(record.anchor_height)) - }) + .filter(|(_, record)| evidence.proves_pruned(&record.anchor, record.anchor_height)) .collect(); if pruned.is_empty() { return; 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 799d152febc..7b8cb80cae6 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/file_store.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/file_store.rs @@ -1084,7 +1084,7 @@ impl ShieldedStore for FileBackedShieldedStore { account_index: id.account_index, activity_id: redrive.activity_id, anchor: redrive.anchor, - anchor_height, + anchor_height: Some(anchor_height), commitment: *commitment, payment, nullifiers: redrive.nullifiers.clone(), @@ -2985,7 +2985,7 @@ mod tests { account_index: 1, activity_id: redrive.activity_id, anchor: redrive.anchor, - anchor_height: 0, + anchor_height: Some(0), 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 fc2f906faf9..d62878a3296 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/store.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/store.rs @@ -242,9 +242,11 @@ pub struct PrivatePaymentRecord { 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, + /// or above this height shows it pruned. `None` when not known, as for a + /// payment saved before the journal or the redrive record kept it, whose + /// expiry is then never proven. Kept as `u64::MAX` in the record's bytes + /// ([`Self::to_bytes`]). + pub anchor_height: Option, /// The commitment of the entry it appends, which keys the record. pub commitment: [u8; 32], /// The request it pays for. @@ -254,6 +256,10 @@ pub struct PrivatePaymentRecord { } impl PrivatePaymentRecord { + /// How an unknown [`Self::anchor_height`] is kept in the record's bytes: + /// no proved height reaches it. + const UNKNOWN_ANCHOR_HEIGHT: u64 = u64::MAX; + /// 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. @@ -279,7 +285,12 @@ 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 + .anchor_height + .unwrap_or(Self::UNKNOWN_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); @@ -317,7 +328,8 @@ impl PrivatePaymentRecord { account_index: u32::from_le_bytes(*account_index), activity_id: *activity_id, anchor: *anchor, - anchor_height: u64::from_le_bytes(*anchor_height), + anchor_height: Some(u64::from_le_bytes(*anchor_height)) + .filter(|height| *height != Self::UNKNOWN_ANCHOR_HEIGHT), commitment: *commitment, payment: PrivateContactPayment { sender: *sender, @@ -1386,7 +1398,7 @@ impl ShieldedStore for InMemoryShieldedStore { account_index: id.account_index, activity_id: redrive.activity_id, anchor: redrive.anchor, - anchor_height, + anchor_height: Some(anchor_height), commitment: *commitment, payment, nullifiers: redrive.nullifiers.clone(), @@ -2120,6 +2132,40 @@ mod tests { ); } + /// A journal record's unknown anchor height is `None` in memory and + /// `u64::MAX` only in its bytes, as format 3 keeps it; a known one reads + /// back as it was. + #[test] + fn should_keep_an_unknown_journal_anchor_height_as_none_across_its_bytes() { + let record = |anchor_height| PrivatePaymentRecord { + account_index: 2, + activity_id: [0x61; 32], + anchor: [0x62; 32], + anchor_height, + commitment: [0x63; 32], + payment: PrivateContactPayment { + sender: [0x64; 32], + recipient: [0x65; 32], + account_reference: 3, + }, + nullifiers: vec![[0x66; 32]], + }; + for anchor_height in [None, Some(0), Some(41)] { + let bytes = record(anchor_height).to_bytes().expect("encodes"); + let height_at = 1 + 4 + 32 + 32; + assert_eq!( + bytes[height_at..height_at + 8], + anchor_height.unwrap_or(u64::MAX).to_le_bytes(), + "{anchor_height:?}" + ); + assert_eq!( + PrivatePaymentRecord::from_bytes(&bytes), + Some(record(anchor_height)), + "{anchor_height:?}" + ); + } + } + /// The fund-safe release: once the anchor is pruned (absent from the /// recorded set) the spend can never execute, so the reservation is /// released and the freed note becomes spendable again. From 17497a4262a190a22362c7f6b84522508db3985b Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Tue, 10 Nov 2026 02:08:35 +0700 Subject: [PATCH 197/269] fix(platform-wallet): hold an unresolved private payment's pair at the wallet, beyond its sender An unresolved private payment's pair reservation lived only on the sender's ManagedIdentity. Removing the identity dropped it, and loading the identity again created a fresh one with no hold: a public request to the pair could then go out while the signed private payment could still execute, publishing the relationship it kept off chain. The wallet's persister now mirrors, by entry commitment, the pair of every payment the shielded store's journal keeps unresolved. The pay path notes a payment just before its journal records are saved (and forgets it when they are not), every restoration from the journal notes those it finds unresolved, and a pair leaves the mirror only when its record is discharged. The public claim and the private reservation both refuse a pair the mirror holds, whatever happened to the sender identity, so the identity's reservation is a cache of the journal. Co-Authored-By: Claude Opus 5.5 --- .../identity/network/contact_requests.rs | 14 +- .../network/private_contact_requests.rs | 310 +++++++++++++++++- .../src/wallet/persister.rs | 60 +++- .../src/wallet/shielded/operations.rs | 105 +++++- 4 files changed, 467 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 e3fada24bff..4cea51b95b8 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 @@ -804,11 +804,14 @@ impl DashPayView<'_, B> { /// 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. + /// reserved, or a private payment the shielded store's journal keeps + /// unresolved holds the pair, whether or not the sender identity was + /// removed and added again since + /// (`WalletPersister::private_pair_journaled`), 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, @@ -830,6 +833,7 @@ impl DashPayView<'_, B> { || self .persister .absent_sender_private_pair_held(sender, recipient) + || self.persister.private_pair_journaled(sender, recipient) { return Err(PlatformWalletError::PrivateContactRequestPending( *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 f951bb366e7..09d4a92dfa5 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 @@ -432,6 +432,10 @@ pub(crate) struct ReservedPrivatePayments { /// 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)>, + /// Every payment left unresolved, reserved or with its sender absent, as + /// (entry commitment, sender, recipient). + #[cfg_attr(not(feature = "shielded"), allow(dead_code))] + pub(crate) unresolved: Vec<([u8; 32], Identifier, Identifier)>, } /// The one step every restoration of a private contact request payment's @@ -475,6 +479,7 @@ pub(crate) fn reserve_unresolved_private_payments( } let Some(managed) = info.identity_manager.managed_identity_mut(&sender) else { reserved.absent.insert((sender, recipient)); + reserved.unresolved.push((commitment, sender, recipient)); continue; }; if managed @@ -484,6 +489,7 @@ pub(crate) fn reserve_unresolved_private_payments( { continue; } + reserved.unresolved.push((commitment, sender, recipient)); let first = !reserved.restored.contains(&(sender, recipient)); if paying == Some((sender, recipient)) { if first { @@ -515,10 +521,17 @@ pub(crate) async fn restore_journaled_private_payments( 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( + let ReservedPrivatePayments { + restored, + absent, + unresolved, + } = reserve_unresolved_private_payments( info, generation, wallet_id, persister, payments, None, )?; persister.hold_absent_sender_private_pairs(absent); + // The wallet holds each unresolved payment's pair too, beyond its sender + // identity, until its record is discharged. + persister.note_journaled_private_payments(unresolved); // Under the same lock, after the reservations: a send that sees an // identity's hold gone sees them. let identity_ids: Vec = info @@ -866,9 +879,14 @@ 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 + // Held beyond the sender identity: a journaled payment's pair outlives + // the identity's removal, as does an absent sender's. + let held_by_wallet = self .persister - .absent_sender_private_pair_held(sender_identity_id, recipient_identity_id); + .absent_sender_private_pair_held(sender_identity_id, recipient_identity_id) + || self + .persister + .private_pair_journaled(sender_identity_id, recipient_identity_id); let reserved = self .with_managed(sender_identity_id, |managed| { if adopt_reply @@ -879,7 +897,7 @@ impl DashPayView<'_, B> { { return None; } - if absent_sender_held { + if held_by_wallet { return Some(None); } Some(managed.reserve_private_reply(*recipient_identity_id)) @@ -2308,7 +2326,7 @@ mod tests { }; #[cfg(feature = "shielded")] use crate::wallet::shielded::operations::{ - arm_redrive_before_broadcast, pay_private_document_refused_before_delivery, + arm_redrive_before_broadcast, pay_private_document_with_outcome, TestBroadcastOutcome, }; #[cfg(feature = "shielded")] use crate::wallet::shielded::{ @@ -6977,7 +6995,7 @@ mod tests { let account_reference = started .account_reference() .expect("the send names its request"); - pay_private_document_refused_before_delivery( + pay_private_document_with_outcome( &store, Some(&persister), wallet_id, @@ -7005,6 +7023,7 @@ mod tests { account_reference, }, &started, + TestBroadcastOutcome::RefusedBeforeDelivery, ) .await } @@ -7071,6 +7090,285 @@ mod tests { assert_eq!(retry.lock().expect("retry").len(), 1, "the retry pays"); } + /// An unresolved private payment holds its pair at the wallet, beyond its + /// sender identity: removed and loaded again through + /// `load_identity_by_index_from_master` (the load an external-signable + /// wallet takes, sharing `load_identity_by_index`'s body), the identity + /// starts with no reservation, yet + /// a public request to the pair is refused (71), and so is a private one, + /// until the payment resolves. Then a private one pays. + #[cfg(feature = "shielded")] + #[tokio::test] + async fn should_hold_a_pair_through_its_sender_s_removal_and_reload() { + use crate::wallet::shielded::{ + ShieldedActivityEntry, ShieldedActivityKind, ShieldedActivityStatus, ShieldedDirection, + }; + use dash_sdk::platform::types::identity::PublicKeyHash; + use dpp::identity::identity_public_key::methods::hash::IdentityPublicKeyHashMethodsV0; + use key_wallet::bip32::ExtendedPrivKey; + + 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 fixture = ExpiryFixture::new().await; + let wallet_id = fixture.wallet.wallet_id(); + let paid = Arc::new(Mutex::new(Vec::new())); + let pay = { + let store = Arc::clone(fixture.coordinator.store()); + let persister = fixture.wallet.persister().clone(); + let subwallet = fixture.subwallet; + let input = fixture.input.clone(); + 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 account_reference = started + .account_reference() + .expect("the send names its request"); + pay_private_document_with_outcome( + &store, + Some(&persister), + wallet_id, + subwallet, + &[input], + ShieldedActivityEntry { + id: [0x7E; 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![], + }, + private_contact_request_payment(entry), + PrivateContactPayment { + sender: sender.to_buffer(), + recipient: recipient.to_buffer(), + account_reference, + }, + &started, + TestBroadcastOutcome::Unconfirmed, + ) + .await + } + }; + let sent = fixture.send_again(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"); + + // The sender identity is removed and loaded again from Platform. + { + let iw = fixture.wallet.identity(); + let mut wm = iw.wallet_manager.write().await; + wm.get_wallet_info_mut(&wallet_id) + .expect("the wallet") + .identity_manager + .remove_identity(&alice.id(), &iw.persister) + .expect("removed"); + } + let probe = alice.public_keys()[&SIGNING_KEY_ID] + .public_key_hash() + .expect("a key hash"); + let mut sdk = (*fixture.wallet.identity().sdk).clone(); + sdk.mock() + .expect_fetch(PublicKeyHash(probe), Some(alice.clone())) + .await + .expect("the identity at index 0"); + fixture + .wallet + .identity() + .load_identity_by_index_from_master( + 0, + &ExtendedPrivKey::new_master(Network::Testnet, &seed(ALICE_PHRASE)) + .expect("the master key"), + ) + .await + .expect("loaded") + .expect("found at index 0"); + managed(&fixture.wallet, ALICE, |managed| { + assert!( + !managed.dashpay().private_reply_pending(&bob.id()), + "the reloaded identity holds no reservation" + ); + }) + .await; + + let public = fixture + .wallet + .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::PrivateContactRequestPending(_)) + ), + "public: {public:?}" + ); + let retry = Arc::new(Mutex::new(Vec::new())); + let private = fixture.send_again(keep_entry(&retry)).await; + assert!( + matches!( + private, + Err(PlatformWalletError::PrivateContactRequestPending(_)) + ), + "private: {private:?}" + ); + assert!(retry.lock().expect("retry").is_empty(), "nothing paid"); + + // Resolved: its entry is read back and its record discharged. + fixture + .coordinator + .store() + .write() + .await + .discharge_private_payment(wallet_id, &commitment) + .expect("discharged"); + fixture + .wallet + .persister() + .note_discharged_private_entries([commitment]); + let private = fixture.send_again(keep_entry(&retry)).await; + assert!( + matches!( + private, + Err(PlatformWalletError::PrivateContactRequestPending(_)) + ), + "paid, not read back: {private:?}" + ); + assert_eq!(retry.lock().expect("retry").len(), 1, "the retry pays"); + } + + /// The wallet holds a private payment's pair from just before its journal + /// records are saved; a payment whose records were not saved, here one + /// spending no note, paid nothing and holds nothing. + #[cfg(feature = "shielded")] + #[tokio::test] + async fn should_not_hold_a_pair_for_a_payment_whose_journal_records_were_not_saved() { + use crate::wallet::shielded::{ + ShieldedActivityEntry, ShieldedActivityKind, ShieldedActivityStatus, ShieldedDirection, + }; + + let alice = identity(ALICE_PHRASE, ALICE); + let bob = identity(BOB_PHRASE, BOB); + let fixture = ExpiryFixture::new().await; + let persister = fixture.wallet.persister().clone(); + let paid = pay_private_document_with_outcome( + fixture.coordinator.store(), + Some(&persister), + fixture.wallet.wallet_id(), + fixture.subwallet, + &[], + ShieldedActivityEntry { + id: [0x7F; 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![], + }, + private_contact_request_payment(BinaryData::new(vec![0x5F; 589])), + PrivateContactPayment { + sender: alice.id().to_buffer(), + recipient: bob.id().to_buffer(), + account_reference: 1, + }, + &PaymentStarted::default(), + TestBroadcastOutcome::Unconfirmed, + ) + .await; + assert!(paid.is_err(), "{paid:?}"); + assert!(fixture + .coordinator + .store() + .read() + .await + .private_payment_journal(fixture.wallet.wallet_id()) + .expect("journal") + .is_empty()); + assert!(!persister.private_pair_journaled(&alice.id(), &bob.id())); + } + + /// A payment a restoration from the journal finds unresolved holds its + /// pair at the wallet too: its sender removed and added again, a public + /// request to the pair is still refused (71). + #[cfg(feature = "shielded")] + #[tokio::test] + async fn should_hold_a_restored_payment_s_pair_through_its_sender_s_removal() { + 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 restored = view + .restore_private_payments(&[(alice.id(), bob.id(), 1, [0x5E; 32])], true) + .await + .expect("restored"); + assert_eq!(restored, vec![(alice.id(), bob.id())]); + { + let iw = alice_wallet.identity(); + let mut wm = iw.wallet_manager.write().await; + let manager = &mut wm + .get_wallet_info_mut(&alice_wallet.wallet_id()) + .expect("the wallet") + .identity_manager; + manager + .remove_identity(&alice.id(), &iw.persister) + .expect("removed"); + manager + .add_identity(alice.clone(), 0, alice_wallet.wallet_id(), &iw.persister) + .expect("added again"); + } + 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(_)) + ), + "{public:?}" + ); + } + /// A private payment that never executes is proven expired once its /// anchor is pruned and its entry is absent from the store: the /// coordinator discharges its journal record, the pair is freed, a retry diff --git a/packages/rs-platform-wallet/src/wallet/persister.rs b/packages/rs-platform-wallet/src/wallet/persister.rs index f2f3ba3297b..e8088c1a8c2 100644 --- a/packages/rs-platform-wallet/src/wallet/persister.rs +++ b/packages/rs-platform-wallet/src/wallet/persister.rs @@ -4,7 +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::collections::{BTreeMap, BTreeSet}; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; @@ -88,7 +88,8 @@ pub struct WalletPersister { } /// The commitments [`WalletPersister::note_discharged_private_entries`] noted, -/// both sets updated under one lock. +/// and the pairs the journal's unresolved payments hold, all updated under one +/// lock. #[derive(Default)] struct DischargedPrivateEntries { /// Noted since the last take, for the wallet to release the reservations @@ -100,6 +101,14 @@ struct DischargedPrivateEntries { /// ([`WalletPersister::private_entry_discharged`]). Only a shielded build /// keeps a journal to discharge: empty otherwise. session: BTreeSet<[u8; 32]>, + /// The (sender, recipient) pair of each private contact request payment + /// the shielded store's journal keeps, by its entry's commitment + /// ([`WalletPersister::note_journaled_private_payments`]): the wallet's + /// hold on the pair, which no identity's removal drops. A commitment in + /// `session` is never added, and one noted there is removed, so a + /// discharged payment holds nothing. Only a shielded build journals: + /// empty otherwise. + journaled: BTreeMap<[u8; 32], (Identifier, Identifier)>, } impl WalletPersister { @@ -174,9 +183,56 @@ impl WalletPersister { for commitment in commitments { discharged.unreleased.insert(commitment); discharged.session.insert(commitment); + discharged.journaled.remove(&commitment); } } + /// Note `payments`, each a private contact request payment's entry + /// commitment with its (sender, recipient) pair, as kept, or about to be + /// kept, by the shielded store's private payment journal: until each is + /// discharged ([`Self::note_discharged_private_entries`]) the wallet holds + /// its pair ([`Self::private_pair_journaled`]), whatever happens to the + /// sender identity. A payment discharged in this session is not noted. + /// Noted before a payment is journaled, and by every restoration from the + /// journal, so the pairs held cover every record the journal keeps. + #[cfg(feature = "shielded")] + pub(crate) fn note_journaled_private_payments( + &self, + payments: impl IntoIterator, + ) { + let mut entries = self.discharged_entries(); + for (commitment, sender, recipient) in payments { + if !entries.session.contains(&commitment) { + entries.journaled.insert(commitment, (sender, recipient)); + } + } + } + + /// Forget `commitments`, noted by [`Self::note_journaled_private_payments`] + /// for a payment whose journal records were then not saved: nothing was + /// paid, so its pair is not held for it. + #[cfg(feature = "shielded")] + pub(crate) fn forget_journaled_private_payments(&self, commitments: &[[u8; 32]]) { + let mut entries = self.discharged_entries(); + for commitment in commitments { + entries.journaled.remove(commitment); + } + } + + /// Whether a private payment the journal keeps unresolved holds the pair + /// `sender`, `recipient` ([`Self::note_journaled_private_payments`]): then + /// neither a public request nor another private one is sent for it. + pub(crate) fn private_pair_journaled( + &self, + sender: &Identifier, + recipient: &Identifier, + ) -> bool { + self.discharged_entries() + .journaled + .values() + .any(|pair| *pair == (*sender, *recipient)) + } + /// Whether the journal record of `commitment` was discharged in this /// session, its reservation released or not. pub(crate) fn private_entry_discharged(&self, commitment: &[u8; 32]) -> bool { diff --git a/packages/rs-platform-wallet/src/wallet/shielded/operations.rs b/packages/rs-platform-wallet/src/wallet/shielded/operations.rs index 48358ce6cce..eeb1776448a 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/operations.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/operations.rs @@ -1800,6 +1800,9 @@ where journaled, payment_started, } = submission; + // The wallet holds the pair before the payment is journaled, beyond its + // sender identity, until the journal records are discharged. + let mut pair_hold = JournaledPairHold::note(persister, private_contact, journaled); let result = async { let anchored = arm_pending_release( store, @@ -1823,6 +1826,7 @@ where anchor_height, ) .await?; + pair_hold.saved(); if anchored { // Each reservation carries its anchor: a pruned anchor releases it. reservation.hand_over(); @@ -1832,6 +1836,7 @@ where } .await; + drop(pair_hold); settle_private_document_payment( store, persister, @@ -1847,6 +1852,68 @@ where .await } +/// The wallet's hold on a private contact request payment's pair, noted +/// before the payment's journal records are saved +/// ([`WalletPersister::note_journaled_private_payments`]), so the pairs held +/// cover every record the journal keeps. Dropped before [`Self::saved`], the +/// records were not saved and nothing was paid: the hold is forgotten. +struct JournaledPairHold<'a> { + persister: Option<&'a WalletPersister>, + commitments: &'a [[u8; 32]], + saved: bool, +} + +impl<'a> JournaledPairHold<'a> { + /// Hold `payment`'s pair for `commitments`, the entries it appends, on + /// `persister`. Holds nothing for a payment that is not for a private + /// contact request. + fn note( + persister: Option<&'a WalletPersister>, + payment: Option, + commitments: Option<&'a [[u8; 32]]>, + ) -> Self { + let (Some(persister), Some(payment), Some(commitments)) = (persister, payment, commitments) + else { + return Self { + persister: None, + commitments: &[], + saved: true, + }; + }; + let (sender, recipient) = ( + Identifier::from(payment.sender), + Identifier::from(payment.recipient), + ); + persister.note_journaled_private_payments( + commitments + .iter() + .map(|commitment| (*commitment, sender, recipient)), + ); + Self { + persister: Some(persister), + commitments, + saved: false, + } + } + + /// The journal records were saved: the hold stays until they are + /// discharged. + fn saved(&mut self) { + self.saved = true; + } +} + +impl Drop for JournaledPairHold<'_> { + fn drop(&mut self) { + if self.saved { + return; + } + if let Some(persister) = self.persister { + persister.forget_journaled_private_payments(self.commitments); + } + } +} + /// 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. @@ -1992,13 +2059,23 @@ async fn settle_private_document_payment( } } -/// A private document payment from `notes` whose broadcast is refused before -/// anything was delivered, saved and settled as the private flow does +/// How the broadcast of [`pay_private_document_with_outcome`]'s payment ends. +#[cfg(test)] +#[derive(Clone, Copy, Debug)] +pub(crate) enum TestBroadcastOutcome { + /// Refused before anything was delivered. + RefusedBeforeDelivery, + /// Delivered, its result wait timed out. + Unconfirmed, +} + +/// A private document payment from `notes` whose broadcast ends as `outcome`, +/// saved and settled as the private flow does /// ([`submit_private_document_payment`]): for tests outside this module that /// drive a settlement through a contact request's pair. #[cfg(test)] #[allow(clippy::too_many_arguments)] -pub(crate) async fn pay_private_document_refused_before_delivery( +pub(crate) async fn pay_private_document_with_outcome( store: &Arc>, persister: Option<&WalletPersister>, wallet_id: WalletId, @@ -2008,6 +2085,7 @@ pub(crate) async fn pay_private_document_refused_before_delivery Result { // Reserved and recorded pending, as the private flow does before it // submits. @@ -2037,12 +2115,21 @@ pub(crate) async fn pay_private_document_refused_before_delivery SpendFailure { + error: PlatformWalletError::ShieldedBroadcastFailed( + "refused before delivery".to_string(), + ), + never_delivered: true, + }, + TestBroadcastOutcome::Unconfirmed => SpendFailure { + error: PlatformWalletError::ShieldedSpendUnconfirmed { + operation: "private document create from shielded pool", + reason: "the result wait timed out".to_string(), + }, + never_delivered: false, + }, })) }, ) From cce059def3c63396377f96263ee0a992662bc3d8 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Tue, 10 Nov 2026 02:10:54 +0700 Subject: [PATCH 198/269] docs(platform-wallet): tell a pinned SDK's refusal apart and list every new store method The code 70 docs and its registry row said a ProtocolVersionNotActive refusal lifts when the network upgrades, which a pinned SDK's does not: the host must raise or remove the pin, the network supporting the required version too. The README's custom ShieldedStore migration list missed private_payment_journal_open and the methods and fields added since (retire_undelivered_spend, note_anchor_recorded, the anchor height on set_pending_spend, StalePendingSpend and PendingRedrive), and its expiry rule and pair hold now read as the code does. Co-Authored-By: Claude Opus 5.5 --- .../ERROR_CODE_REGISTRY.md | 2 +- packages/rs-platform-wallet-ffi/src/error.rs | 27 +++++++++++++++++-- packages/rs-platform-wallet/README.md | 23 ++++++++++++---- 3 files changed, 44 insertions(+), 8 deletions(-) diff --git a/packages/rs-platform-wallet-ffi/ERROR_CODE_REGISTRY.md b/packages/rs-platform-wallet-ffi/ERROR_CODE_REGISTRY.md index b1cda68de23..e03bccf2f7b 100644 --- a/packages/rs-platform-wallet-ffi/ERROR_CODE_REGISTRY.md +++ b/packages/rs-platform-wallet-ffi/ERROR_CODE_REGISTRY.md @@ -183,7 +183,7 @@ Fork-era numbers remain in the collision history, which is immutable record. | 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 | +| 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. A refusal from the network's version lifts when the network upgrades; one from a pin lifts only once the host raises or removes the pin, the network supporting the required version too, as a network upgrade alone does not lift it. 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 | diff --git a/packages/rs-platform-wallet-ffi/src/error.rs b/packages/rs-platform-wallet-ffi/src/error.rs index ab7f47a7aff..3e93ca92e87 100644 --- a/packages/rs-platform-wallet-ffi/src/error.rs +++ b/packages/rs-platform-wallet-ffi/src/error.rs @@ -654,8 +654,11 @@ pub enum PlatformWalletFFIResultCode { /// 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 whose query for the network's + /// or the SDK is pinned below it. Expected on such a network: a refusal + /// from the network's version lifts when the network upgrades, one from a + /// pin only once the host raises or removes the pin (and the network + /// supports the required version too); a network upgrade alone does not + /// lift it. 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. @@ -2843,6 +2846,26 @@ mod private_contact_request_error_tests { )))); assert_eq!(transient.code, PlatformWalletFFIResultCode::ErrorUnknown); + // A pinned SDK refuses whatever its query for the network's version + // showed: the lasting code, with a cause too. + let pinned = PlatformWalletFFIResult::from(PlatformWalletError::Sdk( + dash_sdk::Error::ProtocolVersionNotActive { + method: "get_private_document_entries", + required: 15, + feature: None, + network: 14, + pinned: true, + cause: Some(Box::new(dash_sdk::Error::Generic( + "no node answered".to_string(), + ))), + }, + )); + assert_eq!( + pinned.code, + PlatformWalletFFIResultCode::ErrorProtocolVersionNotActive + ); + assert_eq!(pinned.code as u32, 70); + // 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( diff --git a/packages/rs-platform-wallet/README.md b/packages/rs-platform-wallet/README.md index f893ac6b246..e0bf7c14c17 100644 --- a/packages/rs-platform-wallet/README.md +++ b/packages/rs-platform-wallet/README.md @@ -123,14 +123,14 @@ 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 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. +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, judged against Platform's recorded anchors fetched before the shielded sync pass and proved at a height H: the spend's anchor, which the record keeps with the proved height of the set it was read from, was read at or below H and is absent from the set at H, the pass's note scan covers H, and its entry is absent from DashPay's private store read through its end with every page proved at or above H. Whether its inputs are spent does not matter: the transition appends its entry in the block that spends them. 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: - 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 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. +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. The wallet holds it, beyond the sender identity, for as long as the journal keeps the payment's record: removing the identity and loading it again does not release it. `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. @@ -144,11 +144,24 @@ Every shielded spend that consumes notes (shielded transfers, unshields, withdra 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`, +`private_contact_redrives_for_wallet`, `retire_undelivered_spend` and the +private payment journal (`private_payment_journal_open`, +`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. +it must never report success while dropping them. `retire_undelivered_spend` +removes a spend's saved record and its journal records in one durable write, +and refuses, changing nothing, once a re-broadcast of the record was claimed +(`PendingRedrive::attempts` above 0). + +Anchored note reservations and saved records now keep the proved height of +the anchor set their anchor was read from: `set_pending_spend` takes an +`anchor_height: Option`, `StalePendingSpend` carries it as a fourth +element, `PendingRedrive` struct literals require `anchor_height`, and stores +must implement `note_anchor_recorded`, which gives that height to the +reservations and records of an anchor that carry none, durably for a record. +A store keeps a record's height with it, so a restart restores it; one +without a known height is never released on an anchor's absence alone. The local shielded balance API introduces three Rust source compatibility changes: From 98aa2f2e9361398035f363a8d197f2e5f323d512 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Tue, 10 Nov 2026 02:10:54 +0700 Subject: [PATCH 199/269] test(platform-wallet-ffi): cover a pinned refusal with a cause and a non-fresh readiness A pinned ProtocolVersionNotActive refusal that carries a cause now has its own assertion that it maps to code 70. The readiness getter is read after a private sync read the store and with an unresolved-payment marker, not only for a fresh identity, so a getter returning a fresh identity's flags fails. ManagedIdentity gains a test-utils accessor that notes the store read, as a private sync does. Co-Authored-By: Claude Opus 5.5 --- .../src/contact_request.rs | 66 ++++++++++++++++++- .../managed_identity/contact_requests.rs | 7 ++ 2 files changed, 70 insertions(+), 3 deletions(-) diff --git a/packages/rs-platform-wallet-ffi/src/contact_request.rs b/packages/rs-platform-wallet-ffi/src/contact_request.rs index 217e36c4651..e1c955270f2 100644 --- a/packages/rs-platform-wallet-ffi/src/contact_request.rs +++ b/packages/rs-platform-wallet-ffi/src/contact_request.rs @@ -349,9 +349,11 @@ mod tests { 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. + /// read yet, no private payment; then the identity's own state, once a + /// private sync read the store and once it was loaded with a private + /// payment that may be unresolved. 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; @@ -445,6 +447,64 @@ mod tests { unresolved_private_payment: false, } ); + // The identity's own state, not a fresh identity's: once a private + // sync read the store to its end, and once loaded with a private + // payment that may be unresolved. + let readiness_now = |readiness: &mut PrivateContactReadinessFFI| { + let read = unsafe { + platform_wallet_get_private_contact_readiness( + handle, + identity_id.to_buffer().as_ptr(), + readiness, + ) + }; + assert_eq!(read.code, PlatformWalletFFIResultCode::Success); + }; + let with_identity = + |change: &dyn Fn(&mut platform_wallet::wallet::identity::ManagedIdentity)| { + PLATFORM_WALLET_STORAGE + .with_item(handle, |wallet| { + crate::runtime::runtime().block_on(async { + let mut wm = wallet.wallet_manager().write().await; + change( + wm.get_wallet_info_mut(&wallet.wallet_id()) + .expect("the wallet's info") + .identity_manager + .managed_identity_mut(&identity_id) + .expect("the identity"), + ); + }) + }) + .expect("the wallet"); + }; + with_identity(&|managed| managed.note_private_contact_requests_read_for_tests()); + readiness_now(&mut readiness); + assert_eq!( + readiness, + PrivateContactReadinessFFI { + private_payments_restored: true, + private_store_read: true, + unresolved_private_payment: false, + } + ); + with_identity(&|managed| { + managed.apply_private_contact_requests_position( + platform_wallet::changeset::PrivateContactRequestsPosition { + position: 0, + key_fingerprint: [0; 32], + private_payments_unresolved: true, + }, + ) + }); + readiness_now(&mut readiness); + assert_eq!( + readiness, + PrivateContactReadinessFFI { + private_payments_restored: false, + private_store_read: true, + unresolved_private_payment: true, + } + ); let stranger = unsafe { platform_wallet_get_private_contact_readiness( handle, 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 d2e268f88c7..a2d45dddadc 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 @@ -821,6 +821,13 @@ impl ManagedIdentity { self.dashpay.private_contact_requests_read = true; } + /// [`Self::note_private_contact_requests_read`], for tests outside this + /// crate that read an identity's state after a private sync. + #[cfg(any(test, feature = "test-utils"))] + pub fn note_private_contact_requests_read_for_tests(&mut self) { + self.note_private_contact_requests_read(); + } + /// 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 From 5cf6cab6980a6f556e78a27b51e19b571a2e74fc Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Tue, 10 Nov 2026 02:14:54 +0700 Subject: [PATCH 200/269] fix(platform-wallet): keep no read-back set without shielded, and sync's rustdoc whole In a build without the shielded feature, a private sync noted the commitments it read back into a set nothing drained, as only the shielded coordinator discharges journal records with them. The set, its noting and its accessors are now shielded-only, so such a build keeps nothing. The read-back discharge helper sat between NetworkShieldedCoordinator::sync and its doc block, taking its rustdoc; it now sits above it, so sync keeps its cooldown and cancellation contract. Co-Authored-By: Claude Opus 5.5 --- .../network/private_contact_requests.rs | 9 +++- .../src/wallet/persister.rs | 7 ++- .../src/wallet/shielded/coordinator.rs | 50 +++++++++---------- 3 files changed, 38 insertions(+), 28 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 09d4a92dfa5..8389c378067 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 @@ -403,7 +403,9 @@ fn release_reservation( /// (`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. +/// the pairs held, until a later read. Only a shielded build keeps a journal, +/// so only it notes anything. +#[cfg(feature = "shielded")] fn note_read_back_once_durable(persister: &WalletPersister, read_back: Vec<[u8; 32]>) { if !persister.store_commits_inline() { if let Err(error) = persister.flush() { @@ -1965,7 +1967,8 @@ 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(); + #[cfg(feature = "shielded")] + let mut read_back: Vec<[u8; 32]> = Vec::new(); for (commitment, record) in sent_commitments .iter() .filter(|(_, record)| record.sender_id == identity_id) @@ -1979,6 +1982,7 @@ impl DashPayView<'_, B> { }); if reconciled { managed.settle_private_reply(&record.recipient_id, commitment); + #[cfg(feature = "shielded")] read_back.push(*commitment); } } @@ -2002,6 +2006,7 @@ impl DashPayView<'_, B> { // 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. + #[cfg(feature = "shielded")] if !read_back.is_empty() { note_read_back_once_durable(&self.persister, read_back); } diff --git a/packages/rs-platform-wallet/src/wallet/persister.rs b/packages/rs-platform-wallet/src/wallet/persister.rs index e8088c1a8c2..0495f4017c4 100644 --- a/packages/rs-platform-wallet/src/wallet/persister.rs +++ b/packages/rs-platform-wallet/src/wallet/persister.rs @@ -66,7 +66,9 @@ pub struct WalletPersister { /// 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. + /// it pays for is recorded. Only a shielded build keeps a journal to + /// discharge, so only it keeps them. + #[cfg(feature = "shielded")] 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 @@ -117,6 +119,7 @@ impl WalletPersister { wallet_id, inner, contact_payment_gate: Arc::new(tokio::sync::Mutex::new(())), + #[cfg(feature = "shielded")] read_back_private_entries: Arc::default(), absent_sender_private_pairs: Arc::default(), discharged_private_entries: Arc::default(), @@ -127,6 +130,7 @@ impl WalletPersister { /// 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`]). + #[cfg(feature = "shielded")] pub(crate) fn note_read_back_private_entries( &self, commitments: impl IntoIterator, @@ -291,6 +295,7 @@ impl WalletPersister { self.private_payments_unrestored.load(Ordering::Acquire) } + #[cfg(feature = "shielded")] fn read_back_entries(&self) -> std::sync::MutexGuard<'_, BTreeSet<[u8; 32]>> { self.read_back_private_entries .lock() diff --git a/packages/rs-platform-wallet/src/wallet/shielded/coordinator.rs b/packages/rs-platform-wallet/src/wallet/shielded/coordinator.rs index 47495851274..b2faac3847f 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/coordinator.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/coordinator.rs @@ -1300,6 +1300,31 @@ impl NetworkShieldedCoordinator { Ok(()) } + /// 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, + ); + } + } + /// Run one shielded sync pass for every registered wallet on /// this coordinator's network. Returns a per-wallet outcome /// summary suitable for emission to UI / persistence layers @@ -1334,31 +1359,6 @@ 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 From 6d0f2e91bd55663128acfce0281bb6cdb5e48e24 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Tue, 10 Nov 2026 02:24:40 +0700 Subject: [PATCH 201/269] fix(platform-wallet): free notes an earlier process reserved before anchor heights were kept A note reservation, saved redrive or journal record from a build before anchor heights were kept had none, so it was never proven pruned unless a set this process fetched still held its anchor: one whose anchor was pruned meanwhile stayed reserved forever. Such a record was saved by an earlier process, so its anchor was read before any set this process fetches. The file store now loads a note spend's record, or a journal record, saved without a height with height 0 (ANCHOR_READ_BEFORE_THIS_PROCESS): the shared pruned-anchor rule then releases or discharges it on the first set lacking its anchor once the scan covers that set, and the store read too for a journal record. One created in this process without a height stays held, and identity debit records, which reserve no note, keep theirs. Co-Authored-By: Claude Opus 5.5 --- packages/rs-platform-wallet/README.md | 10 +- .../network/private_contact_requests.rs | 95 +++++++++++++++++++ .../src/wallet/shielded/coordinator.rs | 80 +++++++++++++++- .../src/wallet/shielded/file_store.rs | 22 ++++- .../src/wallet/shielded/store.rs | 25 +++-- 5 files changed, 219 insertions(+), 13 deletions(-) diff --git a/packages/rs-platform-wallet/README.md b/packages/rs-platform-wallet/README.md index e0bf7c14c17..ee931934c44 100644 --- a/packages/rs-platform-wallet/README.md +++ b/packages/rs-platform-wallet/README.md @@ -160,8 +160,14 @@ the anchor set their anchor was read from: `set_pending_spend` takes an element, `PendingRedrive` struct literals require `anchor_height`, and stores must implement `note_anchor_recorded`, which gives that height to the reservations and records of an anchor that carry none, durably for a record. -A store keeps a record's height with it, so a restart restores it; one -without a known height is never released on an anchor's absence alone. +A store keeps a record's height with it, so a restart restores it. A note +spend's record, or a journal record, that an earlier process saved without a +height (one saved before heights were kept) is loaded with height 0: its +anchor was necessarily read before any anchor set this process fetches, so it +is released, or discharged, on the first set lacking its anchor once the scan +covers that set (and, for a journal record, the store read through its end +lacks its entry at that set's height). One created in this process without a +height is never released on an anchor's absence alone. The local shielded balance API introduces three Rust source compatibility changes: 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 8389c378067..98b3105239d 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 @@ -7374,6 +7374,101 @@ mod tests { ); } + /// A journal record with no anchor height is never proven expired while + /// it was created in this process. One an earlier process saved, before + /// heights were kept, counts as read before any set this process fetches: + /// reopened, it is discharged once its anchor is absent from a set at H, + /// the scan covers H and the store read through its end at H lacks its + /// entry, and not while the scan is below H. + #[cfg(feature = "shielded")] + #[tokio::test] + async fn should_discharge_a_reopened_heightless_journal_record_once_its_evidence_covers_the_set( + ) { + use crate::wallet::shielded::PrivatePaymentRecord; + + let alice = identity(ALICE_PHRASE, ALICE); + let bob = identity(BOB_PHRASE, BOB); + let fixture = ExpiryFixture::new().await; + let height = ExpiryFixture::ANCHOR_SET_HEIGHT; + fixture.prove_at(height); + let wallet_id = fixture.wallet.wallet_id(); + let record = PrivatePaymentRecord { + account_index: 0, + activity_id: [0x71; 32], + anchor: ExpiryFixture::ANCHOR, + anchor_height: None, + commitment: [0x72; 32], + payment: PrivateContactPayment { + sender: alice.id().to_buffer(), + recipient: bob.id().to_buffer(), + account_reference: 1, + }, + nullifiers: vec![fixture.input.nullifier], + }; + fixture + .coordinator + .store() + .write() + .await + .journal_private_payments(wallet_id, std::slice::from_ref(&record)) + .expect("journaled"); + let recorded = HashSet::new(); + // Created in this process with no height: unproven. + let journal = fixture.journal_snapshot().await; + fixture.discharge(&journal, &recorded, Some(height)).await; + assert_eq!(fixture.journaled().await, 1, "created in this process"); + + // Saved by an earlier process: this one reopens the store. + let reopened = NetworkShieldedCoordinator::new( + Arc::clone(&fixture.wallet.identity().sdk), + Network::Testnet, + fixture.db_path.clone(), + FileBackedShieldedStore::open_path(&fixture.db_path, 100).expect("reopened"), + ); + let records = |coordinator: &NetworkShieldedCoordinator| { + let store = Arc::clone(coordinator.store()); + async move { + store + .read() + .await + .private_payment_journal(wallet_id) + .expect("journal") + } + }; + let journal: Vec<(WalletId, PrivatePaymentRecord)> = records(&reopened) + .await + .into_iter() + .map(|record| (wallet_id, record)) + .collect(); + assert_eq!( + journal[0].1.anchor_height, + Some(0), + "read before this process" + ); + for (covered, discharged) in [(height - 1, false), (height, true)] { + reopened + .discharge_expired_private_payments( + &journal, + PrunedAnchorEvidence { + recorded: &recorded, + height, + scan_covered_height: Some(covered), + }, + ) + .await; + assert_eq!( + records(&reopened).await.is_empty(), + discharged, + "scan covering {covered}" + ); + } + assert!(FileBackedShieldedStore::open_path(&fixture.db_path, 100) + .expect("reopened again") + .private_payment_journal(wallet_id) + .expect("journal") + .is_empty()); + } + /// A private payment that never executes is proven expired once its /// anchor is pruned and its entry is absent from the store: the /// coordinator discharges its journal record, the pair is freed, a retry diff --git a/packages/rs-platform-wallet/src/wallet/shielded/coordinator.rs b/packages/rs-platform-wallet/src/wallet/shielded/coordinator.rs index b2faac3847f..afed9450d4d 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/coordinator.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/coordinator.rs @@ -2569,7 +2569,7 @@ mod tests { use crate::changeset::ShieldedChangeSet; use crate::wallet::shielded::activity::compute_activity_id; - use crate::wallet::shielded::store::PendingRedrive; + use crate::wallet::shielded::store::{PendingRedrive, ANCHOR_READ_BEFORE_THIS_PROCESS}; use dpp::platform_value::BinaryData; use dpp::serialization::PlatformSerializable; use dpp::shielded::SerializedAction; @@ -4403,6 +4403,84 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } + /// A note spend an earlier process saved before anchor heights were kept, + /// its anchor since pruned: reopened, its anchor counts as read before any + /// set this process fetches, so the first set lacking it releases the + /// reservation once the scan covers that set, and not before. + #[tokio::test] + async fn should_release_a_reopened_heightless_reservation_once_the_scan_covers_the_set() { + let dir = temp_dir("release_reopened_heightless"); + std::fs::create_dir_all(&dir).expect("create temp dir"); + let db_path = dir.join("tree.sqlite"); + let id = SubwalletId::new([0x11; 32], 0); + let (nullifier, anchor, activity) = ([0x61; 32], [0x62; 32], [0x63; 32]); + { + // An earlier process, before heights were kept. + let mut store = FileBackedShieldedStore::open_path(&db_path, 100).expect("open"); + store.mark_pending(id, &nullifier).expect("reserved"); + 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 coordinator = NetworkShieldedCoordinator::new( + Arc::new(dash_sdk::Sdk::new_mock()), + dashcore::Network::Testnet, + db_path.clone(), + FileBackedShieldedStore::open_path(&db_path, 100).expect("reopened"), + ); + let stale = || async { + coordinator + .store + .read() + .await + .stale_pending_spends(id) + .expect("stale") + }; + assert_eq!( + stale().await, + vec![( + nullifier, + anchor, + Some(activity), + Some(ANCHOR_READ_BEFORE_THIS_PROCESS) + )], + "read before any set this process fetches" + ); + let recorded = HashSet::new(); + for (covered, released) in [(9, false), (10, true)] { + let snapshot: Vec<(SubwalletId, StalePendingSpend)> = + stale().await.into_iter().map(|entry| (id, entry)).collect(); + coordinator + .release_stranded_spends( + snapshot, + PrunedAnchorEvidence { + recorded: &recorded, + height: 10, + scan_covered_height: Some(covered), + }, + ) + .await; + assert_eq!( + stale().await.is_empty(), + released, + "scan covering {covered}" + ); + } + 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 7b8cb80cae6..fa20f3eced4 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/file_store.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/file_store.rs @@ -25,6 +25,7 @@ use super::balance::{ShieldedBalanceSource, ShieldedLocalAccountBalance}; use super::store::{ PendingRedrive, PrivateContactPayment, PrivatePaymentRecord, ShieldedNote, ShieldedOutgoingNote, ShieldedStore, StalePendingSpend, SubwalletId, SubwalletState, + ANCHOR_READ_BEFORE_THIS_PROCESS, }; use crate::error::PlatformWalletError; use crate::wallet::platform_wallet::WalletId; @@ -330,7 +331,12 @@ impl FileBackedShieldedStore { (record.commitment == commitment).then_some((wallet_id, record)) }); match read { - Some((wallet_id, record)) => { + Some((wallet_id, mut record)) => { + // Saved by an earlier process with no height: its anchor + // was read before any set this process fetches. + record + .anchor_height + .get_or_insert(ANCHOR_READ_BEFORE_THIS_PROCESS); self.private_payment_journal .insert((wallet_id, record.commitment), record); } @@ -554,8 +560,7 @@ 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. + // A height that does not read is not known. let anchor_height = row .get::<_, Option>(10) .ok() @@ -671,6 +676,14 @@ impl FileBackedShieldedStore { return Err(corrupted("invalid nullifier width")); } let nullifiers: Vec<[u8; 32]> = nullifiers.as_chunks::<32>().0.to_vec(); + // A note spend saved by an earlier process with no height, + // before heights were kept: its anchor was read before any set + // this process fetches, which its reservations are released + // by. An identity debit reserves no note and keeps its own. + let anchor_height = match anchor_height { + None if !nullifiers.is_empty() => Some(ANCHOR_READ_BEFORE_THIS_PROCESS), + height => height, + }; Ok(( SubwalletId::new(wallet_id, account_index), PendingRedrive { @@ -2068,7 +2081,8 @@ mod tests { attempts: 0, identity_nonce_finalized: false, identity_user_abandoned: false, - anchor_height: None, + // As a spend saved by this build keeps it. + anchor_height: Some(5), }; let payment = PrivateContactPayment { sender: [0xB1; 32], diff --git a/packages/rs-platform-wallet/src/wallet/shielded/store.rs b/packages/rs-platform-wallet/src/wallet/shielded/store.rs index d62878a3296..b2671d46617 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/store.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/store.rs @@ -126,6 +126,15 @@ pub struct ShieldedOutgoingNote { pub block_height: u64, } +/// The anchor height a store gives a reservation, saved record or journal +/// record it loads at open with none kept: one saved by an earlier process, +/// before heights were kept, whose anchor was necessarily read before any +/// anchor set this process fetches. So a set this process fetches that lacks +/// the anchor, the scan covering that set, shows it pruned +/// (`PrunedAnchorEvidence::proves_pruned`). A record created in this process +/// always has its height; one without stays unproven. +pub(crate) const ANCHOR_READ_BEFORE_THIS_PROCESS: u64 = 0; + /// A pending reservation that carries a recorded anchor: /// `(nullifier, anchor, activity_id, anchor_height)`, `anchor_height` being a /// proved height at which `anchor` was in Platform's recorded set, when known. @@ -167,9 +176,11 @@ pub struct PendingRedrive { /// 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. + /// that lacks the anchor shows it pruned rather than newer. A store + /// loading a record an earlier process saved without it gives it + /// [`ANCHOR_READ_BEFORE_THIS_PROCESS`]. `None` for one created in this + /// process 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 @@ -243,9 +254,11 @@ pub struct PrivatePaymentRecord { /// 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. `None` when not known, as for a - /// payment saved before the journal or the redrive record kept it, whose - /// expiry is then never proven. Kept as `u64::MAX` in the record's bytes - /// ([`Self::to_bytes`]). + /// payment saved before the journal or the redrive record kept it; a + /// store loading such a record an earlier process saved gives it + /// [`ANCHOR_READ_BEFORE_THIS_PROCESS`], and one created in this process + /// without it is never proven expired. Kept as `u64::MAX` in the record's + /// bytes ([`Self::to_bytes`]). pub anchor_height: Option, /// The commitment of the entry it appends, which keys the record. pub commitment: [u8; 32], From ac533fd551a879d4c965593761393d195cfed4aa Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Tue, 10 Nov 2026 02:28:45 +0700 Subject: [PATCH 202/269] perf(platform-wallet): read the private store no more for an entry an expiry read found A paid request whose entry an expiry read found in DashPay's private store kept its journal record, as it should until a private sync reads it back, but every later pass read the whole store again for it. An entry found at or above the anchor set's height shows its payment executed: the coordinator now keeps it in a session set of landed entries, which are no candidates for expiry, so no later pass reads the store for them. The record stays, holding its pair, until the private sync reads the entry back and records the request; it leaves the set when the record is discharged. Co-Authored-By: Claude Opus 5.5 --- .../network/private_contact_requests.rs | 45 +++++++++++++++++++ .../src/wallet/shielded/coordinator.rs | 40 ++++++++++++++++- 2 files changed, 84 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 98b3105239d..86f99027e7c 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 @@ -7469,6 +7469,51 @@ mod tests { .is_empty()); } + /// An expiry read that finds a candidate's entry in the store shows its + /// payment executed: the record stays, holding its pair until a private + /// sync reads the entry back and records the request, and no later pass + /// reads the store again for it. + #[cfg(feature = "shielded")] + #[tokio::test] + async fn should_not_read_the_store_again_for_an_entry_an_expiry_read_found() { + use dash_sdk::dapi_grpc::platform::v0::GetPrivateDocumentEntriesRequest; + use dash_sdk::platform::Query; + + let fixture = ExpiryFixture::new().await; + let height = ExpiryFixture::ANCHOR_SET_HEIGHT; + fixture.prove_at(height); + let entry = fixture.pay_unconfirmed(ExpiryFixture::ANCHOR, height).await; + publish(&fixture.wallet, 0, vec![entry.to_vec()]).await; + let mut sdk = (*fixture.wallet.identity().sdk).clone(); + let request: GetPrivateDocumentEntriesRequest = PrivateDocumentEntriesQuery::new( + dashpay_contract().expect("bundled DashPay").id(), + PRIVATE_CONTACT_REQUEST_DOCUMENT_TYPE_NAME, + 0, + ) + .query(&sdk.query_settings()) + .expect("encodes"); + let recorded = HashSet::new(); + let journal = fixture.journal_snapshot().await; + + let before = sdk.mock().request_count(&request).await; + fixture.discharge(&journal, &recorded, Some(height)).await; + let read = sdk.mock().request_count(&request).await; + assert!(read > before, "the expiry read the store"); + assert_eq!( + fixture.journaled().await, + 1, + "landed: kept for its read-back" + ); + + fixture.discharge(&journal, &recorded, Some(height)).await; + assert_eq!( + sdk.mock().request_count(&request).await, + read, + "no store read for it again" + ); + assert_eq!(fixture.journaled().await, 1); + } + /// A private payment that never executes is proven expired once its /// anchor is pruned and its entry is absent from the store: the /// coordinator discharges its journal record, the pair is freed, a retry diff --git a/packages/rs-platform-wallet/src/wallet/shielded/coordinator.rs b/packages/rs-platform-wallet/src/wallet/shielded/coordinator.rs index afed9450d4d..af9f5f16978 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/coordinator.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/coordinator.rs @@ -51,7 +51,7 @@ //! //! [`sync_notes_across`]: super::sync::sync_notes_across -use std::collections::{BTreeMap, HashSet}; +use std::collections::{BTreeMap, BTreeSet, HashSet}; use std::path::PathBuf; use std::sync::Arc; @@ -179,6 +179,16 @@ pub struct NetworkShieldedCoordinator { /// activity; bypassed by `force` syncs. last_caught_up_at: std::sync::Mutex>, + /// The journal records, by wallet and entry commitment, whose entry an + /// expiry read found in DashPay's private store + /// ([`Self::discharge_expired_private_payments`]): their payments + /// executed, so they are no candidates for expiry and no later pass reads + /// the store again for them. Each record stays, holding its pair, until a + /// private sync reads its entry back and records the request, which + /// discharges it; then it leaves this set too. In memory only: a restart + /// reads the store for it once more. + landed_private_payments: std::sync::Mutex>, + /// Optional progress callback fired once per chunk inside /// `sync_shielded_notes`. Lets the manager translate chunk-level /// progress into `PlatformEventHandler::on_shielded_sync_progress` @@ -405,6 +415,7 @@ impl NetworkShieldedCoordinator { accounts: Arc::new(RwLock::new(BTreeMap::new())), persisters: Arc::new(RwLock::new(BTreeMap::new())), last_caught_up_at: std::sync::Mutex::new(None), + landed_private_payments: std::sync::Mutex::new(BTreeSet::new()), progress_handler: std::sync::Mutex::new(None), tree_progress_handler: std::sync::Mutex::new(None), lifecycle: tokio::sync::Mutex::new(()), @@ -1962,8 +1973,23 @@ impl NetworkShieldedCoordinator { evidence: PrunedAnchorEvidence<'_>, ) { let recorded_height = evidence.height; + // Entries found landed before are no candidates: their payments + // executed. One whose record is gone was read back and leaves. + let landed: BTreeSet<(WalletId, [u8; 32])> = { + let mut landed = self + .landed_private_payments + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + landed.retain(|(wallet_id, commitment)| { + journal + .iter() + .any(|(owner, record)| owner == wallet_id && record.commitment == *commitment) + }); + landed.clone() + }; let pruned: Vec<&(WalletId, PrivatePaymentRecord)> = journal .iter() + .filter(|(wallet_id, record)| !landed.contains(&(*wallet_id, record.commitment))) .filter(|(_, record)| evidence.proves_pruned(&record.anchor, record.anchor_height)) .collect(); if pruned.is_empty() { @@ -1998,6 +2024,18 @@ impl NetworkShieldedCoordinator { return; } }; + // An entry the store holds at or above H executed: its record waits + // for a private sync to read it back, and no later pass reads the + // store again for it. + self.landed_private_payments + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .extend( + candidates + .iter() + .filter(|(_, record)| held.contains(&record.commitment)) + .map(|(wallet_id, record)| (*wallet_id, record.commitment)), + ); let persisters = self.persisters.read().await; let mut store = self.store.write().await; for (wallet_id, record) in candidates { From 7e11255797131d43017b6fade405ba2afd049566 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Tue, 10 Nov 2026 02:41:53 +0700 Subject: [PATCH 203/269] fix(platform-wallet): keep a restored wallet available on a busy rebind, and teardown clear of restores An identical rebind while a scan held the shielded store raised the wallet's hold even when this generation had already restored its private payments from that store, refusing public sends and accepts until a deferred restoration ran, and every such rebind queued another. The wallet generation now remembers the store a restoration last succeeded from: a busy store then holds nothing and queues nothing. Deferred restorations are coalesced to one queued per store. The restoration also no longer holds the generation's payment guard across its store waits, which made a removal wait behind them. The step that reserves its pairs already fences it to the wallet's generation under the wallet manager's write lock, the lock a removal takes, so a stale restoration reserves nothing on a replacement; what it does before that (journal read-back discharges and migrations, its own holds) holds whatever the generation. Co-Authored-By: Claude Opus 5.5 --- .../network/private_contact_requests.rs | 21 +- .../src/wallet/platform_wallet.rs | 219 ++++++++++++++++-- 2 files changed, 212 insertions(+), 28 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 86f99027e7c..716277bd843 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 @@ -8370,8 +8370,9 @@ mod tests { "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. + // waiting for it. The bind already restored from this store, so the + // wallet is not held again, and the pair stays held by its restored + // reservation. { let scan = after.store().write().await; tokio::time::timeout( @@ -8382,21 +8383,13 @@ mod tests { .expect("the rebind does not wait for the store") .expect("rebound"); assert!( - restarted.private_payments_held().await, - "held until the deferred restoration" + !restarted.private_payments_held().await, + "restored from this store already: not held again" ); + assert!(pending_to(&restarted, BOB, &alice).await); 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!(!restarted.private_payments_held().await); assert!(pending_to(&restarted, BOB, &alice).await); let accepted = restarted diff --git a/packages/rs-platform-wallet/src/wallet/platform_wallet.rs b/packages/rs-platform-wallet/src/wallet/platform_wallet.rs index 5eb1c34f374..5dd94248400 100644 --- a/packages/rs-platform-wallet/src/wallet/platform_wallet.rs +++ b/packages/rs-platform-wallet/src/wallet/platform_wallet.rs @@ -51,7 +51,9 @@ use { discharge_read_back_private_payments, private_document_create_from_pool_with_metadata, }, super::shielded::store::{PendingRedrive, PrivateContactPayment, PrivatePaymentRecord}, - super::shielded::{NetworkShieldedCoordinator, OrchardKeySet, ShieldedStore}, + super::shielded::{ + FileBackedShieldedStore, NetworkShieldedCoordinator, OrchardKeySet, ShieldedStore, + }, crate::{ AutoAcceptProofSource, ContactCryptoProvider, ContactRequest, EstablishedContact, PaymentStarted, @@ -427,6 +429,13 @@ pub struct PlatformWallet { /// wallet handles observe the one flag. #[cfg(feature = "shielded")] pub(crate) shielded_detached: Arc, + /// This generation's restorations of its private contact request + /// payments from a shielded store's journal: the store one last succeeded + /// from, and the store a deferred one is queued for + /// ([`Self::restore_private_payments_or_defer`]). `Arc` so cloned wallet + /// handles share them. + #[cfg(feature = "shielded")] + private_payment_restores: Arc>, } impl PlatformWallet { @@ -808,6 +817,8 @@ impl PlatformWallet { shield_guard: Arc::new(tokio::sync::Mutex::new(())), #[cfg(feature = "shielded")] shielded_detached: Arc::new(std::sync::atomic::AtomicBool::new(false)), + #[cfg(feature = "shielded")] + private_payment_restores: Arc::default(), } } @@ -1042,14 +1053,33 @@ impl PlatformWallet { ) .await?; // After the install transaction: the restore takes the store and - // 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. + // wallet locks on its own. + self.restore_private_payments_or_defer(coordinator).await; + Ok(()) + } + + /// Restore this wallet's private contact request payments from + /// `coordinator`'s store at once, or, while the store is busy, on a task + /// once it frees. 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 holds the wallet's contact requests until the deferred + /// restoration succeeds, unless this generation already restored from that + /// store: then its reservations are in place and nothing is held or + /// queued. At most one deferred restoration per store is queued: a bind + /// while one waits queues none. Returns whether one was queued. + #[cfg(feature = "shielded")] + pub(crate) async fn restore_private_payments_or_defer( + &self, + coordinator: &Arc, + ) -> bool { let restore = self.private_payment_restore(coordinator); match restore.restore_and_release(false).await { - Ok(Some(_)) => {} + Ok(Some(_)) => false, Ok(None) => { + if restore.restored_from_this_store() || !restore.queue_deferred() { + return false; + } tracing::debug!( wallet_id = %hex::encode(self.wallet_id), "the shielded store is busy; private contact request payments are \ @@ -1064,7 +1094,9 @@ impl PlatformWallet { contact requests stay held" ); } + restore.finish_deferred(); }); + true } Err(error) => { tracing::warn!( @@ -1073,9 +1105,9 @@ impl PlatformWallet { "private contact request payments not restored at bind; the wallet's \ contact requests stay held until a restoration succeeds" ); + false } } - Ok(()) } /// The body of [`Self::install_shielded_views`]: one coordinator install @@ -1876,6 +1908,7 @@ impl PlatformWallet { wallet_manager: Arc::clone(&self.wallet_manager), persister: self.persister.clone(), generation: Arc::clone(&self.generation), + restores: Arc::clone(&self.private_payment_restores), wallet_id: self.wallet_id, network: self.sdk.network, } @@ -2765,6 +2798,8 @@ impl Clone for PlatformWallet { shield_guard: self.shield_guard.clone(), #[cfg(feature = "shielded")] shielded_detached: self.shielded_detached.clone(), + #[cfg(feature = "shielded")] + private_payment_restores: self.private_payment_restores.clone(), } } } @@ -2914,14 +2949,31 @@ struct PrivatePaymentRestore { wallet_manager: Arc>>, persister: WalletPersister, generation: Arc, + restores: Arc>, wallet_id: WalletId, network: dashcore::Network, } +/// A wallet generation's restorations of its private contact request +/// payments ([`PlatformWallet::restore_private_payments_or_defer`]). Weak, so +/// a store dropped since is never taken for a new one at the same address. +#[cfg(feature = "shielded")] +#[derive(Default)] +struct PrivatePaymentRestores { + /// The store a restoration last succeeded from. + restored_from: std::sync::Weak>, + /// The store a deferred restoration is queued for. + queued_for: std::sync::Weak>, + /// How many deferred restorations were queued, for tests. + #[cfg(test)] + queued: usize, +} + #[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. + /// once a restoration succeeds, raised when it fails, and when it cannot + /// run yet unless this generation already restored from this store. /// `Ok(None)` when the store is busy and `wait` is false. async fn restore_and_release( &self, @@ -2929,16 +2981,73 @@ impl PrivatePaymentRestore { ) -> Result>, PlatformWalletError> { let restored = self.restore(wait).await; match &restored { - Ok(Some(_)) => self.persister.private_payments_restored(), + Ok(Some(_)) => { + self.restores_lock().restored_from = Arc::downgrade(self.coordinator.store()); + self.persister.private_payments_restored(); + } + Ok(None) if self.restored_from_this_store() => {} Ok(None) | Err(_) => self.persister.hold_until_private_payments_restored(), } restored } + fn restores_lock(&self) -> std::sync::MutexGuard<'_, PrivatePaymentRestores> { + self.restores + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + } + + /// Whether this generation already restored from this coordinator's store. + fn restored_from_this_store(&self) -> bool { + self.restores_lock() + .restored_from + .upgrade() + .is_some_and(|store| Arc::ptr_eq(&store, self.coordinator.store())) + } + + /// Queue a deferred restoration from this store, unless one is queued + /// already. Returns whether it was queued. + fn queue_deferred(&self) -> bool { + let mut restores = self.restores_lock(); + if restores + .queued_for + .upgrade() + .is_some_and(|store| Arc::ptr_eq(&store, self.coordinator.store())) + { + return false; + } + restores.queued_for = Arc::downgrade(self.coordinator.store()); + #[cfg(test)] + { + restores.queued += 1; + } + true + } + + /// The deferred restoration from this store ran: another may be queued. + fn finish_deferred(&self) { + let mut restores = self.restores_lock(); + if restores + .queued_for + .upgrade() + .is_some_and(|store| Arc::ptr_eq(&store, self.coordinator.store())) + { + restores.queued_for = std::sync::Weak::new(); + } + } + /// 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. + /// documents, fenced against the wallet's replacement. + /// + /// It holds no lifecycle gate, so a removal never waits for its store + /// waits. The fence is the step that reserves the pairs + /// (`reserve_unresolved_private_payments`), which checks the wallet's + /// generation under the wallet manager's write lock, the one a removal + /// takes: a restoration of a wallet removed, or re-created under its id, + /// reserves nothing on the replacement and fails. What it does before + /// that holds whatever the generation: the journal's read-back + /// discharges and migrations are facts about the shared store, and the + /// holds it raises or releases are this generation's own. async fn restore( &self, wait: bool, @@ -2950,7 +3059,6 @@ impl PrivatePaymentRestore { 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) @@ -3311,11 +3419,18 @@ mod private_contact_request_payment_tests { } fn coordinator_at(dir: &std::path::Path) -> Arc { + coordinator_on(dir, dashcore::Network::Testnet) + } + + fn coordinator_on( + dir: &std::path::Path, + network: dashcore::Network, + ) -> 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, + network, db_path, store, )) @@ -3446,6 +3561,82 @@ mod private_contact_request_payment_tests { .expect("the removal completes"); assert!(!wallet.core().is_current_generation().await); } + + /// A restoration waiting for a busy shielded store holds no lifecycle + /// gate: a removal is not held behind its store wait. The step that + /// reserves its pairs fences it to the wallet's generation instead. + #[tokio::test] + async fn should_not_hold_a_removal_behind_a_restoration_s_store_wait() { + let dir = tempfile::tempdir().expect("temporary shielded store"); + let (_manager, wallet) = manager_with_wallet().await; + let coordinator = coordinator_on(dir.path(), wallet.sdk.network); + + let store = coordinator.store().write().await; + let restore = { + let wallet = Arc::clone(&wallet); + let coordinator = Arc::clone(&coordinator); + tokio::spawn(async move { wallet.restore_private_contact_payments(&coordinator).await }) + }; + tokio::time::sleep(Duration::from_millis(100)).await; + assert!(!restore.is_finished(), "the restoration waits at the store"); + let teardown = tokio::time::timeout( + Duration::from_millis(200), + wallet.generation.teardown_guard(), + ) + .await; + assert!( + teardown.is_ok(), + "a removal takes the lifecycle gate while the restoration waits" + ); + drop(teardown); + drop(store); + restore + .await + .expect("restore task") + .expect("the restoration completes"); + } + + /// A bind while the shielded store is busy defers the restoration of + /// the wallet's private payments to one task per store: a second bind + /// while it waits queues none. Once a restoration from that store + /// succeeded, a busy store neither holds the wallet's contact requests + /// again nor queues another. + #[tokio::test] + async fn should_coalesce_deferred_restorations_and_keep_a_restored_wallet_available() { + let dir = tempfile::tempdir().expect("temporary shielded store"); + let (_manager, wallet) = manager_with_wallet().await; + let coordinator = coordinator_on(dir.path(), wallet.sdk.network); + let queued = || { + wallet + .private_payment_restores + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .queued + }; + + let store = coordinator.store().write().await; + assert!(wallet.restore_private_payments_or_defer(&coordinator).await); + assert!(!wallet.restore_private_payments_or_defer(&coordinator).await); + assert_eq!(queued(), 1, "one deferred restoration per store"); + assert!(wallet.private_payments_held().await, "held until it runs"); + drop(store); + for _ in 0..100 { + if !wallet.private_payments_held().await { + break; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + assert!(!wallet.private_payments_held().await, "restored"); + + let store = coordinator.store().write().await; + assert!(!wallet.restore_private_payments_or_defer(&coordinator).await); + assert!( + !wallet.private_payments_held().await, + "a busy store holds a restored wallet no more" + ); + assert_eq!(queued(), 1, "and queues nothing"); + drop(store); + } } #[cfg(all(test, feature = "shielded"))] From 0754c4e4af4cff8b394fcbba4ec31a466bcd836d Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Tue, 10 Nov 2026 02:52:52 +0700 Subject: [PATCH 204/269] fix(platform-wallet): leave the lifecycle gate clear while the host signs a public request A public contact request send held the wallet generation's lifecycle gate from its liveness check through the submission, so a host's signing prompt (a biometric one, say) held a wallet removal back, and every payment queued behind that removal with it. The send now holds the gate while it decides the channel and claims the pair, then releases it: preparing the request and the host's signature run without it, the pair still claimed. The host signs through GatedSigner, which takes the gate again once the signature returns and before the SDK can broadcast it, checks under it that the wallet is still current, and keeps it through the broadcast and the send's bookkeeping. A signature returning after the wallet was removed, or re-created under its id, is refused, so nothing is published and the send reports WalletRemovedBeforeSend. Co-Authored-By: Claude Opus 5.5 --- .../src/wallet/core/generation.rs | 11 +- .../identity/network/contact_requests.rs | 180 +++++++++++++++--- .../network/private_contact_requests.rs | 95 +++++++++ 3 files changed, 259 insertions(+), 27 deletions(-) diff --git a/packages/rs-platform-wallet/src/wallet/core/generation.rs b/packages/rs-platform-wallet/src/wallet/core/generation.rs index b6fc28b1cc8..885dbddd8e9 100644 --- a/packages/rs-platform-wallet/src/wallet/core/generation.rs +++ b/packages/rs-platform-wallet/src/wallet/core/generation.rs @@ -8,7 +8,7 @@ use std::ops::Deref; use std::sync::{Arc, Mutex, MutexGuard, PoisonError}; use dashcore::{OutPoint, Transaction}; -use tokio::sync::{OwnedRwLockWriteGuard, RwLock, RwLockReadGuard}; +use tokio::sync::{OwnedRwLockReadGuard, OwnedRwLockWriteGuard, RwLock, RwLockReadGuard}; use super::balance::WalletBalance; @@ -427,6 +427,15 @@ impl WalletGeneration { self.lifecycle.read().await } + /// [`Self::payment_guard`], owned: for an operation that takes the gate in + /// a callback and holds it past the call, as a contact request send takes + /// it once the host's signature returns and holds it through the + /// broadcast and its bookkeeping. The same lock ordering and reentrancy + /// rules apply. + pub(crate) async fn payment_guard_owned(&self) -> OwnedRwLockReadGuard<()> { + Arc::clone(&self.lifecycle).read_owned().await + } + /// Enter this generation's lifecycle gate as a *teardown*. /// /// Exclusive against every payment operation on this generation. Removal 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 4cea51b95b8..4f161e4734f 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 @@ -763,30 +763,34 @@ impl DashPayView<'_, B> { 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) + // while the channel is decided and the pair claimed, and again from + // the moment the host's signature returns through the submission and + // its bookkeeping (`GatedSigner`): a removal cannot complete in either + // section, and a send started on a wallet since removed and re-created + // under the same id publishes nothing and records nothing on the + // replacement. The gate is not held while the host signs, so a + // signing prompt never holds a removal back. + let claim = { + let _lifecycle = self.generation.payment_guard().await; + if !self.is_current_generation().await { + 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?; - let Some(claim) = self - .claim_public_pair(sender_identity_id, recipient_identity_id, adopt_reply) - .await? - else { - return Ok(None); + self.release_discharged_private_requests().await?; + match self + .claim_public_pair(sender_identity_id, recipient_identity_id, adopt_reply) + .await? + { + Some(claim) => claim, + None => return Ok(None), + } }; let sent = self .send_claimed_contact_request( @@ -802,6 +806,16 @@ impl DashPayView<'_, B> { sent.map(Some) } + /// Whether this handle's wallet generation is the one the manager holds: + /// not removed, nor re-created under its id since the handle was taken. + async fn is_current_generation(&self) -> bool { + self.wallet_manager + .read() + .await + .get_wallet_info(&self.wallet_id) + .is_some_and(|info| Arc::ptr_eq(&info.generation, &self.generation)) + } + /// Claim the pair `sender`, `recipient` for a public request: refused, /// in the same lock, while a private request to the recipient is /// reserved, or a private payment the shielded store's journal keeps @@ -929,7 +943,15 @@ impl DashPayView<'_, B> { // 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`. + // `sdk_writer.rs`. The host signs through `GatedSigner`, which + // takes the wallet generation's lifecycle gate once the signature + // returns and keeps it, to the end of this call, through the + // broadcast the SDK makes right after and the bookkeeping below. + let gated = GatedSigner { + signer, + wallet: self, + gate: std::sync::Mutex::new(SigningGate::Unsigned), + }; let result = self .sdk_writer .send_contact_request(SendContactRequestParams { @@ -944,9 +966,13 @@ impl DashPayView<'_, B> { expected_recipient_pubkey: recipient_enc_pubkey, xpub_bytes, signing_public_key: identity_public_key, - signer: signer as &(dyn Signer + Send + Sync), + signer: &gated as &(dyn Signer + Send + Sync), }) - .await?; + .await + .map_err(|error| match gated.removed() { + true => PlatformWalletError::WalletRemovedBeforeSend(hex::encode(self.wallet_id)), + false => error, + })?; // 8. Mirror the local-state bookkeeping in `send_contact_request`. // @@ -6947,6 +6973,108 @@ mod stamp_race_tests { } } +/// Where a contact request send's signing stands, for [`GatedSigner`]. +enum SigningGate { + /// The host has not signed yet: no gate is held. + Unsigned, + /// Signed, and the wallet generation's lifecycle gate is held from then + /// on, until the send ends: kept for its drop. + Gated { + _gate: tokio::sync::OwnedRwLockReadGuard<()>, + }, + /// Signed after the wallet was removed or replaced: nothing goes out. + Removed, +} + +/// The host's signer for a contact request send, taking the wallet +/// generation's lifecycle gate once a signature returns and before it can +/// leave: the SDK broadcasts right after signing, so the broadcast and the +/// send's bookkeeping run under the gate, while the host's signing prompt +/// does not. A signature made after the wallet was removed, or re-created +/// under its id, is refused, so nothing is published. +struct GatedSigner<'a, S: ?Sized, B: TransactionBroadcaster + ?Sized> { + signer: &'a S, + wallet: &'a IdentityWallet, + gate: std::sync::Mutex, +} + +impl GatedSigner<'_, S, B> { + fn gate(&self) -> std::sync::MutexGuard<'_, SigningGate> { + self.gate + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + } + + /// Whether a signature was refused because the wallet was removed or + /// replaced while the host signed. + fn removed(&self) -> bool { + matches!(*self.gate(), SigningGate::Removed) + } + + /// Take the lifecycle gate after a signature, unless held already, and + /// check the wallet is still current under it. The gate is taken before + /// the wallet manager's lock, as the lock ordering requires. + async fn hold_gate_after_signing(&self) -> Result<(), dpp::ProtocolError> { + if matches!(*self.gate(), SigningGate::Gated { .. }) { + return Ok(()); + } + let guard = self.wallet.generation.payment_guard_owned().await; + let current = self + .wallet + .wallet_manager + .read() + .await + .get_wallet_info(&self.wallet.wallet_id) + .is_some_and(|info| Arc::ptr_eq(&info.generation, &self.wallet.generation)); + if current { + *self.gate() = SigningGate::Gated { _gate: guard }; + Ok(()) + } else { + *self.gate() = SigningGate::Removed; + Err(dpp::ProtocolError::Generic( + "the wallet was removed while the host signed: nothing is sent".to_string(), + )) + } + } +} + +impl std::fmt::Debug for GatedSigner<'_, S, B> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("GatedSigner") + } +} + +#[async_trait::async_trait] +impl Signer for GatedSigner<'_, S, B> +where + S: Signer + ?Sized + Send + Sync, + B: TransactionBroadcaster + ?Sized + Send + Sync, +{ + async fn sign( + &self, + key: &IdentityPublicKey, + data: &[u8], + ) -> Result { + let signature = self.signer.sign(key, data).await?; + self.hold_gate_after_signing().await?; + Ok(signature) + } + + async fn sign_create_witness( + &self, + key: &IdentityPublicKey, + data: &[u8], + ) -> Result { + let witness = self.signer.sign_create_witness(key, data).await?; + self.hold_gate_after_signing().await?; + Ok(witness) + } + + fn can_sign_with(&self, key: &IdentityPublicKey) -> bool { + self.signer.can_sign_with(key) + } +} + #[cfg(test)] mod drain_budget_tests { use super::{bounded, budget_spent}; 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 716277bd843..4a0685b0966 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 @@ -4619,6 +4619,101 @@ mod tests { .await; } + /// A public request's host signing does not hold the wallet's lifecycle + /// gate: parked at its signature, the send leaves a removal free to take + /// the gate and complete. Its signature, returning after the removal, + /// takes the gate, finds the wallet gone and publishes nothing. + #[tokio::test] + async fn should_not_hold_a_removal_while_a_public_request_is_signed() { + use dash_sdk::query_types::IdentityContractNonceFetcher; + use std::sync::Arc as StdArc; + use tokio::sync::Notify; + + #[derive(Debug)] + struct ParkedSigner { + inner: simple_signer::signer::SimpleSigner, + called: StdArc, + release: StdArc, + } + #[async_trait::async_trait] + impl Signer for ParkedSigner { + async fn sign( + &self, + key: &IdentityPublicKey, + data: &[u8], + ) -> Result { + self.called.notify_one(); + self.release.notified().await; + self.inner.sign(key, data).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) + } + } + + let alice = identity(ALICE_PHRASE, ALICE); + let bob = identity(BOB_PHRASE, BOB); + let (manager, alice_wallet) = wallet(ALICE_PHRASE, &alice, &[&alice, &bob], vec![]).await; + let wallet_id = alice_wallet.wallet_id(); + let mut sdk = (*alice_wallet.identity().sdk).clone(); + sdk.mock() + .expect_fetch( + ( + alice.id(), + dashpay_contract().expect("bundled DashPay").id(), + ), + Some(IdentityContractNonceFetcher(3)), + ) + .await + .expect("the nonce read"); + let called = StdArc::new(Notify::new()); + let release = StdArc::new(Notify::new()); + let send = { + let wallet = Arc::clone(&alice_wallet); + let signer = ParkedSigner { + inner: signer(ALICE_PHRASE, &alice), + called: StdArc::clone(&called), + release: StdArc::clone(&release), + }; + let (alice_id, bob_id) = (alice.id(), bob.id()); + tokio::spawn(async move { + let crypto = SeedCryptoProvider::from_seed(seed(ALICE_PHRASE), Network::Testnet); + wallet + .identity() + .dashpay() + .send_contact_request_with_external_signer( + &alice_id, + &bob_id, + None, + AutoAcceptProofSource::None, + &signer, + &crypto, + ) + .await + }) + }; + tokio::time::timeout(Duration::from_secs(5), called.notified()) + .await + .expect("the send reaches its signature"); + tokio::time::timeout(Duration::from_secs(5), manager.remove_wallet(&wallet_id)) + .await + .expect("a removal is not held behind the signing") + .expect("removed"); + release.notify_one(); + let sent = send.await.expect("send task"); + assert!( + matches!(sent, Err(PlatformWalletError::WalletRemovedBeforeSend(_))), + "{sent:?}" + ); + } + /// 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. From ea35875f086ff5657f352162d5157fc7f36ad69e Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Tue, 10 Nov 2026 03:29:12 +0700 Subject: [PATCH 205/269] fix(platform-wallet): free the deferred restore queue however its task ends A deferred restoration of a wallet's private payments freed its queue only when its task ran to the end. A task that panicked, or was dropped with its runtime or by an abort, left the queue pointing at the live store, so no later bind queued another and the wallet stayed held until a configure or a restart. The deferred restoration now lives in a DeferredFinish guard the task's future owns from before it is first polled, which frees the queue when dropped, so even a task dropped unpolled frees it. The deferral returns the task's handle, which a test aborts. Co-Authored-By: Claude Opus 5.5 --- .../src/wallet/platform_wallet.rs | 93 ++++++++++++++++--- 1 file changed, 79 insertions(+), 14 deletions(-) diff --git a/packages/rs-platform-wallet/src/wallet/platform_wallet.rs b/packages/rs-platform-wallet/src/wallet/platform_wallet.rs index 5dd94248400..b7352868ba9 100644 --- a/packages/rs-platform-wallet/src/wallet/platform_wallet.rs +++ b/packages/rs-platform-wallet/src/wallet/platform_wallet.rs @@ -1067,36 +1067,39 @@ impl PlatformWallet { /// restoration succeeds, unless this generation already restored from that /// store: then its reservations are in place and nothing is held or /// queued. At most one deferred restoration per store is queued: a bind - /// while one waits queues none. Returns whether one was queued. + /// while one waits queues none. The queue frees however its task ends, + /// a panic or an abort included (`DeferredFinish`). Returns the task of + /// the deferred restoration when one was queued. #[cfg(feature = "shielded")] pub(crate) async fn restore_private_payments_or_defer( &self, coordinator: &Arc, - ) -> bool { + ) -> Option> { let restore = self.private_payment_restore(coordinator); match restore.restore_and_release(false).await { - Ok(Some(_)) => false, + Ok(Some(_)) => None, Ok(None) => { if restore.restored_from_this_store() || !restore.queue_deferred() { - return false; + return 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 { + // Owned by the task's future from before it is first polled, + // so the queue frees even for a task dropped unpolled. + let restore = DeferredFinish(restore); + Some(tokio::spawn(async move { + if let Err(error) = restore.0.restore_and_release(true).await { tracing::warn!( - wallet_id = %hex::encode(restore.wallet_id), + wallet_id = %hex::encode(restore.0.wallet_id), %error, "private contact request payments not restored; the wallet's \ contact requests stay held" ); } - restore.finish_deferred(); - }); - true + })) } Err(error) => { tracing::warn!( @@ -1105,7 +1108,7 @@ impl PlatformWallet { "private contact request payments not restored at bind; the wallet's \ contact requests stay held until a restoration succeeds" ); - false + None } } } @@ -2954,6 +2957,22 @@ struct PrivatePaymentRestore { network: dashcore::Network, } +/// A deferred restoration, freeing its queue +/// ([`PrivatePaymentRestore::finish_deferred`]) however its task ends: run, +/// panicked, or dropped with its runtime or by an abort, polled or not. +/// Otherwise a task that ended early would leave the queue taken, so no later +/// bind would queue another and the wallet would stay held until a configure +/// or a restart. +#[cfg(feature = "shielded")] +struct DeferredFinish(PrivatePaymentRestore); + +#[cfg(feature = "shielded")] +impl Drop for DeferredFinish { + fn drop(&mut self) { + self.0.finish_deferred(); + } +} + /// A wallet generation's restorations of its private contact request /// payments ([`PlatformWallet::restore_private_payments_or_defer`]). Weak, so /// a store dropped since is never taken for a new one at the same address. @@ -3596,6 +3615,43 @@ mod private_contact_request_payment_tests { .expect("the restoration completes"); } + /// A deferred restoration whose task ends early, here aborted while it + /// waits for the store, still frees the queue: the next bind queues one. + #[tokio::test] + async fn should_free_the_deferred_restore_queue_when_its_task_is_dropped() { + let dir = tempfile::tempdir().expect("temporary shielded store"); + let (_manager, wallet) = manager_with_wallet().await; + let coordinator = coordinator_on(dir.path(), wallet.sdk.network); + let queued = || { + wallet + .private_payment_restores + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .queued + }; + + let store = coordinator.store().write().await; + let task = wallet + .restore_private_payments_or_defer(&coordinator) + .await + .expect("queued"); + assert!(wallet + .restore_private_payments_or_defer(&coordinator) + .await + .is_none()); + task.abort(); + assert!(task.await.is_err_and(|error| error.is_cancelled())); + assert!( + wallet + .restore_private_payments_or_defer(&coordinator) + .await + .is_some(), + "the aborted task freed the queue" + ); + assert_eq!(queued(), 2); + drop(store); + } + /// A bind while the shielded store is busy defers the restoration of /// the wallet's private payments to one task per store: a second bind /// while it waits queues none. Once a restoration from that store @@ -3615,8 +3671,14 @@ mod private_contact_request_payment_tests { }; let store = coordinator.store().write().await; - assert!(wallet.restore_private_payments_or_defer(&coordinator).await); - assert!(!wallet.restore_private_payments_or_defer(&coordinator).await); + assert!(wallet + .restore_private_payments_or_defer(&coordinator) + .await + .is_some()); + assert!(wallet + .restore_private_payments_or_defer(&coordinator) + .await + .is_none()); assert_eq!(queued(), 1, "one deferred restoration per store"); assert!(wallet.private_payments_held().await, "held until it runs"); drop(store); @@ -3629,7 +3691,10 @@ mod private_contact_request_payment_tests { assert!(!wallet.private_payments_held().await, "restored"); let store = coordinator.store().write().await; - assert!(!wallet.restore_private_payments_or_defer(&coordinator).await); + assert!(wallet + .restore_private_payments_or_defer(&coordinator) + .await + .is_none()); assert!( !wallet.private_payments_held().await, "a busy store holds a restored wallet no more" From 5987ecb4180462530ad62525413280efd2e6611a Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Tue, 10 Nov 2026 03:29:34 +0700 Subject: [PATCH 206/269] fix(platform-wallet): read the private store before a public send's lifecycle gate A public send not yet read since load held the wallet generation's lifecycle gate through its private store read: network reads and the host's key resolution, a keychain or biometric prompt. A removal, and every payment queued behind it, waited for them. The read now runs before the gate. It updates the wallet only through steps fenced to the handle's generation under the wallet manager's write lock (IdentityWallet::current_wallet_info_mut and current_wallet_and_info_mut): the private sync's ingest, the scan key reconcile, the discharged-request release and the deferred contact crypto queues. A replacement created under the wallet's id never receives a stale read. The send then takes the gate, rechecks the generation and claims the pair under it, as before. The read's future is boxed, as it nests in the accept paths' futures. Co-Authored-By: Claude Opus 5.5 --- .../identity/network/contact_requests.rs | 44 +++-- .../identity/network/identity_handle.rs | 26 +++ .../network/private_contact_requests.rs | 161 ++++++++++++++---- 3 files changed, 182 insertions(+), 49 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 4f161e4734f..3267a9903f5 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 @@ -763,13 +763,26 @@ impl DashPayView<'_, B> { C: ContactCryptoProvider + Sync, { // Bound to this handle's wallet generation, under its lifecycle gate - // while the channel is decided and the pair claimed, and again from - // the moment the host's signature returns through the submission and - // its bookkeeping (`GatedSigner`): a removal cannot complete in either - // section, and a send started on a wallet since removed and re-created - // under the same id publishes nothing and records nothing on the - // replacement. The gate is not held while the host signs, so a - // signing prompt never holds a removal back. + // while the pair is claimed, and again from the moment the host's + // signature returns through the submission and its bookkeeping + // (`GatedSigner`): a removal cannot complete in either section, and a + // send started on a wallet since removed and re-created under the same + // id publishes nothing and records nothing on the replacement. + // + // The private store read runs before the gate: its network reads and + // the host's key resolution (a keychain or biometric prompt) never hold + // a removal back. It writes the wallet's state only through steps + // fenced to this handle's generation (`current_wallet_info_mut`), so a + // replacement never receives it, and a read that ran on a wallet + // removed meanwhile is refused at the gate. + // Boxed: the read's future (a private sync) is large, and the send's + // callers nest it in their own. + let read = Box::pin(self.read_private_store_before_deciding( + sender_identity_id, + recipient_identity_id, + crypto, + )) + .await; let claim = { let _lifecycle = self.generation.payment_guard().await; if !self.is_current_generation().await { @@ -777,12 +790,7 @@ impl DashPayView<'_, B> { self.wallet_id, ))); } - self.read_private_store_before_deciding( - sender_identity_id, - recipient_identity_id, - crypto, - ) - .await?; + read?; self.release_discharged_private_requests().await?; match self .claim_public_pair(sender_identity_id, recipient_identity_id, adopt_reply) @@ -2477,9 +2485,10 @@ impl DashPayView<'_, B> { }) .collect(); - // Serialize the queue write with identity removal under the same guard. + // Serialize the queue write with identity removal under the same guard, + // and fence it to this handle's generation. let mut wm = self.wallet_manager.write().await; - let Some(info) = wm.get_wallet_info_mut(&self.wallet_id) else { + let Ok(info) = self.current_wallet_info_mut(&mut wm) else { return; }; let Some(managed) = info.identity_manager.managed_identity_mut(identity_id) else { @@ -2675,9 +2684,10 @@ impl DashPayView<'_, B> { }); } - // Serialize the queue write with identity removal under the same guard. + // Serialize the queue write with identity removal under the same guard, + // and fence it to this handle's generation. let mut wm = self.wallet_manager.write().await; - let Some(info) = wm.get_wallet_info_mut(&self.wallet_id) else { + let Ok(info) = self.current_wallet_info_mut(&mut wm) else { return; }; let Some(managed) = info.identity_manager.managed_identity_mut(identity_id) else { 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 d252507076f..c8e7937b2e6 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 @@ -567,6 +567,32 @@ impl Clone for IdentityWallet { } impl IdentityWallet { + /// This handle's wallet in `wm`, the wallet manager under its write lock, + /// while it is still this handle's generation: refused, as not found, once + /// the wallet was removed, or re-created under its id, since the handle + /// was taken. Work that ran without the wallet's lifecycle gate (network + /// reads, host key resolution) applies its results through this, so a + /// replacement never receives a stale handle's state: the check and the + /// write are under the one lock a removal takes. + pub(crate) fn current_wallet_info_mut<'w>( + &self, + wm: &'w mut WalletManager, + ) -> Result<&'w mut PlatformWalletInfo, PlatformWalletError> { + wm.get_wallet_info_mut(&self.wallet_id) + .filter(|info| Arc::ptr_eq(&info.generation, &self.generation)) + .ok_or_else(|| PlatformWalletError::WalletNotFound(hex::encode(self.wallet_id))) + } + + /// [`Self::current_wallet_info_mut`], with the wallet itself. + pub(crate) fn current_wallet_and_info_mut<'w>( + &self, + wm: &'w mut WalletManager, + ) -> Result<(&'w mut Wallet, &'w mut PlatformWalletInfo), PlatformWalletError> { + wm.get_wallet_mut_and_info_mut(&self.wallet_id) + .filter(|(_, info)| Arc::ptr_eq(&info.generation, &self.generation)) + .ok_or_else(|| PlatformWalletError::WalletNotFound(hex::encode(self.wallet_id))) + } + /// Build the DIP-9 identity authentication derivation path. /// /// Path format: `m/9'/coin_type'/5'/0'/key_type'/identity_index'/key_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 4a0685b0966..ac08e4ffa96 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 @@ -1073,9 +1073,7 @@ impl DashPayView<'_, B> { 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 info = self.current_wallet_info_mut(&mut wm)?; let identity_ids: Vec = info .identity_manager .all_identities() @@ -1198,12 +1196,10 @@ impl DashPayView<'_, B> { 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. + // Taken only once the wallet is locked and found, this handle's + // generation, 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 info = self.current_wallet_info_mut(&mut wm)?; let discharged = self.persister.take_unreleased_discharged_private_entries(); let identity_ids: Vec = info .identity_manager @@ -1887,12 +1883,10 @@ impl DashPayView<'_, B> { // cursor past the page if that reached disk. let mut candidates = Vec::new(); { + // Fenced to this handle's generation: what the pass read + // never lands on a wallet re-created under its id. 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)) - })?; + let (wallet, info) = self.current_wallet_and_info_mut(&mut wm)?; for identity in identities.iter_mut() { let identity_id = identity.identity_id; let Some(managed) = @@ -4524,12 +4518,12 @@ 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. + /// A public request is bound to its wallet's generation: while one claims + /// its pair under the wallet's lifecycle gate, paused here at its check + /// that the wallet is still live, 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 claims 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; @@ -4543,9 +4537,10 @@ mod tests { 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; + // Stepped one wallet-lock section at a time: the private store read + // runs without the lifecycle gate, a removal free to take it; then + // the send takes the gate and waits under it, at its check that the + // wallet is still live, for the wallet lock this test holds. let view = old.identity().dashpay(); let mut send = Box::pin(view.send_contact_request_with_external_signer( &alice_id, @@ -4556,18 +4551,33 @@ mod tests { &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() + let wallet_manager = old.wallet_manager(); + let mut held = wallet_manager.write().await; + let mut gated = false; + for _ in 0..200 { + assert!(send.as_mut().poll(&mut cx).is_pending()); + // In flight under the gate, which a removal takes exclusively + // before anything else: no removal can complete. + if tokio::time::timeout( + Duration::from_millis(20), + old.generation().teardown_guard(), ) .await - .is_err(), - "a removal waits for the public request" - ); + .is_err() + { + gated = true; + break; + } + let mut next = Box::pin(wallet_manager.write()); + assert!(next.as_mut().poll(&mut cx).is_pending()); + drop(held); + assert!(send.as_mut().poll(&mut cx).is_pending()); + held = match next.as_mut().poll(&mut cx) { + std::task::Poll::Ready(guard) => guard, + std::task::Poll::Pending => panic!("the test's write is next"), + }; + } + assert!(gated, "a removal waits for the public request's claim"); // Dropped, the send gives the wallet up. drop(send); drop(held); @@ -4619,6 +4629,93 @@ mod tests { .await; } + /// A public request's private store read, with its network reads and the + /// host's key resolution, does not hold the wallet's lifecycle gate: + /// parked at its scan, the send leaves a removal free to complete. The + /// wallet re-created meanwhile under its id, the old read's results never + /// reach the replacement, as they are applied only to the read's own + /// generation, and the old send is refused. + #[tokio::test] + async fn should_not_hold_a_removal_while_a_public_request_reads_the_private_store() { + use std::sync::Arc as StdArc; + use tokio::sync::Notify; + + let alice = identity(ALICE_PHRASE, ALICE); + let bob = identity(BOB_PHRASE, BOB); + let (manager, old) = wallet(ALICE_PHRASE, &alice, &[&alice, &bob], vec![]).await; + let wallet_id = old.wallet_id(); + let called = StdArc::new(Notify::new()); + let release = StdArc::new(Notify::new()); + let send = { + let wallet = Arc::clone(&old); + let mut crypto = HookedCrypto::new(ALICE_PHRASE); + let (called, release) = (StdArc::clone(&called), StdArc::clone(&release)); + crypto.before_scan = Some(Arc::new(move || { + let (called, release) = (StdArc::clone(&called), StdArc::clone(&release)); + Box::pin(async move { + called.notify_one(); + release.notified().await; + }) + })); + let signer = signer(ALICE_PHRASE, &alice); + let (alice_id, bob_id) = (alice.id(), bob.id()); + tokio::spawn(async move { + wallet + .identity() + .dashpay() + .send_contact_request_with_external_signer( + &alice_id, + &bob_id, + None, + AutoAcceptProofSource::None, + &signer, + &crypto, + ) + .await + }) + }; + tokio::time::timeout(Duration::from_secs(5), called.notified()) + .await + .expect("the send reads the private store"); + tokio::time::timeout(Duration::from_secs(5), manager.remove_wallet(&wallet_id)) + .await + .expect("a removal is not held behind the read") + .expect("removed"); + let replacement = manager + .create_wallet_from_seed_bytes( + Network::Testnet, + &seed(ALICE_PHRASE), + WalletAccountCreationOptions::Default, + Some(0), + ) + .await + .expect("re-created"); + { + 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"); + } + release.notify_one(); + let sent = send.await.expect("send task"); + assert!( + matches!(sent, Err(PlatformWalletError::WalletRemovedBeforeSend(_))), + "{sent:?}" + ); + managed(&replacement, ALICE, |managed| { + assert!( + !managed.dashpay().private_contact_requests_read(), + "the old read's results do not reach the replacement" + ); + assert!(!managed.dashpay().public_contact_request_claimed(&bob.id())); + assert!(managed.dashpay().outgoing_request(&bob.id()).is_none()); + }) + .await; + } + /// A public request's host signing does not hold the wallet's lifecycle /// gate: parked at its signature, the send leaves a removal free to take /// the gate and complete. Its signature, returning after the removal, From d5448b61735c6e58550d4d6fddcd2f7c223fc0d2 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Tue, 10 Nov 2026 03:29:43 +0700 Subject: [PATCH 207/269] test(platform-wallet): resolve a pair's wallet-level hold through the read-back path The test of a pair held through its sender's removal resolved the payment by discharging its record and noting it on the persister by hand. It now resolves it as a wallet does: the entry lands, a private sync reads it back, and a restoration discharges its journal record from that read-back. It checks the wallet-level hold (private_pair_journaled) before and after, and that a private rotation then reaches its payment. Co-Authored-By: Claude Opus 5.5 --- .../network/private_contact_requests.rs | 46 +++++++++++++++---- 1 file changed, 37 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 ac08e4ffa96..cd9fbbcd519 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 @@ -7436,18 +7436,42 @@ mod tests { ); assert!(retry.lock().expect("retry").is_empty(), "nothing paid"); - // Resolved: its entry is read back and its record discharged. + // Resolved, with no notice given by hand: its entry lands and a + // private sync reads it back, then a restoration discharges its + // journal record from that read-back. The wallet-level hold clears, + // and a private rotation to the pair reaches its payment. + let entry = paid.lock().expect("paid")[0].clone(); + publish(&fixture.wallet, 0, vec![entry.to_vec()]).await; fixture - .coordinator - .store() - .write() + .wallet + .identity() + .dashpay() + .sync_private_contact_requests(&alice_crypto, 4) .await - .discharge_private_payment(wallet_id, &commitment) - .expect("discharged"); + .expect("the entry is read back"); + let persister = fixture.wallet.persister(); + assert!( + persister.private_pair_journaled(&alice.id(), &bob.id()), + "held until its record is discharged" + ); fixture .wallet - .persister() - .note_discharged_private_entries([commitment]); + .restore_private_contact_payments(&fixture.coordinator) + .await + .expect("restored"); + assert!(fixture + .coordinator + .store() + .read() + .await + .private_payment_journal(wallet_id) + .expect("journal") + .iter() + .all(|record| record.commitment != commitment)); + assert!( + !persister.private_pair_journaled(&alice.id(), &bob.id()), + "the read-back discharge clears the wallet-level hold" + ); let private = fixture.send_again(keep_entry(&retry)).await; assert!( matches!( @@ -7456,7 +7480,11 @@ mod tests { ), "paid, not read back: {private:?}" ); - assert_eq!(retry.lock().expect("retry").len(), 1, "the retry pays"); + assert_eq!( + retry.lock().expect("retry").len(), + 1, + "the rotation reaches its payment" + ); } /// The wallet holds a private payment's pair from just before its journal From 2e94b35148cf6532f36d6cd4f9f65a7202551e44 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Tue, 10 Nov 2026 03:36:02 +0700 Subject: [PATCH 208/269] docs(platform-wallet): list the attempt-owned reservation methods for custom stores The custom ShieldedStore migration notes now name mark_pending_by and clear_pending_reserved_by, and say a store must keep the attempt that owns each reservation and compare and clear it under its own lock: forwarding clear_pending_reserved_by to the unconditional clear_pending would let a stale cleanup release a newer attempt's notes or delete its records. Co-Authored-By: Claude Opus 5.5 --- packages/rs-platform-wallet/README.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/packages/rs-platform-wallet/README.md b/packages/rs-platform-wallet/README.md index ee931934c44..e9063515516 100644 --- a/packages/rs-platform-wallet/README.md +++ b/packages/rs-platform-wallet/README.md @@ -154,6 +154,16 @@ removes a spend's saved record and its journal records in one durable write, and refuses, changing nothing, once a re-broadcast of the record was claimed (`PendingRedrive::attempts` above 0). +Note reservations are owned by the spend attempt that took them: stores must +implement `mark_pending_by(id, nullifier, reservation)`, which reserves a note +for the attempt `reservation`, and `clear_pending_reserved_by(id, nullifier, +reservation)`, which releases it only while that same attempt still holds it, +returning whether it did. Keep the owner with each reservation and compare and +clear it under the store's own lock. Forwarding `clear_pending_reserved_by` to +the unconditional `clear_pending` defeats the protection it gives: a cleanup +that runs after a purge, a rebind or a wallet's replacement would release a +newer attempt's notes or delete its records. + Anchored note reservations and saved records now keep the proved height of the anchor set their anchor was read from: `set_pending_spend` takes an `anchor_height: Option`, `StalePendingSpend` carries it as a fourth From 21910999433bb7804942d400eef2143f16131214 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Tue, 10 Nov 2026 03:36:02 +0700 Subject: [PATCH 209/269] fix(platform-wallet): hold an absent sender's pair through the commitment-owned journal mirror A payment restored while the wallet did not manage its sender held the pair through a separate set of (sender, recipient) pairs, which only the next full restoration replaced. Once the sender was loaded and the payment discharged, read back or proven expired, the set kept refusing the pair until a restart or rebind. The commitment-indexed journal mirror, which restoration already fills for absent senders and which a discharge clears, is now the only owner of those holds; the separate set is gone. Co-Authored-By: Claude Opus 5.5 --- .../identity/network/contact_requests.rs | 3 - .../network/private_contact_requests.rs | 121 +++++++++++++++--- .../src/wallet/persister.rs | 34 ----- 3 files changed, 103 insertions(+), 55 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 3267a9903f5..9fefb9326c1 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 @@ -852,9 +852,6 @@ impl DashPayView<'_, B> { return Ok(None); } if managed.dashpay().private_reply_pending(recipient) - || self - .persister - .absent_sender_private_pair_held(sender, recipient) || self.persister.private_pair_journaled(sender, recipient) { return Err(PlatformWalletError::PrivateContactRequestPending( 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 cd9fbbcd519..c83743181bc 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 @@ -431,11 +431,9 @@ 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)>, - /// Every payment left unresolved, reserved or with its sender absent, as - /// (entry commitment, sender, recipient). + /// Every payment left unresolved, reserved or with its sender not managed + /// by the wallet, as (entry commitment, sender, recipient): the wallet's + /// hold on each pair until its record is discharged. #[cfg_attr(not(feature = "shielded"), allow(dead_code))] pub(crate) unresolved: Vec<([u8; 32], Identifier, Identifier)>, } @@ -458,7 +456,7 @@ pub(crate) struct ReservedPrivatePayments { /// - 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`]. +/// [`ReservedPrivatePayments::unresolved`] for the wallet to hold its pair. /// /// 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 @@ -480,7 +478,6 @@ pub(crate) fn reserve_unresolved_private_payments( continue; } let Some(managed) = info.identity_manager.managed_identity_mut(&sender) else { - reserved.absent.insert((sender, recipient)); reserved.unresolved.push((commitment, sender, recipient)); continue; }; @@ -525,14 +522,13 @@ pub(crate) async fn restore_journaled_private_payments( .ok_or_else(|| PlatformWalletError::WalletNotFound(hex::encode(wallet_id)))?; let ReservedPrivatePayments { restored, - absent, unresolved, } = reserve_unresolved_private_payments( info, generation, wallet_id, persister, payments, None, )?; - persister.hold_absent_sender_private_pairs(absent); // The wallet holds each unresolved payment's pair too, beyond its sender - // identity, until its record is discharged. + // identity, and whether or not it manages the sender, until its record is + // discharged. persister.note_journaled_private_payments(unresolved); // Under the same lock, after the reservations: a send that sees an // identity's hold gone sees them. @@ -882,13 +878,11 @@ impl DashPayView<'_, B> { .await?; self.release_discharged_private_requests().await?; // Held beyond the sender identity: a journaled payment's pair outlives - // the identity's removal, as does an absent sender's. + // the identity's removal, and holds for a sender loaded after it was + // restored, until its record is discharged. let held_by_wallet = self .persister - .absent_sender_private_pair_held(sender_identity_id, recipient_identity_id) - || self - .persister - .private_pair_journaled(sender_identity_id, recipient_identity_id); + .private_pair_journaled(sender_identity_id, recipient_identity_id); let reserved = self .with_managed(sender_identity_id, |managed| { if adopt_reply @@ -2101,9 +2095,9 @@ impl DashPayView<'_, B> { /// 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`). + /// whose sender the wallet does not manage holds its pair for the sends + /// of an identity added later, until its record is discharged + /// (`WalletPersister::private_pair_journaled`). /// /// An identity loaded marking a private payment that may be unresolved /// is released when the journal is authoritative (`journal_authoritative`: @@ -7544,6 +7538,97 @@ mod tests { assert!(!persister.private_pair_journaled(&alice.id(), &bob.id())); } + /// A payment restored while the wallet did not manage its sender holds the + /// pair for that sender once it is loaded, through the wallet-level hold + /// its commitment owns: so its discharge releases the pair, and a retry + /// pays with no other restoration. + #[cfg(feature = "shielded")] + #[tokio::test] + async fn should_release_an_absent_sender_s_pair_once_its_payment_is_discharged() { + 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 wallet_id = alice_wallet.wallet_id(); + let commitment = [0x5C; 32]; + let identities = |change: Box< + dyn FnOnce(&mut crate::wallet::identity::IdentityManager, &WalletPersister) + Send, + >| { + let iw = alice_wallet.identity(); + async move { + let mut wm = iw.wallet_manager.write().await; + change( + &mut wm + .get_wallet_info_mut(&wallet_id) + .expect("the wallet") + .identity_manager, + &iw.persister, + ); + } + }; + identities(Box::new(|manager, persister| { + manager + .remove_identity(&alice.id(), persister) + .expect("removed"); + })) + .await; + let view = alice_wallet.identity().dashpay(); + let restored = view + .restore_private_payments(&[(alice.id(), bob.id(), 1, commitment)], true) + .await + .expect("restored"); + assert!(restored.is_empty(), "the sender is not managed"); + let loaded = alice.clone(); + identities(Box::new(move |manager, persister| { + manager + .add_identity(loaded, 0, wallet_id, persister) + .expect("loaded"); + })) + .await; + let send = |kept: Arc>>| { + let view = alice_wallet.identity().dashpay(); + let (alice_id, bob_id) = (alice.id(), bob.id()); + let (signer, crypto) = (&alice_signer, &alice_crypto); + async move { + view.send_private_contact_request( + &alice_id, + &bob_id, + None, + AutoAcceptProofSource::None, + None, + signer, + crypto, + keep_entry(&kept), + ) + .await + } + }; + let kept = Arc::new(Mutex::new(Vec::new())); + let held = send(Arc::clone(&kept)).await; + assert!( + matches!( + held, + Err(PlatformWalletError::PrivateContactRequestPending(_)) + ), + "{held:?}" + ); + assert!(kept.lock().expect("kept").is_empty(), "held: nothing paid"); + + alice_wallet + .persister() + .note_discharged_private_entries([commitment]); + let retried = send(Arc::clone(&kept)).await; + assert!( + matches!( + retried, + Err(PlatformWalletError::PrivateContactRequestPending(_)) + ), + "paid, not read back: {retried:?}" + ); + assert_eq!(kept.lock().expect("kept").len(), 1, "the retry pays"); + } + /// A payment a restoration from the journal finds unresolved holds its /// pair at the wallet too: its sender removed and added again, a public /// request to the pair is still refused (71). diff --git a/packages/rs-platform-wallet/src/wallet/persister.rs b/packages/rs-platform-wallet/src/wallet/persister.rs index 0495f4017c4..52d220c6e8b 100644 --- a/packages/rs-platform-wallet/src/wallet/persister.rs +++ b/packages/rs-platform-wallet/src/wallet/persister.rs @@ -70,11 +70,6 @@ pub struct WalletPersister { /// discharge, so only it keeps them. #[cfg(feature = "shielded")] 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>>, /// 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 @@ -121,7 +116,6 @@ impl WalletPersister { contact_payment_gate: Arc::new(tokio::sync::Mutex::new(())), #[cfg(feature = "shielded")] read_back_private_entries: Arc::default(), - absent_sender_private_pairs: Arc::default(), discharged_private_entries: Arc::default(), private_payments_unrestored: Arc::default(), } @@ -145,34 +139,6 @@ impl WalletPersister { std::mem::take(&mut *self.read_back_entries()) } - /// 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 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)) - } - /// 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 From 863cf9c16ee5cf4f67f51a19530f1535b3ef2fb2 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Tue, 10 Nov 2026 03:38:57 +0700 Subject: [PATCH 210/269] test(platform-wallet): write the absent-sender test's identity changes inline The test's boxed identity-change closure tripped clippy's type_complexity lint; the removal and the reload are now two plain blocks. Co-Authored-By: Claude Opus 5.5 --- .../network/private_contact_requests.rs | 40 +++++++------------ 1 file changed, 15 insertions(+), 25 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 c83743181bc..7e6f01068a9 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 @@ -7552,40 +7552,30 @@ mod tests { let (_manager, alice_wallet) = wallet(ALICE_PHRASE, &alice, &[&alice, &bob], vec![]).await; let wallet_id = alice_wallet.wallet_id(); let commitment = [0x5C; 32]; - let identities = |change: Box< - dyn FnOnce(&mut crate::wallet::identity::IdentityManager, &WalletPersister) + Send, - >| { + { let iw = alice_wallet.identity(); - async move { - let mut wm = iw.wallet_manager.write().await; - change( - &mut wm - .get_wallet_info_mut(&wallet_id) - .expect("the wallet") - .identity_manager, - &iw.persister, - ); - } - }; - identities(Box::new(|manager, persister| { - manager - .remove_identity(&alice.id(), persister) + let mut wm = iw.wallet_manager.write().await; + wm.get_wallet_info_mut(&wallet_id) + .expect("the wallet") + .identity_manager + .remove_identity(&alice.id(), &iw.persister) .expect("removed"); - })) - .await; + } let view = alice_wallet.identity().dashpay(); let restored = view .restore_private_payments(&[(alice.id(), bob.id(), 1, commitment)], true) .await .expect("restored"); assert!(restored.is_empty(), "the sender is not managed"); - let loaded = alice.clone(); - identities(Box::new(move |manager, persister| { - manager - .add_identity(loaded, 0, wallet_id, persister) + { + let iw = alice_wallet.identity(); + let mut wm = iw.wallet_manager.write().await; + wm.get_wallet_info_mut(&wallet_id) + .expect("the wallet") + .identity_manager + .add_identity(alice.clone(), 0, wallet_id, &iw.persister) .expect("loaded"); - })) - .await; + } let send = |kept: Arc>>| { let view = alice_wallet.identity().dashpay(); let (alice_id, bob_id) = (alice.id(), bob.id()); From 7d053ca60ec01e75a6d0bcff82b5d2eb3e37552d Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Tue, 10 Nov 2026 03:55:39 +0700 Subject: [PATCH 211/269] fix(platform-wallet): refuse removing an identity while its contact request channel is held An unresolved private payment's pair, a public request's claim and a private request's reservation all lived on the sender's ManagedIdentity, and removing it dropped them with its persisted rows: loaded again, the identity started with no hold and stored a cleared marker. A public request could then publish a pair a private payment still kept off chain, in this process (a send parked in signing while the sender was removed and reloaded) or after a restart without shielded support. IdentityManager::remove_identity now refuses, with nothing removed, while the identity has a contact request in flight, private or public, or a private payment the wallet's journal still keeps (PrivateContactRequestPending, 71), or one from an earlier process not restored yet (PrivateContactPaymentsNotRestored, 72). The identity's stored marker also consults the wallet's journal, so it is never stored cleared while a payment the identity's own state no longer shows may still execute. The FFI removal reports those two codes instead of "identity not found". Co-Authored-By: Claude Opus 5.5 --- .../src/identity_manager.rs | 17 +- .../identity/network/contact_requests.rs | 8 +- .../network/private_contact_requests.rs | 293 ++++++++++++++---- .../state/managed_identity/dashpay.rs | 12 + .../identity/state/manager/lifecycle.rs | 23 ++ .../src/wallet/persister.rs | 12 + 6 files changed, 297 insertions(+), 68 deletions(-) diff --git a/packages/rs-platform-wallet-ffi/src/identity_manager.rs b/packages/rs-platform-wallet-ffi/src/identity_manager.rs index 0fe211ae4bc..3329cb904c8 100644 --- a/packages/rs-platform-wallet-ffi/src/identity_manager.rs +++ b/packages/rs-platform-wallet-ffi/src/identity_manager.rs @@ -4,7 +4,7 @@ use crate::handle::*; use crate::types::*; use crate::{unwrap_option_or_return, unwrap_result_or_return}; use platform_wallet::wallet::persister::{NoPlatformPersistence, WalletPersister}; -use platform_wallet::IdentityManager; +use platform_wallet::{IdentityManager, PlatformWalletError}; use std::sync::Arc; pub(crate) fn ffi_noop_persister() -> WalletPersister { @@ -65,13 +65,20 @@ pub unsafe extern "C" fn identity_manager_remove_identity( manager.remove_identity(&id, &ffi_noop_persister()) }); let result = unwrap_option_or_return!(option); - if result.is_err() { - return PlatformWalletFFIResult::err( + match result { + Ok(_) => PlatformWalletFFIResult::ok(), + // Refused while the identity's contact request channel is held: a + // request in flight or a private payment that may still execute (71), + // or one not restored yet (72). + Err( + error @ (PlatformWalletError::PrivateContactRequestPending(_) + | PlatformWalletError::PrivateContactPaymentsNotRestored), + ) => PlatformWalletFFIResult::from(error), + Err(_) => PlatformWalletFFIResult::err( PlatformWalletFFIResultCode::ErrorIdentityNotFound, "Identity not found", - ); + ), } - PlatformWalletFFIResult::ok() } /// Get an identity by ID. `identity_id` is a `*const u8` to a 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 9fefb9326c1..a1e31101350 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 @@ -27,7 +27,7 @@ use key_wallet_manager::WalletManager; use std::sync::Arc; use tokio::sync::RwLock; -use super::private_contact_requests::apply_detached; +use super::private_contact_requests::{apply_detached, with_journaled_marker}; use dash_sdk::platform::dashpay::PrivateContactRequest; use dash_sdk::platform::private_document_entries::PrivateDocumentEntries; use dash_sdk::platform::private_document_scan::PrivateDocumentScan; @@ -4726,7 +4726,11 @@ impl DashPayView<'_, B> { if self.persister.persists_private_contact_scan_state() { cs.private_contact_requests_positions.insert( *identity_id, - managed.private_contact_requests_position_record(), + with_journaled_marker( + &self.persister, + *identity_id, + 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 7e6f01068a9..5157f2676b5 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 @@ -640,6 +640,20 @@ fn shielded_address_changeset( changeset } +/// `position`, `owner`'s record, marked as holding a private payment that may +/// be unresolved while the wallet's journal keeps one of `owner`'s +/// (`WalletPersister::private_sender_journaled`): a marker is never stored +/// cleared while a payment the identity's own state no longer shows may still +/// execute. +pub(super) fn with_journaled_marker( + persister: &WalletPersister, + owner: Identifier, + mut position: PrivateContactRequestsPosition, +) -> PrivateContactRequestsPosition { + position.private_payments_unresolved |= persister.private_sender_journaled(&owner).is_some(); + position +} + /// 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. @@ -648,6 +662,7 @@ fn store_private_position( owner: Identifier, position: PrivateContactRequestsPosition, ) { + let position = with_journaled_marker(persister, owner, position); let mut changeset = ContactChangeSet::default(); changeset .private_contact_requests_positions @@ -6902,6 +6917,7 @@ mod tests { #[cfg(feature = "shielded")] struct ExpiryFixture { _manager: PlatformWalletManager, + persistence: Arc, wallet: Arc, coordinator: Arc, subwallet: SubwalletId, @@ -6921,11 +6937,12 @@ mod tests { let alice = identity(ALICE_PHRASE, ALICE); let bob = identity(BOB_PHRASE, BOB); // A backend that keeps the viewing key, so the wallet binds. + let persistence = RecordingPersistence::attesting( + PersistenceCapabilities::SHIELDED_FVK_RESTART + .union(PersistenceCapabilities::PRIVATE_CONTACT_SCAN_STATE_RESTART), + ); let (manager, wallet) = wallet_on( - RecordingPersistence::attesting( - PersistenceCapabilities::SHIELDED_FVK_RESTART - .union(PersistenceCapabilities::PRIVATE_CONTACT_SCAN_STATE_RESTART), - ), + Arc::clone(&persistence), ALICE_PHRASE, &alice, &[&alice, &bob], @@ -6964,6 +6981,7 @@ mod tests { .expect("the input note"); Self { _manager: manager, + persistence, wallet, coordinator, subwallet, @@ -7281,16 +7299,21 @@ mod tests { assert_eq!(retry.lock().expect("retry").len(), 1, "the retry pays"); } - /// An unresolved private payment holds its pair at the wallet, beyond its - /// sender identity: removed and loaded again through - /// `load_identity_by_index_from_master` (the load an external-signable - /// wallet takes, sharing `load_identity_by_index`'s body), the identity - /// starts with no reservation, yet - /// a public request to the pair is refused (71), and so is a private one, - /// until the payment resolves. Then a private one pays. + /// An unresolved private payment holds its pair beyond its sender + /// identity's own state, and refuses the identity's removal (71): removed + /// and loaded again, the identity would start with no hold. Once its entry + /// is read back the identity's reservation settles, but the wallet's + /// journal keeps the payment until its record is discharged: until then + /// the pair stays held, the removal stays refused, and the identity's + /// stored marker stays set, so a restart without shielded support holds + /// it too. Discharged by a restoration from that read-back, with no + /// notice given by hand, the removal goes through, and the identity loaded + /// again through `load_identity_by_index_from_master` (the load an + /// external-signable wallet takes, sharing `load_identity_by_index`'s + /// body) sends a private request that reaches its payment. #[cfg(feature = "shielded")] #[tokio::test] - async fn should_hold_a_pair_through_its_sender_s_removal_and_reload() { + async fn should_hold_a_pair_and_its_sender_until_the_private_payment_resolves() { use crate::wallet::shielded::{ ShieldedActivityEntry, ShieldedActivityKind, ShieldedActivityStatus, ShieldedDirection, }; @@ -7358,46 +7381,25 @@ mod tests { ), "{sent:?}" ); - let commitment: [u8; 32] = paid.lock().expect("paid")[0].as_slice()[..32] + let entry = paid.lock().expect("paid")[0].clone(); + let commitment: [u8; 32] = entry.as_slice()[..32] .try_into() .expect("an entry starts with its commitment"); - - // The sender identity is removed and loaded again from Platform. - { + let remove = || async { let iw = fixture.wallet.identity(); let mut wm = iw.wallet_manager.write().await; wm.get_wallet_info_mut(&wallet_id) .expect("the wallet") .identity_manager .remove_identity(&alice.id(), &iw.persister) - .expect("removed"); - } - let probe = alice.public_keys()[&SIGNING_KEY_ID] - .public_key_hash() - .expect("a key hash"); - let mut sdk = (*fixture.wallet.identity().sdk).clone(); - sdk.mock() - .expect_fetch(PublicKeyHash(probe), Some(alice.clone())) - .await - .expect("the identity at index 0"); - fixture - .wallet - .identity() - .load_identity_by_index_from_master( - 0, - &ExtendedPrivKey::new_master(Network::Testnet, &seed(ALICE_PHRASE)) - .expect("the master key"), - ) - .await - .expect("loaded") - .expect("found at index 0"); - managed(&fixture.wallet, ALICE, |managed| { - assert!( - !managed.dashpay().private_reply_pending(&bob.id()), - "the reloaded identity holds no reservation" - ); - }) - .await; + .map(|_| ()) + }; + let refused = |result: &Result<(), PlatformWalletError>| { + matches!(result, Err(PlatformWalletError::PrivateContactRequestPending(recipient)) + if *recipient == bob.id()) + }; + let removed = remove().await; + assert!(refused(&removed), "removal refused while held: {removed:?}"); let public = fixture .wallet @@ -7430,11 +7432,8 @@ mod tests { ); assert!(retry.lock().expect("retry").is_empty(), "nothing paid"); - // Resolved, with no notice given by hand: its entry lands and a - // private sync reads it back, then a restoration discharges its - // journal record from that read-back. The wallet-level hold clears, - // and a private rotation to the pair reaches its payment. - let entry = paid.lock().expect("paid")[0].clone(); + // Its entry lands and a private sync reads it back: the identity's + // reservation settles, the journal record waits for its discharge. publish(&fixture.wallet, 0, vec![entry.to_vec()]).await; fixture .wallet @@ -7443,11 +7442,28 @@ mod tests { .sync_private_contact_requests(&alice_crypto, 4) .await .expect("the entry is read back"); + managed(&fixture.wallet, ALICE, |managed| { + assert!(!managed.dashpay().private_reply_pending(&bob.id())); + assert!(!managed.dashpay().private_payments_unresolved()); + }) + .await; let persister = fixture.wallet.persister(); assert!( persister.private_pair_journaled(&alice.id(), &bob.id()), "held until its record is discharged" ); + assert_eq!( + last_marker( + &fixture.persistence.stored.lock().expect("stored"), + &alice.id() + ), + Some(true), + "the stored marker stays set while the journal keeps the payment" + ); + let removed = remove().await; + assert!(refused(&removed), "still refused: {removed:?}"); + + // Discharged from that read-back by a restoration. fixture .wallet .restore_private_contact_payments(&fixture.coordinator) @@ -7466,6 +7482,26 @@ mod tests { !persister.private_pair_journaled(&alice.id(), &bob.id()), "the read-back discharge clears the wallet-level hold" ); + remove().await.expect("removed once resolved"); + let probe = alice.public_keys()[&SIGNING_KEY_ID] + .public_key_hash() + .expect("a key hash"); + let mut sdk = (*fixture.wallet.identity().sdk).clone(); + sdk.mock() + .expect_fetch(PublicKeyHash(probe), Some(alice.clone())) + .await + .expect("the identity at index 0"); + fixture + .wallet + .identity() + .load_identity_by_index_from_master( + 0, + &ExtendedPrivKey::new_master(Network::Testnet, &seed(ALICE_PHRASE)) + .expect("the master key"), + ) + .await + .expect("loaded") + .expect("found at index 0"); let private = fixture.send_again(keep_entry(&retry)).await; assert!( matches!( @@ -7477,10 +7513,145 @@ mod tests { assert_eq!( retry.lock().expect("retry").len(), 1, - "the rotation reaches its payment" + "a private request reaches its payment" ); } + /// A sender identity is not removed while a contact request of it is in + /// flight (71), whichever channel: a public request parked at its host + /// signature holds its claim, and a private one parked at the signature + /// of the request it seals holds its reservation. Removed and loaded again in either + /// window, the identity would start with no hold, and the other channel + /// could publish the pair or pay for it. + #[tokio::test] + async fn should_refuse_removing_a_sender_while_a_contact_request_is_in_flight() { + use dash_sdk::query_types::IdentityContractNonceFetcher; + use std::sync::Arc as StdArc; + use tokio::sync::Notify; + + #[derive(Debug)] + struct ParkedSigner { + inner: simple_signer::signer::SimpleSigner, + called: StdArc, + release: StdArc, + } + #[async_trait::async_trait] + impl Signer for ParkedSigner { + async fn sign( + &self, + key: &IdentityPublicKey, + data: &[u8], + ) -> Result { + self.called.notify_one(); + self.release.notified().await; + self.inner.sign(key, data).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) + } + } + + let alice = identity(ALICE_PHRASE, ALICE); + let bob = identity(BOB_PHRASE, BOB); + for public in [true, false] { + let (_manager, alice_wallet) = + wallet(ALICE_PHRASE, &alice, &[&alice, &bob], vec![]).await; + let wallet_id = alice_wallet.wallet_id(); + let mut sdk = (*alice_wallet.identity().sdk).clone(); + sdk.mock() + .expect_fetch( + ( + alice.id(), + dashpay_contract().expect("bundled DashPay").id(), + ), + Some(IdentityContractNonceFetcher(3)), + ) + .await + .expect("the nonce read"); + let called = StdArc::new(Notify::new()); + let release = StdArc::new(Notify::new()); + let send = { + let wallet = Arc::clone(&alice_wallet); + let (alice_id, bob_id) = (alice.id(), bob.id()); + let (called, release) = (StdArc::clone(&called), StdArc::clone(&release)); + let alice_signer = signer(ALICE_PHRASE, &alice); + tokio::spawn(async move { + let signer = ParkedSigner { + inner: alice_signer, + called, + release, + }; + let crypto = + SeedCryptoProvider::from_seed(seed(ALICE_PHRASE), Network::Testnet); + if public { + wallet + .identity() + .dashpay() + .send_contact_request_with_external_signer( + &alice_id, + &bob_id, + None, + AutoAcceptProofSource::None, + &signer, + &crypto, + ) + .await + .map(|_| ()) + } else { + wallet + .identity() + .dashpay() + .send_private_contact_request( + &alice_id, + &bob_id, + None, + AutoAcceptProofSource::None, + None, + &signer, + &crypto, + |_: BinaryData, _: PaymentStarted| { + std::future::ready(Err::( + PlatformWalletError::ShieldedBroadcastFailed( + "not paid in this test".to_string(), + ), + )) + }, + ) + .await + .map(|_| ()) + } + }) + }; + tokio::time::timeout(Duration::from_secs(5), called.notified()) + .await + .expect("the send is in flight"); + let removed = { + let iw = alice_wallet.identity(); + let mut wm = iw.wallet_manager.write().await; + wm.get_wallet_info_mut(&wallet_id) + .expect("the wallet") + .identity_manager + .remove_identity(&alice.id(), &iw.persister) + .map(|_| ()) + }; + assert!( + matches!(&removed, Err(PlatformWalletError::PrivateContactRequestPending(recipient)) + if *recipient == bob.id()), + "public {public}: {removed:?}" + ); + release.notify_one(); + let _ = send.await.expect("send task"); + managed(&alice_wallet, ALICE, |_| {}).await; + } + } + /// The wallet holds a private payment's pair from just before its journal /// records are saved; a payment whose records were not saved, here one /// spending no note, paid nothing and holds nothing. @@ -7620,11 +7791,11 @@ mod tests { } /// A payment a restoration from the journal finds unresolved holds its - /// pair at the wallet too: its sender removed and added again, a public - /// request to the pair is still refused (71). + /// pair at the wallet too, and refuses its sender's removal (71): the + /// identity stays, and a public request to the pair is refused. #[cfg(feature = "shielded")] #[tokio::test] - async fn should_hold_a_restored_payment_s_pair_through_its_sender_s_removal() { + async fn should_refuse_removing_a_sender_holding_a_restored_payment() { let alice = identity(ALICE_PHRASE, ALICE); let bob = identity(BOB_PHRASE, BOB); let alice_crypto = SeedCryptoProvider::from_seed(seed(ALICE_PHRASE), Network::Testnet); @@ -7639,16 +7810,16 @@ mod tests { { let iw = alice_wallet.identity(); let mut wm = iw.wallet_manager.write().await; - let manager = &mut wm + let removed = wm .get_wallet_info_mut(&alice_wallet.wallet_id()) .expect("the wallet") - .identity_manager; - manager - .remove_identity(&alice.id(), &iw.persister) - .expect("removed"); - manager - .add_identity(alice.clone(), 0, alice_wallet.wallet_id(), &iw.persister) - .expect("added again"); + .identity_manager + .remove_identity(&alice.id(), &iw.persister); + assert!( + matches!(&removed, Err(PlatformWalletError::PrivateContactRequestPending(recipient)) + if *recipient == bob.id()), + "{removed:?}" + ); } let public = view .send_contact_request_with_external_signer( 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 acd334985c4..5fd632c5f17 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 @@ -349,6 +349,18 @@ impl DashPayState { !self.pending_private_replies.is_empty() } + /// A recipient this identity holds a contact request in flight to, if + /// any: a private request it is paying for or has not read back + /// ([`Self::private_reply_pending`]), or a public one claimed and not yet + /// recorded ([`Self::public_contact_request_claimed`]). + pub fn contact_request_in_flight(&self) -> Option { + self.pending_private_replies + .keys() + .chain(self.public_contact_request_claims.keys()) + .next() + .copied() + } + /// 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/identity/state/manager/lifecycle.rs b/packages/rs-platform-wallet/src/wallet/identity/state/manager/lifecycle.rs index 2efbf0f4345..c5ad0eabb31 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/state/manager/lifecycle.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/state/manager/lifecycle.rs @@ -180,11 +180,34 @@ impl IdentityManager { /// Removal is terminal in storage as well as in memory: the persister /// deletes the identity and every row it owns, so re-adding the same /// id later starts from a blank identity. + /// + /// Refused, with nothing removed, while the identity's private contact + /// request channel is held: a contact request in flight, private or + /// public, or a private payment that may still execute, whether its own + /// state or the wallet's journal holds it + /// ([`PlatformWalletError::PrivateContactRequestPending`]), or one from an + /// earlier process not restored yet + /// ([`PlatformWalletError::PrivateContactPaymentsNotRestored`]). Removed + /// and loaded again, the identity would start with none of those holds, + /// and its persisted marker with it, so a public request could publish a + /// pair a private payment still keeps off chain. pub fn remove_identity( &mut self, identity_id: &Identifier, persister: &WalletPersister, ) -> Result { + if let Some(managed) = self.managed_identity(identity_id) { + let dashpay = managed.dashpay(); + if let Some(recipient) = dashpay + .contact_request_in_flight() + .or_else(|| persister.private_sender_journaled(identity_id)) + { + return Err(PlatformWalletError::PrivateContactRequestPending(recipient)); + } + if dashpay.private_payments_unrestored() { + return Err(PlatformWalletError::PrivateContactPaymentsNotRestored); + } + } // Drop the side-index entry first; its value tells us which // bucket the identity lives in so we can avoid a second scan. let location = self diff --git a/packages/rs-platform-wallet/src/wallet/persister.rs b/packages/rs-platform-wallet/src/wallet/persister.rs index 52d220c6e8b..7ce95283f8c 100644 --- a/packages/rs-platform-wallet/src/wallet/persister.rs +++ b/packages/rs-platform-wallet/src/wallet/persister.rs @@ -189,6 +189,18 @@ impl WalletPersister { } } + /// A recipient of a private payment from `sender` the journal keeps + /// unresolved ([`Self::note_journaled_private_payments`]), if any: the + /// sender then holds a payment that may still execute, whatever its + /// identity's own state says. + pub(crate) fn private_sender_journaled(&self, sender: &Identifier) -> Option { + self.discharged_entries() + .journaled + .values() + .find(|(journaled_sender, _)| journaled_sender == sender) + .map(|(_, recipient)| *recipient) + } + /// Whether a private payment the journal keeps unresolved holds the pair /// `sender`, `recipient` ([`Self::note_journaled_private_payments`]): then /// neither a public request nor another private one is sent for it. From a2630b2f30a6515fb0f51f9082bf426ecf0498bf Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Tue, 10 Nov 2026 04:23:45 +0700 Subject: [PATCH 212/269] feat(platform-wallet): keep the largest commitment tree the shielded store held The file-backed store now keeps durably the largest commitment tree it has held, raised at every open and before every reset, and reports the size held when it was opened through `ShieldedStore::legacy_anchor_tree_bound` (default `None`). A record an earlier build saved without its anchor height took its anchor from a tree no larger, which bounds the pool size that shows that anchor recorded. Co-Authored-By: Claude Opus 5.5 --- .../src/wallet/shielded/file_store.rs | 100 ++++++++++++++++++ .../src/wallet/shielded/store.rs | 13 +++ 2 files changed, 113 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 fa20f3eced4..17193227a18 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/file_store.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/file_store.rs @@ -142,6 +142,9 @@ pub struct FileBackedShieldedStore { /// 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 largest commitment tree the store had held when it was opened + /// ([`ShieldedStore::legacy_anchor_tree_bound`]); `None` for none. + legacy_anchor_tree_bound: Option, } /// The private contact request association a saved record carries. @@ -500,6 +503,17 @@ impl FileBackedShieldedStore { .map_err(|e| { FileShieldedStoreError::Storage(format!("create private payment journal: {e}")) })?; + // The largest commitment tree the store has held, raised at every open + // and before every reset: never purged. + pending_conn + .execute( + "CREATE TABLE IF NOT EXISTS shielded_store_meta ( + key TEXT NOT NULL PRIMARY KEY, + value INTEGER NOT NULL + )", + [], + ) + .map_err(|e| FileShieldedStoreError::Storage(format!("create store meta: {e}")))?; let mut store = Self { tree: Mutex::new(tree), path, @@ -511,12 +525,49 @@ impl FileBackedShieldedStore { private_payment_journal: BTreeMap::new(), private_payment_journals: BTreeSet::new(), unreadable_private_payment_journal: BTreeSet::new(), + legacy_anchor_tree_bound: None, }; + // Every record loaded below without an anchor height was saved by an + // earlier process, from a tree no larger than the largest the store + // has held: the tree as it is now, or one a reset cleared, whose size + // the reset kept. + let size = store.tree_size()?; + let largest = store.raise_largest_tree_size(size)?; + store.legacy_anchor_tree_bound = (largest > 0).then_some(largest); store.rehydrate_pending_spends()?; store.load_private_payment_journal()?; Ok(store) } + /// Raise the largest commitment tree size the store keeps durably to + /// `size`, returning the largest it keeps now. + fn raise_largest_tree_size(&self, size: u64) -> Result { + let size = i64::try_from(size).map_err(|_| { + FileShieldedStoreError::Storage(format!("tree size {size} past SQLite's integers")) + })?; + let conn = self + .pending_conn + .lock() + .map_err(|e| FileShieldedStoreError::Storage(format!("pending_conn mutex: {e}")))?; + conn.execute( + "INSERT INTO shielded_store_meta (key, value) VALUES ('largest_tree_size', ?1) \ + ON CONFLICT(key) DO UPDATE SET value = MAX(value, excluded.value)", + rusqlite::params![size], + ) + .map_err(|e| FileShieldedStoreError::Storage(format!("raise largest tree size: {e}")))?; + let largest: i64 = conn + .query_row( + "SELECT value FROM shielded_store_meta WHERE key = 'largest_tree_size'", + [], + |row| row.get(0), + ) + .map_err(|e| FileShieldedStoreError::Storage(format!("read largest tree size: {e}")))?; + u64::try_from(largest).map_err(|_| FileShieldedStoreError::RecoveryCorrupted { + account_index: None, + reason: format!("a negative largest tree size {largest}"), + }) + } + /// Reload every persisted [`PendingRedrive`] into the in-memory /// per-subwallet state, re-arming both the redrive record and the /// note reservations its nullifiers carry — an unconfirmed @@ -1628,7 +1679,16 @@ impl ShieldedStore for FileBackedShieldedStore { Ok(()) } + fn legacy_anchor_tree_bound(&self) -> Option { + self.legacy_anchor_tree_bound + } + fn reset_commitment_tree(&mut self) -> Result<(), Self::Error> { + // Its size kept first: a record saved before the reset, without an + // anchor height, took its anchor from a tree no larger than this one. + // A failure resets nothing. + let size = self.tree_size()?; + self.raise_largest_tree_size(size)?; // The `ClientPersistentCommitmentTree` wrapper owns its // `Connection` and exposes no public truncate (only the inner // `SqliteShardStore` has `truncate_shards`). A full reset @@ -3052,6 +3112,46 @@ mod tests { let _ = std::fs::remove_file(&path); } + /// The bound on the trees that anchors named without their heights came + /// from is the largest tree the store has held: none for a store that + /// held no tree when it was opened, kept as it was at open while the tree + /// grows, and after a reset emptied the tree and a smaller one grew, the + /// size the reset kept first. + #[test] + fn should_bound_heightless_anchors_by_the_largest_tree_the_store_held() { + let path = temp_tree_path("largest_tree"); + let append = |store: &mut FileBackedShieldedStore, leaves: u8| { + for leaf in 0..leaves { + store + .append_commitment(&[leaf + 1; 32], true) + .expect("appended"); + } + }; + let mut store = FileBackedShieldedStore::open_path(&path, 100).expect("open"); + assert_eq!(store.legacy_anchor_tree_bound(), None, "no tree at open"); + append(&mut store, 3); + assert_eq!(store.legacy_anchor_tree_bound(), None, "as it was at open"); + store.reset_commitment_tree().expect("reset"); + append(&mut store, 1); + drop(store); + let mut store = FileBackedShieldedStore::open_path(&path, 100).expect("reopened"); + assert_eq!(store.tree_size().expect("size"), 1); + assert_eq!( + store.legacy_anchor_tree_bound(), + Some(3), + "the tree the reset emptied" + ); + append(&mut store, 4); + drop(store); + let store = FileBackedShieldedStore::open_path(&path, 100).expect("reopened again"); + assert_eq!( + store.legacy_anchor_tree_bound(), + Some(5), + "the tree as reopened" + ); + 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, diff --git a/packages/rs-platform-wallet/src/wallet/shielded/store.rs b/packages/rs-platform-wallet/src/wallet/shielded/store.rs index b2671d46617..8c00264bcbe 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/store.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/store.rs @@ -506,6 +506,19 @@ pub trait ShieldedStore: Send + Sync + 'static { height: u64, ) -> Result<(), Self::Error>; + /// An upper bound on the size of the commitment tree any record this store + /// loaded at open without an anchor height took its anchor from: the + /// largest tree the store has held, kept durably across resets. Such a + /// record was saved by an earlier process, whose spends were built on + /// roots of its local tree, which only holds proved commitments. So once + /// a proved pool count at or below an anchor set's height reaches this + /// bound, the anchor was recorded by then, and the set's lack of it shows + /// it pruned rather than newer (`PrunedAnchorEvidence::proves_pruned`). + /// `None` when the store keeps no such bound: such records stay held. + fn legacy_anchor_tree_bound(&self) -> Option { + None + } + /// Pending reservations for `id` that carry a recorded anchor — /// `(nullifier, anchor, activity_id, anchor_height)` per /// built-but-unconfirmed spend. Empty when `id` has no anchored reservations (the common From 192d5d869b98aa39ec50809bfeb16f06797aa4c5 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Tue, 10 Nov 2026 04:24:24 +0700 Subject: [PATCH 213/269] fix(platform-wallet): prove a heightless anchor recorded by the pool's leaf count A reservation, saved record or journal record an earlier build saved without its anchor height was loaded with height 0, so the first anchor set lacking its anchor released its notes and discharged its pair. A restarted process can be served authentic proofs a block behind an anchor first recorded at H: the set at H - 1 lacks it because it did not exist yet. Unknown heights now stay unknown. Such an anchor counts as recorded by a set's height H only when the pool's leaf count, proved in this process at or below H, reaches both the largest tree the store held (every such anchor is a root of a tree no larger) and one past the position of every note spent on it that the store holds. Platform records a root at the end of the first block whose tree ends at that size, and the size never shrinks, so the set's lack of it then shows it pruned. The sync fetches that count before the anchor set, only when a heightless record exists and the store keeps a bound. The scan must still cover H and, for a journal record, the store read through its end must still lack the entry at H. Without that evidence the record stays held. Co-Authored-By: Claude Opus 5.5 --- .../network/private_contact_requests.rs | 107 ++++-- .../src/wallet/shielded/coordinator.rs | 360 ++++++++++++++++-- .../src/wallet/shielded/file_store.rs | 16 +- .../src/wallet/shielded/store.rs | 29 +- 4 files changed, 416 insertions(+), 96 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 5157f2676b5..4b6ea75cd7c 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 @@ -2330,7 +2330,8 @@ mod tests { use crate::wallet::platform_wallet::{PlatformWallet, WalletId}; #[cfg(feature = "shielded")] use crate::wallet::shielded::coordinator::{ - private_payment_journal_snapshot, PrunedAnchorEvidence, + heightless_tree_bounds, private_payment_journal_snapshot, HeightlessAnchorEvidence, + PrunedAnchorEvidence, }; #[cfg(feature = "shielded")] use crate::wallet::shielded::operations::{ @@ -7093,6 +7094,7 @@ mod tests { recorded, height: Self::ANCHOR_SET_HEIGHT, scan_covered_height, + heightless: None, }, ) .await; @@ -7840,17 +7842,20 @@ mod tests { ); } - /// A journal record with no anchor height is never proven expired while - /// it was created in this process. One an earlier process saved, before - /// heights were kept, counts as read before any set this process fetches: - /// reopened, it is discharged once its anchor is absent from a set at H, - /// the scan covers H and the store read through its end at H lacks its - /// entry, and not while the scan is below H. + /// A journal record with no anchor height, saved while the store's tree + /// held `TREE` leaves. A store that held no tree when it was opened bounds + /// no such anchor, so the record stays. Reopened, its height stays + /// unknown, and it is discharged only with the pool proved, at or below + /// the set's height, to hold `TREE` leaves or more, the scan covering the + /// set and the store read through its end at that height lacking its + /// entry. Evidence a block behind the anchor, every proof at H - 1 and the + /// pool short of its tree, discharges nothing. #[cfg(feature = "shielded")] #[tokio::test] - async fn should_discharge_a_reopened_heightless_journal_record_once_its_evidence_covers_the_set( + async fn should_discharge_a_reopened_heightless_journal_record_only_once_the_pool_reached_its_tree( ) { use crate::wallet::shielded::PrivatePaymentRecord; + const TREE: u64 = 4; let alice = identity(ALICE_PHRASE, ALICE); let bob = identity(BOB_PHRASE, BOB); @@ -7871,18 +7876,27 @@ mod tests { }, nullifiers: vec![fixture.input.nullifier], }; - fixture - .coordinator - .store() - .write() - .await - .journal_private_payments(wallet_id, std::slice::from_ref(&record)) - .expect("journaled"); + { + let mut store = fixture.coordinator.store().write().await; + for leaf in 0..TREE { + store + .append_commitment(&[leaf as u8 + 1; 32], true) + .expect("appended"); + } + store + .journal_private_payments(wallet_id, std::slice::from_ref(&record)) + .expect("journaled"); + } let recorded = HashSet::new(); - // Created in this process with no height: unproven. + // The store held no tree when it was opened: no bound. let journal = fixture.journal_snapshot().await; + assert_eq!( + heightless_tree_bounds(&*fixture.coordinator.store().read().await, &[], &journal), + None, + "no tree held at open" + ); fixture.discharge(&journal, &recorded, Some(height)).await; - assert_eq!(fixture.journaled().await, 1, "created in this process"); + assert_eq!(fixture.journaled().await, 1, "unbounded"); // Saved by an earlier process: this one reopens the store. let reopened = NetworkShieldedCoordinator::new( @@ -7906,27 +7920,62 @@ mod tests { .into_iter() .map(|record| (wallet_id, record)) .collect(); - assert_eq!( - journal[0].1.anchor_height, - Some(0), - "read before this process" - ); - for (covered, discharged) in [(height - 1, false), (height, true)] { + assert_eq!(journal[0].1.anchor_height, None, "its height stays unknown"); + let (tree_bound, anchor_floors) = + heightless_tree_bounds(&*reopened.store().read().await, &[], &journal) + .expect("bounded"); + assert_eq!(tree_bound, TREE, "the tree the earlier process held"); + // (set height, scan coverage, pool leaves and their height, discharged) + let cases = [ + ( + height - 1, + height - 1, + (TREE - 1, height - 1), + false, + "a block behind", + ), + ( + height, + height, + (TREE - 1, height), + false, + "the pool short of the tree", + ), + ( + height, + height, + (TREE, height + 1), + false, + "the pool proved above the set", + ), + ( + height, + height - 1, + (TREE, height), + false, + "the scan below the set", + ), + (height, height, (TREE, height), true, "proven"), + ]; + for (set_height, covered, (pool_leaves, pool_height), discharged, case) in cases { + let heightless = HeightlessAnchorEvidence { + tree_bound, + pool_leaves, + pool_height, + anchor_floors: anchor_floors.clone(), + }; reopened .discharge_expired_private_payments( &journal, PrunedAnchorEvidence { recorded: &recorded, - height, + height: set_height, scan_covered_height: Some(covered), + heightless: Some(&heightless), }, ) .await; - assert_eq!( - records(&reopened).await.is_empty(), - discharged, - "scan covering {covered}" - ); + assert_eq!(records(&reopened).await.is_empty(), discharged, "{case}"); } assert!(FileBackedShieldedStore::open_path(&fixture.db_path, 100) .expect("reopened again") diff --git a/packages/rs-platform-wallet/src/wallet/shielded/coordinator.rs b/packages/rs-platform-wallet/src/wallet/shielded/coordinator.rs index af9f5f16978..8e154171d2b 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/coordinator.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/coordinator.rs @@ -1522,6 +1522,7 @@ impl NetworkShieldedCoordinator { journal, recorded, recorded_height, + heightless, }) = stranded_release.filter(|_| !cancelled) { for (id, _) in &subwallets { @@ -1545,6 +1546,7 @@ impl NetworkShieldedCoordinator { recorded: &recorded, height: recorded_height, scan_covered_height: notes.covered_height, + heightless: heightless.as_ref(), }; self.release_stranded_spends(stale, evidence).await; self.discharge_expired_private_payments(&journal, evidence) @@ -1709,7 +1711,10 @@ impl NetworkShieldedCoordinator { /// 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`]). + /// pass ([`Self::discharge_expired_private_payments`]). When one of them + /// names its anchor without the anchor's height and the store bounds the + /// trees such anchors came from, the pool's proved leaf count is fetched + /// first, before the set ([`HeightlessAnchorEvidence`]). /// /// Skips the network round-trip when neither an anchored reservation nor /// a journal record exists (the common case), and treats a @@ -1723,7 +1728,7 @@ impl NetworkShieldedCoordinator { // 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 (stale, journal, tree_bounds) = { let store = self.store.read().await; let mut stale: Vec<(SubwalletId, StalePendingSpend)> = Vec::new(); for (id, _) in subwallets { @@ -1741,13 +1746,45 @@ impl NetworkShieldedCoordinator { &*store, subwallets.iter().map(|(id, _)| id.wallet_id), ); - (stale, journal) + let tree_bounds = heightless_tree_bounds(&*store, &stale, &journal); + (stale, journal, tree_bounds) }; if stale.is_empty() && journal.is_empty() { return None; } use dash_sdk::platform::fetch_current_no_parameters::FetchCurrent; + // The pool's leaf count, read before the anchor set so that it is + // proved at or below the set's height whenever both reads reach + // nodes at the same tip. A count proved above that height shows + // nothing this pass ([`HeightlessAnchorEvidence::recorded_by`]). + let heightless = match tree_bounds { + Some((tree_bound, anchor_floors)) => { + match dash_sdk::query_types::ShieldedNotesCount::fetch_current_with_metadata( + &self.sdk, + ) + .await + { + Ok((dash_sdk::query_types::ShieldedNotesCount(pool_leaves), metadata)) => { + Some(HeightlessAnchorEvidence { + tree_bound, + pool_leaves, + pool_height: metadata.height, + anchor_floors, + }) + } + Err(e) => { + tracing::warn!( + error = %e, + "shielded reservation release: failed to fetch the pool's leaf \ + count; records without an anchor height stay this pass" + ); + None + } + } + } + None => None, + }; match dash_sdk::query_types::ShieldedAnchors::fetch_current_with_metadata(&self.sdk).await { Ok((dash_sdk::query_types::ShieldedAnchors(anchors), metadata)) => { Some(PreScanEvidence { @@ -1755,6 +1792,7 @@ impl NetworkShieldedCoordinator { journal, recorded: anchors.into_iter().collect(), recorded_height: metadata.height, + heightless, }) } Err(e) => { @@ -1811,7 +1849,9 @@ impl NetworkShieldedCoordinator { /// 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. + /// released only on a later set that lacks it, or on a set that lacks it + /// with the pool proved, at or below that set's height, to have reached + /// the tree the anchor is a root of ([`HeightlessAnchorEvidence`]). async fn release_stranded_spends( &self, snapshot: Vec<(SubwalletId, StalePendingSpend)>, @@ -1948,8 +1988,10 @@ impl NetworkShieldedCoordinator { /// 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 + /// (`anchor_height`), or, for a record without it, recorded at or + /// below H as the pool's proved leaf count shows + /// ([`HeightlessAnchorEvidence`]), 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); @@ -2513,8 +2555,10 @@ fn is_own_orchard_recipient(views: &AccountViewingKeys, recipient: &[u8]) -> boo /// 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 +/// note scan, the height that scan covers (`scan_covered_height`, +/// `MultiSyncNotesResult::covered_height`), and, for records that carry no +/// anchor height, what bounds the trees their anchors came from +/// (`heightless`). The one rule the note release /// ([`NetworkShieldedCoordinator::release_stranded_spends`]) and the private /// payment expiry /// ([`NetworkShieldedCoordinator::discharge_expired_private_payments`]) judge @@ -2524,6 +2568,10 @@ pub(crate) struct PrunedAnchorEvidence<'a> { pub(crate) recorded: &'a HashSet<[u8; 32]>, pub(crate) height: u64, pub(crate) scan_covered_height: Option, + /// `None` when the pass gathered none: a record without an anchor height + /// is then never proven pruned, unless a set is seen holding its anchor + /// ([`ShieldedStore::note_anchor_recorded`]). + pub(crate) heightless: Option<&'a HeightlessAnchorEvidence>, } impl PrunedAnchorEvidence<'_> { @@ -2531,15 +2579,24 @@ impl PrunedAnchorEvidence<'_> { /// 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. + /// 1. the anchor was recorded at or below H: `anchor_height` is known and + /// at or below H, or, for one not known, the pool is proved to have + /// reached the tree the anchor is a root of at or below H + /// ([`HeightlessAnchorEvidence::recorded_by`]). Then 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 without that proof, + /// 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) + let recorded_by_the_set = match anchor_height { + Some(read) => read <= self.height, + None => self + .heightless + .is_some_and(|heightless| heightless.recorded_by(anchor, self.height)), + }; + recorded_by_the_set && !self.recorded.contains(anchor) && self .scan_covered_height @@ -2547,6 +2604,131 @@ impl PrunedAnchorEvidence<'_> { } } +/// What shows that an anchor a record names without its height was recorded +/// by a given height. +/// +/// Every anchor is the root of the local commitment tree at some size S, and +/// Platform records a root at the end of the first block its pool's tree ends +/// at that size (`record_shielded_pool_anchor_if_changed`), the size never +/// shrinking. So a pool proved to hold S leaves or more at a height has +/// recorded the anchor by then. S is not kept, but two bounds are: +/// +/// - S is at most [`Self::tree_bound`], the largest tree the store has held +/// ([`ShieldedStore::legacy_anchor_tree_bound`]): every record this build +/// saves keeps its anchor height, so one without was saved by an earlier +/// build, from a tree no larger than this store held when this build first +/// opened it, and every reset since kept the size it reset first. A reset +/// by an earlier build before that is not seen; the floors below still +/// bound such an anchor's tree. The tree grows only by proved note +/// batches, so the pool reaches that size. +/// - S is more than the position of every note spent on the anchor: a +/// witness to a note proves it in the tree the anchor is a root of +/// ([`Self::anchor_floors`]). +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct HeightlessAnchorEvidence { + /// The largest commitment tree the store has held. + pub(crate) tree_bound: u64, + /// The pool's leaf count, proved in this process at [`Self::pool_height`]. + pub(crate) pool_leaves: u64, + /// The height [`Self::pool_leaves`] was proved at. + pub(crate) pool_height: u64, + /// Per anchor of a record without an anchor height, one past the highest + /// tree position among the notes spent on it that the store holds. + pub(crate) anchor_floors: BTreeMap<[u8; 32], u64>, +} + +impl HeightlessAnchorEvidence { + /// Whether Platform recorded `anchor`, named without its height, at or + /// below `height`: the pool was proved at or below `height` to hold at + /// least [`Self::tree_bound`] leaves, and at least one more than every + /// note spent on it. + pub(crate) fn recorded_by(&self, anchor: &[u8; 32], height: u64) -> bool { + let floor = self.anchor_floors.get(anchor).copied().unwrap_or(0); + self.pool_height <= height && self.pool_leaves >= self.tree_bound.max(floor) + } +} + +/// The bounds `store` keeps on the trees of the anchors that `stale` and +/// `journal` name without their heights: the largest tree it has held +/// ([`HeightlessAnchorEvidence::tree_bound`]) and the floors of those anchors +/// ([`HeightlessAnchorEvidence::anchor_floors`]). `None` when no anchor is +/// named without its height, or the store keeps no such bound. +pub(crate) fn heightless_tree_bounds( + store: &S, + stale: &[(SubwalletId, StalePendingSpend)], + journal: &[(WalletId, PrivatePaymentRecord)], +) -> Option<(u64, BTreeMap<[u8; 32], u64>)> { + let heightless = stale + .iter() + .any(|(_, (_, _, _, anchor_height))| anchor_height.is_none()) + || journal + .iter() + .any(|(_, record)| record.anchor_height.is_none()); + if !heightless { + return None; + } + let tree_bound = store.legacy_anchor_tree_bound()?; + Some((tree_bound, heightless_anchor_floors(store, stale, journal))) +} + +/// Per anchor that a reservation in `stale` or a record in `journal` names +/// without its height, one past the highest commitment tree position among +/// the notes spent on it that `store` holds +/// ([`HeightlessAnchorEvidence::anchor_floors`]). A note the store does not +/// hold, or a subwallet whose notes do not read, raises no floor. +fn heightless_anchor_floors( + store: &S, + stale: &[(SubwalletId, StalePendingSpend)], + journal: &[(WalletId, PrivatePaymentRecord)], +) -> BTreeMap<[u8; 32], u64> { + let spends = stale + .iter() + .filter(|(_, (_, _, _, anchor_height))| anchor_height.is_none()) + .map(|(id, (nullifier, anchor, _, _))| (*id, *anchor, std::slice::from_ref(nullifier))) + .chain( + journal + .iter() + .filter(|(_, record)| record.anchor_height.is_none()) + .map(|(wallet_id, record)| { + ( + SubwalletId::new(*wallet_id, record.account_index), + record.anchor, + record.nullifiers.as_slice(), + ) + }), + ); + let mut positions: BTreeMap> = BTreeMap::new(); + let mut floors: BTreeMap<[u8; 32], u64> = BTreeMap::new(); + for (id, anchor, nullifiers) in spends { + let notes = positions + .entry(id) + .or_insert_with(|| match store.get_all_notes(id) { + Ok(notes) => notes + .into_iter() + .map(|note| (note.nullifier, note.position)) + .collect(), + Err(error) => { + tracing::warn!( + wallet_id = %hex::encode(id.wallet_id), + account = id.account_index, + %error, + "shielded reservation release: the notes do not read; they raise no tree \ + floor for anchors without a height" + ); + BTreeMap::new() + } + }); + for position in nullifiers + .iter() + .filter_map(|nullifier| notes.get(nullifier)) + { + let floor = floors.entry(anchor).or_insert(0); + *floor = (*floor).max(position.saturating_add(1)); + } + } + floors +} + /// 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`]). @@ -2562,6 +2744,10 @@ struct PreScanEvidence { recorded: HashSet<[u8; 32]>, /// The height of the proof the anchor set was read from. recorded_height: u64, + /// What bounds the trees of the anchors `stale` and `journal` name + /// without their heights, read before the anchor set; `None` when none + /// does, or none was needed. + heightless: Option, } /// The private payment journal records of `wallet_ids`, each wallet once, in @@ -2607,7 +2793,9 @@ mod tests { use crate::changeset::ShieldedChangeSet; use crate::wallet::shielded::activity::compute_activity_id; - use crate::wallet::shielded::store::{PendingRedrive, ANCHOR_READ_BEFORE_THIS_PROCESS}; + use crate::wallet::shielded::store::{ + InMemoryShieldedStore, PendingRedrive, PrivateContactPayment, ShieldedNote, + }; use dpp::platform_value::BinaryData; use dpp::serialization::PlatformSerializable; use dpp::shielded::SerializedAction; @@ -4145,6 +4333,7 @@ mod tests { recorded: &recorded, height: 10, scan_covered_height: Some(10), + heightless: None, }, ) .await; @@ -4408,6 +4597,7 @@ mod tests { recorded: &recorded, height, scan_covered_height: Some(height), + heightless: None, }, ) .await; @@ -4442,11 +4632,17 @@ mod tests { } /// A note spend an earlier process saved before anchor heights were kept, - /// its anchor since pruned: reopened, its anchor counts as read before any - /// set this process fetches, so the first set lacking it releases the - /// reservation once the scan covers that set, and not before. + /// from a tree of `TREE` leaves. Reopened, its height stays unknown, and a + /// set lacking its anchor shows it pruned only with the pool proved, at or + /// below the set's height, to hold `TREE` leaves or more, and the scan + /// covering the set. Evidence a block behind the anchor, with every proof + /// at H - 1 and the pool short of its tree, releases nothing: the anchor + /// may postdate that set. #[tokio::test] - async fn should_release_a_reopened_heightless_reservation_once_the_scan_covers_the_set() { + async fn should_release_a_reopened_heightless_reservation_only_once_the_pool_reached_its_tree() + { + const TREE: u64 = 4; + const H: u64 = 10; let dir = temp_dir("release_reopened_heightless"); std::fs::create_dir_all(&dir).expect("create temp dir"); let db_path = dir.join("tree.sqlite"); @@ -4455,6 +4651,11 @@ mod tests { { // An earlier process, before heights were kept. let mut store = FileBackedShieldedStore::open_path(&db_path, 100).expect("open"); + for leaf in 0..TREE { + store + .append_commitment(&[leaf as u8 + 1; 32], true) + .expect("appended"); + } store.mark_pending(id, &nullifier).expect("reserved"); store .arm_redrive( @@ -4488,37 +4689,130 @@ mod tests { }; assert_eq!( stale().await, - vec![( - nullifier, - anchor, - Some(activity), - Some(ANCHOR_READ_BEFORE_THIS_PROCESS) - )], - "read before any set this process fetches" + vec![(nullifier, anchor, Some(activity), None)], + "its height stays unknown" ); let recorded = HashSet::new(); - for (covered, released) in [(9, false), (10, true)] { + // (set height, scan coverage, pool leaves and their height, released) + let cases = [ + ( + H - 1, + H - 1, + Some((TREE - 1, H - 1)), + false, + "a block behind", + ), + (H, H, None, false, "no pool count"), + ( + H, + H, + Some((TREE - 1, H)), + false, + "the pool short of the tree", + ), + ( + H, + H, + Some((TREE, H + 1)), + false, + "the pool proved above the set", + ), + (H, H - 1, Some((TREE, H)), false, "the scan below the set"), + (H, H, Some((TREE, H - 1)), true, "proven"), + ]; + for (set_height, covered, pool, released, case) in cases { let snapshot: Vec<(SubwalletId, StalePendingSpend)> = stale().await.into_iter().map(|entry| (id, entry)).collect(); + let heightless = { + let store = coordinator.store.read().await; + let (tree_bound, anchor_floors) = + heightless_tree_bounds(&*store, &snapshot, &[]).expect("bounded"); + assert_eq!(tree_bound, TREE, "the tree the earlier process held"); + pool.map(|(pool_leaves, pool_height)| HeightlessAnchorEvidence { + tree_bound, + pool_leaves, + pool_height, + anchor_floors, + }) + }; coordinator .release_stranded_spends( snapshot, PrunedAnchorEvidence { recorded: &recorded, - height: 10, + height: set_height, scan_covered_height: Some(covered), + heightless: heightless.as_ref(), }, ) .await; - assert_eq!( - stale().await.is_empty(), - released, - "scan covering {covered}" - ); + assert_eq!(stale().await.is_empty(), released, "{case}"); } let _ = std::fs::remove_dir_all(&dir); } + /// The floor of an anchor named without its height is one past the + /// highest position of the notes spent on it that the store holds, over + /// every reservation and journal record naming it, and a pool count + /// shows the anchor recorded only once it reaches both that floor and the + /// tree bound. A note the store does not hold raises no floor. + #[test] + fn should_bound_a_heightless_anchor_s_tree_by_the_notes_spent_on_it() { + let (anchor, other, dated) = ([0x51; 32], [0x52; 32], [0x53; 32]); + let wallet_id = [0x11; 32]; + let id = SubwalletId::new(wallet_id, 0); + let note = |position: u64, nullifier: u8| ShieldedNote { + position, + cmx: [nullifier; 32], + nullifier: [nullifier; 32], + block_height: 1, + is_spent: false, + value: 1, + note_data: vec![], + }; + let mut store = InMemoryShieldedStore::new(); + for (position, nullifier) in [(3, 0x61), (8, 0x62), (20, 0x63)] { + store + .save_note(id, ¬e(position, nullifier)) + .expect("saved"); + } + let stale = vec![ + (id, ([0x61; 32], anchor, None, None)), + (id, ([0x64; 32], other, None, None)), + // A dated reservation raises no floor. + (id, ([0x63; 32], dated, None, Some(5))), + ]; + let journal = vec![( + wallet_id, + PrivatePaymentRecord { + account_index: 0, + activity_id: [0x71; 32], + anchor, + anchor_height: None, + commitment: [0x72; 32], + payment: PrivateContactPayment { + sender: [1; 32], + recipient: [2; 32], + account_reference: 0, + }, + nullifiers: vec![[0x62; 32], [0x65; 32]], + }, + )]; + let floors = heightless_anchor_floors(&store, &stale, &journal); + assert_eq!(floors, BTreeMap::from([(anchor, 9)])); + let evidence = |pool_leaves| HeightlessAnchorEvidence { + tree_bound: 6, + pool_leaves, + pool_height: 10, + anchor_floors: floors.clone(), + }; + assert!(!evidence(8).recorded_by(&anchor, 10), "below its floor"); + assert!(evidence(9).recorded_by(&anchor, 10), "at its floor"); + assert!(!evidence(5).recorded_by(&other, 10), "below the bound"); + assert!(evidence(6).recorded_by(&other, 10), "at the bound"); + assert!(!evidence(9).recorded_by(&anchor, 9), "proved above the set"); + } + /// 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 17193227a18..f75d5e6b467 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/file_store.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/file_store.rs @@ -25,7 +25,6 @@ use super::balance::{ShieldedBalanceSource, ShieldedLocalAccountBalance}; use super::store::{ PendingRedrive, PrivateContactPayment, PrivatePaymentRecord, ShieldedNote, ShieldedOutgoingNote, ShieldedStore, StalePendingSpend, SubwalletId, SubwalletState, - ANCHOR_READ_BEFORE_THIS_PROCESS, }; use crate::error::PlatformWalletError; use crate::wallet::platform_wallet::WalletId; @@ -334,12 +333,7 @@ impl FileBackedShieldedStore { (record.commitment == commitment).then_some((wallet_id, record)) }); match read { - Some((wallet_id, mut record)) => { - // Saved by an earlier process with no height: its anchor - // was read before any set this process fetches. - record - .anchor_height - .get_or_insert(ANCHOR_READ_BEFORE_THIS_PROCESS); + Some((wallet_id, record)) => { self.private_payment_journal .insert((wallet_id, record.commitment), record); } @@ -727,14 +721,6 @@ impl FileBackedShieldedStore { return Err(corrupted("invalid nullifier width")); } let nullifiers: Vec<[u8; 32]> = nullifiers.as_chunks::<32>().0.to_vec(); - // A note spend saved by an earlier process with no height, - // before heights were kept: its anchor was read before any set - // this process fetches, which its reservations are released - // by. An identity debit reserves no note and keeps its own. - let anchor_height = match anchor_height { - None if !nullifiers.is_empty() => Some(ANCHOR_READ_BEFORE_THIS_PROCESS), - height => height, - }; Ok(( SubwalletId::new(wallet_id, account_index), PendingRedrive { diff --git a/packages/rs-platform-wallet/src/wallet/shielded/store.rs b/packages/rs-platform-wallet/src/wallet/shielded/store.rs index 8c00264bcbe..055d01ce9b5 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/store.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/store.rs @@ -126,15 +126,6 @@ pub struct ShieldedOutgoingNote { pub block_height: u64, } -/// The anchor height a store gives a reservation, saved record or journal -/// record it loads at open with none kept: one saved by an earlier process, -/// before heights were kept, whose anchor was necessarily read before any -/// anchor set this process fetches. So a set this process fetches that lacks -/// the anchor, the scan covering that set, shows it pruned -/// (`PrunedAnchorEvidence::proves_pruned`). A record created in this process -/// always has its height; one without stays unproven. -pub(crate) const ANCHOR_READ_BEFORE_THIS_PROCESS: u64 = 0; - /// A pending reservation that carries a recorded anchor: /// `(nullifier, anchor, activity_id, anchor_height)`, `anchor_height` being a /// proved height at which `anchor` was in Platform's recorded set, when known. @@ -176,11 +167,11 @@ pub struct PendingRedrive { /// 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. A store - /// loading a record an earlier process saved without it gives it - /// [`ANCHOR_READ_BEFORE_THIS_PROCESS`]. `None` for one created in this - /// process without it, whose anchor is not proven pruned until a set is - /// seen holding it. + /// that lacks the anchor shows it pruned rather than newer. `None` for a + /// record saved without it, by a build before heights were kept: its + /// anchor is proven pruned only once a set is seen holding it, or by a + /// proved pool count reaching the store's bound on the trees its anchors + /// were taken from ([`ShieldedStore::legacy_anchor_tree_bound`]). 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 @@ -254,11 +245,11 @@ pub struct PrivatePaymentRecord { /// 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. `None` when not known, as for a - /// payment saved before the journal or the redrive record kept it; a - /// store loading such a record an earlier process saved gives it - /// [`ANCHOR_READ_BEFORE_THIS_PROCESS`], and one created in this process - /// without it is never proven expired. Kept as `u64::MAX` in the record's - /// bytes ([`Self::to_bytes`]). + /// payment saved before the journal or the redrive record kept it, whose + /// expiry is then proven only by a proved pool count reaching the store's + /// bound on the trees its anchors were taken from + /// ([`ShieldedStore::legacy_anchor_tree_bound`]). Kept as `u64::MAX` in + /// the record's bytes ([`Self::to_bytes`]). pub anchor_height: Option, /// The commitment of the entry it appends, which keys the record. pub commitment: [u8; 32], From 5d2084f91cddb61bfd869a3223556f144f769ed0 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Tue, 10 Nov 2026 04:24:30 +0700 Subject: [PATCH 214/269] docs(platform-wallet): describe how heightless anchor records are released Records saved without an anchor height keep it unknown, and custom stores can implement `legacy_anchor_tree_bound` to let a proved pool count release them. Co-Authored-By: Claude Opus 5.5 --- packages/rs-platform-wallet/README.md | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/packages/rs-platform-wallet/README.md b/packages/rs-platform-wallet/README.md index e9063515516..9355696c17e 100644 --- a/packages/rs-platform-wallet/README.md +++ b/packages/rs-platform-wallet/README.md @@ -171,13 +171,19 @@ element, `PendingRedrive` struct literals require `anchor_height`, and stores must implement `note_anchor_recorded`, which gives that height to the reservations and records of an anchor that carry none, durably for a record. A store keeps a record's height with it, so a restart restores it. A note -spend's record, or a journal record, that an earlier process saved without a -height (one saved before heights were kept) is loaded with height 0: its -anchor was necessarily read before any anchor set this process fetches, so it -is released, or discharged, on the first set lacking its anchor once the scan -covers that set (and, for a journal record, the store read through its end -lacks its entry at that set's height). One created in this process without a -height is never released on an anchor's absence alone. +spend's record, or a journal record, saved without a height (one saved before +heights were kept) keeps its height unknown. Its anchor is a root of a tree no +larger than the largest the store has held, so a store can implement +`legacy_anchor_tree_bound` to return that size; the default `None` keeps such +records held until a set is seen holding their anchor. `FileBackedShieldedStore` +keeps the size durably, raised at every open and before every +`reset_commitment_tree`, and reports the size held when it was opened. With a +bound, the sync fetches the pool's proved leaf count before the anchor set, +and such a record is released, or discharged, on a set lacking its anchor only +when that count, proved at or below the set's height, reaches the bound and +one past the position of every note spent on that anchor that the store +holds, the scan covers the set, and, for a journal record, the store read +through its end lacks its entry at that set's height. Otherwise it stays held. The local shielded balance API introduces three Rust source compatibility changes: From e0f384fa48a1488f358c685645bc29fcc2cd6376 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Tue, 10 Nov 2026 05:34:37 +0700 Subject: [PATCH 215/269] fix(platform-wallet): store the private payment marker whenever it differs from the one last stored A read-back settles an identity's own reservation while the journal still keeps the payment, so its position record is stored marked. When the coordinator then discharged the journal record, the release stored the record again only if the identity's own flag changed, which it no longer did, so the record stayed marked. A reload then held the identity, and a build without the shielded feature never released it. The wallet persister now keeps the marker each identity's record was last stored with in this session. The release of discharged records, and the private sync, store a record whenever its marker, the identity's part and the journal's together, differs from that one. A store or flush that fails forgets it, so the next comparison stores again. Co-Authored-By: Claude Opus 5.5 --- .../network/private_contact_requests.rs | 317 +++++++++++++++++- .../src/wallet/persister.rs | 33 ++ .../src/wallet/shielded/coordinator.rs | 2 +- 3 files changed, 340 insertions(+), 12 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 4b6ea75cd7c..12060a494fc 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 @@ -604,6 +604,8 @@ fn store_private_payment_marker( ); if !persister.store_commits_inline() { if let Err(error) = persister.flush() { + // Not known to be stored: the next comparison stores it again. + persister.note_private_marker_stored(*owner, None); tracing::warn!( owner = %owner, %error, @@ -667,14 +669,43 @@ fn store_private_position( changeset .private_contact_requests_positions .insert(owner, position); - if let Err(error) = persister.store(changeset.into()) { - tracing::warn!( - owner = %owner, position = position.position, %error, - "private read position not stored; the next launch reads from the last one stored" - ); + match persister.store(changeset.into()) { + Ok(()) => { + persister.note_private_marker_stored(owner, Some(position.private_payments_unresolved)) + } + Err(error) => { + persister.note_private_marker_stored(owner, None); + tracing::warn!( + owner = %owner, position = position.position, %error, + "private read position not stored; the next launch reads from the last one stored" + ); + } } } +/// Whether `managed`'s position record, stored now, would carry another +/// marker than the one `owner`'s record was last stored with in this session +/// (`WalletPersister::private_marker_stored`), for a backend that keeps it: +/// its identity's part and the journal's (`with_journaled_marker`) together, +/// so a journal record discharged after the identity's own reservation +/// settled clears it too. None stored this session counts as another. +fn private_marker_stale( + persister: &WalletPersister, + owner: Identifier, + managed: &ManagedIdentity, +) -> bool { + persister.persists_private_contact_scan_state() + && persister.private_marker_stored(&owner) + != Some( + with_journaled_marker( + persister, + owner, + managed.private_contact_requests_position_record(), + ) + .private_payments_unresolved, + ) +} + /// 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 @@ -1131,10 +1162,14 @@ impl DashPayView<'_, B> { is not paid for: {error}" )) }; + // Not known to be stored until both succeed. + self.persister.note_private_marker_stored(*owner, None); self.persister.store(changeset.into()).map_err(not_kept)?; if !self.persister.store_commits_inline() { self.persister.flush().map_err(not_kept)?; } + self.persister + .note_private_marker_stored(*owner, Some(marker.private_payments_unresolved)); Ok(()) } @@ -1198,7 +1233,11 @@ impl DashPayView<'_, B> { /// 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. + /// no restart. Each identity's marker, its own part and the journal's, is + /// stored again when it differs from the one last stored + /// (`private_marker_stale`): a discharge clears the journal's part also + /// for an identity whose own reservation a read-back already settled, so + /// a reload does not hold it on a marker no payment backs. pub(crate) async fn release_discharged_private_requests( &self, ) -> Result<(), PlatformWalletError> { @@ -1219,9 +1258,8 @@ impl DashPayView<'_, B> { 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 { + if private_marker_stale(&self.persister, identity_id, managed) { changed.push(identity_id); } } @@ -1992,12 +2030,14 @@ 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(); - // Stored once it moved, or once the last private - // payment it held settled. + // Stored once it moved, once the last private + // payment it held settled, or once its marker + // differs from the one last stored. if persists_scan_state && (after != before || managed.dashpay().private_payments_unresolved() - != unresolved) + != unresolved + || private_marker_stale(&self.persister, identity_id, managed)) { store_private_position( &self.persister, @@ -2547,6 +2587,9 @@ mod tests { struct RecordingPersistence { capabilities: PersistenceCapabilities, stored: Mutex>, + /// While set, a store carrying a private read position record fails + /// and keeps nothing. + positions_fail: AtomicBool, } impl RecordingPersistence { @@ -2554,6 +2597,7 @@ mod tests { Arc::new(Self { capabilities, stored: Mutex::new(Vec::new()), + positions_fail: AtomicBool::new(false), }) } @@ -2574,6 +2618,14 @@ mod tests { _wallet_id: WalletId, changeset: PlatformWalletChangeSet, ) -> Result<(), PersistenceError> { + if self.positions_fail.load(Ordering::SeqCst) + && changeset + .contacts + .as_ref() + .is_some_and(|contacts| !contacts.private_contact_requests_positions.is_empty()) + { + return Err(PersistenceError::backend("the disk is full")); + } self.stored.lock().expect("stored").push(changeset); Ok(()) } @@ -3065,6 +3117,121 @@ mod tests { } } + /// A private sync stores an identity's position record whenever its + /// marker differs from the one last stored in this session, though + /// nothing moved: one whose store failed is stored by the next sync, and + /// not again while it is unchanged. + #[tokio::test] + async fn should_store_a_marker_that_differs_from_the_one_last_stored() { + let alice = identity(ALICE_PHRASE, ALICE); + let bob = identity(BOB_PHRASE, BOB); + let alice_crypto = SeedCryptoProvider::from_seed(seed(ALICE_PHRASE), Network::Testnet); + 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 records = || { + persistence + .stored + .lock() + .expect("stored") + .iter() + .filter_map(|changeset| changeset.contacts.as_ref()) + .filter(|contacts| { + contacts + .private_contact_requests_positions + .contains_key(&alice.id()) + }) + .count() + }; + let view = alice_wallet.identity().dashpay(); + let before = records(); + persistence.positions_fail.store(true, Ordering::SeqCst); + view.sync_private_contact_requests(&alice_crypto, 4) + .await + .expect("a private sync whose position stores fail"); + assert_eq!(records(), before, "nothing kept"); + persistence.positions_fail.store(false, Ordering::SeqCst); + + view.sync_private_contact_requests(&alice_crypto, 4) + .await + .expect("the next private sync"); + assert_eq!(records(), before + 1, "stored, though nothing moved"); + assert_eq!( + last_marker(&persistence.stored.lock().expect("stored"), &alice.id()), + Some(false) + ); + view.sync_private_contact_requests(&alice_crypto, 4) + .await + .expect("another private sync"); + assert_eq!(records(), before + 1, "unchanged: not stored again"); + } + + /// An identity reloaded from a position record stored with its marker + /// cleared, as a discharge leaves it, is not held, in any build: its + /// public request goes on to be built, with no restoration needed. + #[tokio::test] + async fn should_not_hold_an_identity_reloaded_with_its_marker_cleared() { + 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 (_before_manager, before) = wallet(ALICE_PHRASE, &alice, &[&alice, &bob], vec![]).await; + let kept = Arc::new(scan_state_snapshot(&before, &alice.id(), false).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); + assert!( + restarted + .identity() + .dashpay() + .private_contact_readiness(&alice.id()) + .await + .expect("the readiness") + .private_payments_restored + ); + // Past the hold, the store read and the claim, the request is built + // (the mock answers no nonce, which ends it there). + 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 + | PlatformWalletError::PrivateContactRequestPending(_)) + ), + "{public:?}" + ); + } + /// 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 @@ -8029,6 +8196,134 @@ mod tests { assert_eq!(fixture.journaled().await, 1); } + /// A private payment read back, its journal record then discharged by the + /// coordinator as every sync pass does: the read-back settles the + /// identity's own reservation and stores its marker set, as the journal + /// still keeps the payment, and the next public request, releasing what + /// the discharge freed, stores it cleared although the identity's own part + /// was already clear. Reloaded from that record, as any build loads it, + /// the identity holds no public request back. + #[cfg(feature = "shielded")] + #[tokio::test] + async fn should_store_the_marker_cleared_once_a_read_back_payment_is_discharged() { + 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 fixture = ExpiryFixture::new().await; + let marker = || { + last_marker( + &fixture.persistence.stored.lock().expect("stored"), + &alice.id(), + ) + }; + let entry = fixture + .pay_unconfirmed(ExpiryFixture::ANCHOR, ExpiryFixture::ANCHOR_SET_HEIGHT) + .await; + assert_eq!(marker(), Some(true), "marked before paying"); + // The wallet holds the pair from the journal too, as a payment's + // submission or a restoration notes it. + fixture + .wallet + .restore_private_contact_payments(&fixture.coordinator) + .await + .expect("restored"); + let persister = fixture.wallet.persister(); + assert!(persister.private_pair_journaled(&alice.id(), &bob.id())); + + // Read back: the identity's own reservation settles, and the journal + // still keeps the payment, so the marker stays set. + publish(&fixture.wallet, 0, vec![entry.to_vec()]).await; + let view = fixture.wallet.identity().dashpay(); + view.sync_private_contact_requests(&alice_crypto, 4) + .await + .expect("the entry is read back"); + assert!(!pending_to(&fixture.wallet, ALICE, &bob).await, "settled"); + assert!(persister.private_pair_journaled(&alice.id(), &bob.id())); + assert_eq!(marker(), Some(true), "the journal still keeps it"); + + // The coordinator's ordinary discharge of the records read back. + fixture + .coordinator + .discharge_read_back_private_payments() + .await; + assert_eq!(fixture.journaled().await, 0, "discharged"); + assert!(!persister.private_pair_journaled(&alice.id(), &bob.id())); + + // The next public request releases what the discharge freed and + // stores the marker cleared, before it is decided on the pair. + 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( + _ + )) + ), + "{public:?}" + ); + assert_eq!(marker(), Some(false), "stored cleared"); + + // Reloaded from the record last stored, the identity is not held. + let kept = Arc::new( + scan_state_snapshot(&fixture.wallet, &alice.id(), marker().expect("stored")).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(&fixture.wallet.wallet_id()) + .await + .expect("the relaunched wallet"); + assert!(!restarted.private_payments_held().await); + assert!( + restarted + .identity() + .dashpay() + .private_contact_readiness(&alice.id()) + .await + .expect("the readiness") + .private_payments_restored + ); + // Past the hold, the send is decided on the pair itself: its channel + // is private, as the request read back shows. + 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::PrivateContactRequestNeedsPrivateReply(recipient)) + if recipient == bob.id() + ), + "{public:?}" + ); + } + /// A private payment that never executes is proven expired once its /// anchor is pruned and its entry is absent from the store: the /// coordinator discharges its journal record, the pair is freed, a retry diff --git a/packages/rs-platform-wallet/src/wallet/persister.rs b/packages/rs-platform-wallet/src/wallet/persister.rs index 7ce95283f8c..a830e9b12fb 100644 --- a/packages/rs-platform-wallet/src/wallet/persister.rs +++ b/packages/rs-platform-wallet/src/wallet/persister.rs @@ -82,6 +82,13 @@ pub struct WalletPersister { /// 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 marker each identity's position record was last stored with in + /// this session (`PrivateContactRequestsPosition::private_payments_unresolved`): + /// a record whose marker, its identity's and the journal's together, + /// differs from it is stored again. Absent when none was stored, or the + /// last store or its flush failed. Shared by the clones the wallet and + /// the coordinator hold. In memory only. + stored_private_markers: Arc>>, } /// The commitments [`WalletPersister::note_discharged_private_entries`] noted, @@ -118,9 +125,35 @@ impl WalletPersister { read_back_private_entries: Arc::default(), discharged_private_entries: Arc::default(), private_payments_unrestored: Arc::default(), + stored_private_markers: Arc::default(), } } + /// Note that `owner`'s position record was stored with the marker + /// `marker`, or, with `None`, that whether it was is not known: its last + /// store or flush failed ([`Self::private_marker_stored`]). + pub(crate) fn note_private_marker_stored(&self, owner: Identifier, marker: Option) { + let mut stored = self + .stored_private_markers + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + match marker { + Some(marker) => stored.insert(owner, marker), + None => stored.remove(&owner), + }; + } + + /// The marker `owner`'s position record was last stored with in this + /// session, if one was and its store did not fail since + /// ([`Self::note_private_marker_stored`]). + pub(crate) fn private_marker_stored(&self, owner: &Identifier) -> Option { + self.stored_private_markers + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .get(owner) + .copied() + } + /// 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`]). diff --git a/packages/rs-platform-wallet/src/wallet/shielded/coordinator.rs b/packages/rs-platform-wallet/src/wallet/shielded/coordinator.rs index 8e154171d2b..ea091e72654 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/coordinator.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/coordinator.rs @@ -1314,7 +1314,7 @@ impl NetworkShieldedCoordinator { /// 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) { + pub(crate) async fn discharge_read_back_private_payments(&self) { let noted: Vec<(WalletId, WalletPersister)> = self .persisters .read() From c4e2f3a03579fc1fad5463e09edc19c72bbd44e1 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Tue, 10 Nov 2026 05:35:16 +0700 Subject: [PATCH 216/269] fix(platform-wallet): refresh the sender's keys from a proved fetch before a public request A public request or acceptance read DashPay's private contact request store with the keys the cached identity held. A DashPay encryption or decryption key another device added was missing from them, so a private request sealed to it read as absent, and the public request published the pair. Before deciding, the preflight now fetches the sender identity with a proof and folds it into the cache, through the fold refresh_identity already used, now shared and fenced to the handle's wallet generation. A key added since changes the scanning keys, which rewinds the read, and the store is read again with it. A fetch that fails refuses the request as the SDK reported it (PlatformWalletError::Sdk, a stale node included), with nothing read or sent. refresh_identity keeps its own error mapping. Cost: one proved identity fetch per public request or acceptance. Co-Authored-By: Claude Opus 5.5 --- .../src/wallet/identity/network/loading.rs | 114 +++++++++------- .../network/private_contact_requests.rs | 125 ++++++++++++++++++ 2 files changed, 188 insertions(+), 51 deletions(-) diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/loading.rs b/packages/rs-platform-wallet/src/wallet/identity/network/loading.rs index 19e591bbe2e..176bae1210e 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/loading.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/loading.rs @@ -6,8 +6,11 @@ use dpp::identity::Identity; use dpp::prelude::Identifier; use key_wallet::bip32::ExtendedPrivKey; +use crate::broadcaster::TransactionBroadcaster; use crate::error::PlatformWalletError; +use crate::wallet::identity::state::managed_identity::key_storage::IdentityStatus; use crate::BlockTime; +use dash_sdk::platform::Fetch; use super::*; @@ -345,60 +348,15 @@ impl IdentityWallet { &self, identity_id: &Identifier, ) -> Result { - use crate::wallet::identity::state::managed_identity::key_storage::IdentityStatus; - use dash_sdk::platform::Fetch; - - // Verify identity exists in the manager. - { - let wm = self.wallet_manager.read().await; - let info = wm.get_wallet_info(&self.wallet_id).ok_or_else(|| { - crate::error::PlatformWalletError::WalletNotFound( - "Wallet info not found in wallet manager".to_string(), - ) - })?; - if info.identity_manager.identity(identity_id).is_none() { - return Err(PlatformWalletError::IdentityNotFound(*identity_id)); - } - } - - // Fetch the latest state from Platform. - let (identity, metadata) = Identity::fetch_with_metadata(&self.sdk, *identity_id, None) + self.fetch_and_fold_identity(identity_id) .await - .map_err(|e| { - PlatformWalletError::InvalidIdentityData(format!( + .map_err(|error| match error { + PlatformWalletError::Sdk(e) => PlatformWalletError::InvalidIdentityData(format!( "Failed to fetch identity {} from Platform: {}", identity_id, e - )) - })?; - let mut identity = identity.ok_or_else(|| { - PlatformWalletError::InvalidIdentityData(format!( - "Identity {} not found on Platform", - identity_id - )) - })?; - let block_time = BlockTime::from(metadata); - - // Update the managed identity. - { - let mut wm = self.wallet_manager.write().await; - let info = wm.get_wallet_info_mut(&self.wallet_id).ok_or_else(|| { - crate::error::PlatformWalletError::WalletNotFound( - "Wallet info not found in wallet manager".to_string(), - ) - })?; - if let Some(managed) = info.identity_manager.managed_identity_mut(identity_id) { - managed.status = IdentityStatus::Active; - if let Err(e) = - managed.store_fetched_identity(&identity, block_time, &self.persister) - { - tracing::error!("Failed to persist changeset: {}", e); - } - // The cache as folded. - identity = managed.identity.clone(); - } - } - - Ok(identity) + )), + other => other, + }) } /// Refresh an identity using an externally-provided identity ID. @@ -531,6 +489,60 @@ impl IdentityWallet { } } +impl IdentityWallet { + /// [`IdentityWallet::refresh_identity`]'s fetch and fold, generic over the + /// broadcaster, with a fetch that fails coming back as the SDK reported it + /// ([`PlatformWalletError::Sdk`]): the fetch is proved, a stale node + /// included (`dash_sdk::Error::StaleNode`). Applied only while the wallet + /// is still this handle's generation (`current_wallet_info_mut`). + pub(crate) async fn fetch_and_fold_identity( + &self, + identity_id: &Identifier, + ) -> Result { + // Verify identity exists in the manager. + { + let wm = self.wallet_manager.read().await; + let info = wm.get_wallet_info(&self.wallet_id).ok_or_else(|| { + PlatformWalletError::WalletNotFound( + "Wallet info not found in wallet manager".to_string(), + ) + })?; + if info.identity_manager.identity(identity_id).is_none() { + return Err(PlatformWalletError::IdentityNotFound(*identity_id)); + } + } + + // Fetch the latest state from Platform. + let (identity, metadata) = + Identity::fetch_with_metadata(&self.sdk, *identity_id, None).await?; + let mut identity = identity.ok_or_else(|| { + PlatformWalletError::InvalidIdentityData(format!( + "Identity {} not found on Platform", + identity_id + )) + })?; + let block_time = BlockTime::from(metadata); + + // Update the managed identity. + { + let mut wm = self.wallet_manager.write().await; + let info = self.current_wallet_info_mut(&mut wm)?; + if let Some(managed) = info.identity_manager.managed_identity_mut(identity_id) { + managed.status = IdentityStatus::Active; + if let Err(e) = + managed.store_fetched_identity(&identity, block_time, &self.persister) + { + tracing::error!("Failed to persist changeset: {}", e); + } + // The cache as folded. + identity = managed.identity.clone(); + } + } + + Ok(identity) + } +} + #[cfg(test)] mod tests { use super::super::identity_handle::{ 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 12060a494fc..b2ec16d8f1a 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 @@ -1069,6 +1069,17 @@ impl DashPayView<'_, B> { /// 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`). + /// + /// Then `identity_id` is fetched from Platform, proved, and its keys + /// folded into the cached identity (`IdentityWallet::refresh_identity`'s + /// fold): a DashPay encryption or decryption key another device added, + /// to which a request in the store may be sealed, changes the identity's + /// scanning keys, which rewinds its read + /// (`Self::reconcile_private_scan_keys`), so the store is read again + /// with it before anything is decided. A fetch that fails refuses the + /// request as the SDK reported it, with nothing read or sent: the keys a + /// read would scan with are not shown current. One proved identity fetch + /// per public request or acceptance. pub(super) async fn read_private_store_before_deciding( &self, identity_id: &Identifier, @@ -1083,6 +1094,7 @@ impl DashPayView<'_, B> { { return Err(PlatformWalletError::PrivateContactPaymentsNotRestored); } + self.fetch_and_fold_identity(identity_id).await?; match self .read_private_store_before_sending(identity_id, recipient, crypto) .await @@ -6454,6 +6466,119 @@ mod tests { .await; } + /// A public request is decided with the keys Platform proves the sender + /// identity holds now: Bob read the store to its end with keys missing the + /// decryption key another device added, to which Alice's private request + /// is sealed. His public request to her fetches his identity, folds the + /// key in, reads the store again with it, finds her private request, and + /// is refused: a public one would publish the pair she kept private. + #[tokio::test] + async fn should_read_the_store_with_a_key_another_device_added_before_a_public_request() { + 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 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![], "nothing opens"); + assert!(store_read(&bob_wallet, BOB).await, "read to its end"); + + 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(sender)) + if sender == alice.id() + ), + "{public:?}" + ); + managed(&bob_wallet, BOB, |managed| { + assert!( + managed + .identity + .public_keys() + .contains_key(&DECRYPTION_KEY_ID), + "the key is folded in" + ); + assert!( + managed.dashpay().incoming_request(&alice.id()).is_some(), + "her private request is recorded" + ); + }) + .await; + } + + /// A public request, or a public acceptance, whose sender identity cannot + /// be fetched is refused as the SDK reported the fetch, before the store is + /// read again or anything is signed: the keys a read would scan with are + /// not shown current. + #[tokio::test] + async fn should_refuse_a_public_request_when_its_sender_identity_does_not_fetch() { + 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 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(); + view.sync_private_contact_requests(&bob_crypto, 4) + .await + .expect("Bob reads the store with his older keys"); + set_identity_known(&bob_wallet, &bob, false).await; + + let public = view + .send_contact_request_with_external_signer( + &bob.id(), + &alice.id(), + None, + AutoAcceptProofSource::None, + &bob_signer, + &bob_crypto, + ) + .await; + // Refused by the identity fetch itself, not a step further on: the + // mock names the request it has no answer for. + let fetch_failed = |error: Option<&PlatformWalletError>| { + matches!(error, Some(PlatformWalletError::Sdk(error)) + if format!("{error:?}").contains("GetIdentityRequest")) + }; + assert!(fetch_failed(public.as_ref().err()), "{public:?}"); + let incoming = public_request(&alice, &bob, 3); + add_incoming(&bob_wallet, BOB, &incoming).await; + let accepted = view + .accept_contact_request_with_external_signer(&incoming, &bob_signer, &bob_crypto) + .await; + assert!(fetch_failed(accepted.as_ref().err()), "{accepted:?}"); + managed(&bob_wallet, BOB, |managed| { + assert!(!managed + .identity + .public_keys() + .contains_key(&DECRYPTION_KEY_ID)); + assert!( + managed.dashpay().outgoing_request(&alice.id()).is_none(), + "nothing sent" + ); + assert!(!managed.dashpay().private_reply_pending(&alice.id())); + }) + .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 From cecf62fbbb8bb2a571e5f1010d20ef2f345d4c75 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Tue, 10 Nov 2026 06:13:44 +0700 Subject: [PATCH 217/269] fix(platform-wallet): decide a public request only with keys proved at or above the store read The preflight's identity fetch discarded its proof height. A node a block behind could answer the identity at H - 1, without a key another device added at H, while the store read covered a page at H holding a request sealed to that key: the request never opened, the read stood, and the public request went ahead. A private sync now reads the store's pages with their proof heights and keeps, per identity, the highest height of a page it read for it. The preflight reads the store first, then fetches the identity with its proof height, folds and reconciles its keys, and decides only when the store was read with the keys the fetch shows and every page it read was proved at or below the fetch: a key added at a height is in no request of a page proved below it. Otherwise it runs another round, reading again when the keys changed and fetching again when the fetch was below the pages, up to three rounds. It then refuses with the SDK's stale node error (StaleNodeError::Height) when the fetch stayed below the pages, or with PrivateContactRequestPending when the keys kept changing. Co-Authored-By: Claude Opus 5.5 --- .../src/wallet/identity/network/loading.rs | 10 +- .../network/private_contact_requests.rs | 275 +++++++++++++++--- .../managed_identity/contact_requests.rs | 8 + .../state/managed_identity/dashpay.rs | 16 + 4 files changed, 271 insertions(+), 38 deletions(-) diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/loading.rs b/packages/rs-platform-wallet/src/wallet/identity/network/loading.rs index 176bae1210e..c0d63b5afe3 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/loading.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/loading.rs @@ -350,6 +350,7 @@ impl IdentityWallet { ) -> Result { self.fetch_and_fold_identity(identity_id) .await + .map(|(identity, _)| identity) .map_err(|error| match error { PlatformWalletError::Sdk(e) => PlatformWalletError::InvalidIdentityData(format!( "Failed to fetch identity {} from Platform: {}", @@ -494,11 +495,13 @@ impl IdentityWallet { /// broadcaster, with a fetch that fails coming back as the SDK reported it /// ([`PlatformWalletError::Sdk`]): the fetch is proved, a stale node /// included (`dash_sdk::Error::StaleNode`). Applied only while the wallet - /// is still this handle's generation (`current_wallet_info_mut`). + /// is still this handle's generation (`current_wallet_info_mut`). Returns + /// the identity the cache holds afterwards, and the height the fetch was + /// proved at. pub(crate) async fn fetch_and_fold_identity( &self, identity_id: &Identifier, - ) -> Result { + ) -> Result<(Identity, u64), PlatformWalletError> { // Verify identity exists in the manager. { let wm = self.wallet_manager.read().await; @@ -521,6 +524,7 @@ impl IdentityWallet { identity_id )) })?; + let proved_at = metadata.height; let block_time = BlockTime::from(metadata); // Update the managed identity. @@ -539,7 +543,7 @@ impl IdentityWallet { } } - Ok(identity) + Ok((identity, proved_at)) } } 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 b2ec16d8f1a..2ba6690c665 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,6 +32,7 @@ use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; use dash_sdk::dapi_grpc::platform::v0::ResponseMetadata; +use dash_sdk::error::StaleNodeError; use dash_sdk::platform::dashpay::{ private_contact_request_identities, private_contact_request_signature, private_contact_request_signing_data, private_contact_request_signing_keys, @@ -177,6 +178,29 @@ const PRIVATE_SCAN_BATCH_PAGES: usize = MAX_CONCURRENT_PRIVATE_DOCUMENT_PAGE_FET /// from where they stopped. const PRIVATE_STORE_READ_PASSES: usize = 4; +/// How many rounds of a store read and a proved fetch of the sender identity +/// a public request or acceptance runs to show the read covers the identity's +/// keys before it refuses +/// ([`DashPayView::read_private_store_before_deciding`]): one when the fetch +/// is proved at or above the pages read and the keys did not change. +const PRIVATE_KEY_COVERAGE_ATTEMPTS: usize = 3; + +/// Whether the private store read an identity's decisions rest on covers its +/// keys as a proved fetch shows them. +enum PrivateStoreCoverage { + /// Read to its end with the keys the fetch shows, every page proved at or + /// below the fetch; or an identity the private sync does not read for. + Covered, + /// Not read to its end with those keys: they changed, or no read finished. + Unread, + /// Read with them, but the fetch was proved below a page the read rests + /// on: a key added in between could have a request sealed to it there. + IdentityBelow { + identity_height: u64, + store_height: u64, + }, +} + /// One of the wallet's identities, as a pass reads the store for it. struct ScannedIdentity { identity_id: Identifier, @@ -747,6 +771,19 @@ pub(super) fn private_request_record( record } +/// Whether `key` is one a private contact request entry can be sealed to, +/// which a private sync scans the store with. +fn scanning_key(key: &IdentityPublicKey) -> bool { + key.key_type() == KeyType::ECDSA_SECP256K1 && recipient_key_purpose_is_valid(key.purpose()) +} + +/// Whether a private sync reads DashPay's private contact request store for +/// `managed`: it has an HD index the wallet derives its keys at, and a key an +/// entry can be sealed to (`DashPayView::private_scan_identities`). +fn scans_private_store(managed: &ManagedIdentity) -> bool { + managed.identity_index.is_some() && managed.identity.public_keys().values().any(scanning_key) +} + /// The public key of `identity`'s key `key_id`. fn identity_public_key( identity: &Identity, @@ -1070,16 +1107,25 @@ impl DashPayView<'_, B> { /// position record marking a private payment that may be unresolved /// (`PrivateContactRequestsPosition::private_payments_unresolved`). /// - /// Then `identity_id` is fetched from Platform, proved, and its keys - /// folded into the cached identity (`IdentityWallet::refresh_identity`'s - /// fold): a DashPay encryption or decryption key another device added, - /// to which a request in the store may be sealed, changes the identity's - /// scanning keys, which rewinds its read - /// (`Self::reconcile_private_scan_keys`), so the store is read again - /// with it before anything is decided. A fetch that fails refuses the - /// request as the SDK reported it, with nothing read or sent: the keys a - /// read would scan with are not shown current. One proved identity fetch - /// per public request or acceptance. + /// Then the store read is shown to cover the identity's keys: after it, + /// `identity_id` is fetched from Platform, proved, and its keys folded + /// into the cached identity (`IdentityWallet::refresh_identity`'s fold). + /// A DashPay encryption or decryption key another device added, to which + /// a request in the store may be sealed, changes the identity's scanning + /// keys, which rewinds its read (`Self::reconcile_private_scan_keys`), and + /// the store is read again with it. A key added at a height is in no + /// request in a page proved below it, so the read covers the keys once + /// the fetch is proved at or above every page it read + /// (`DashPayState::private_store_read_height`); a fetch proved below them, + /// from a node behind, is made again. Up to + /// [`PRIVATE_KEY_COVERAGE_ATTEMPTS`] rounds, then refused: with the SDK's + /// stale node error (`StaleNodeError::Height`) when the fetch stayed + /// below the pages, and with + /// [`PlatformWalletError::PrivateContactRequestPending`] when the keys + /// kept changing so the read never finished with them. A fetch that fails + /// refuses the request as the SDK reported it. Nothing is sent before. + /// One proved identity fetch per public request or acceptance when the + /// first round covers it. pub(super) async fn read_private_store_before_deciding( &self, identity_id: &Identifier, @@ -1094,27 +1140,88 @@ impl DashPayView<'_, B> { { return Err(PlatformWalletError::PrivateContactPaymentsNotRestored); } - self.fetch_and_fold_identity(identity_id).await?; - match self - .read_private_store_before_sending(identity_id, recipient, crypto) - .await - { - 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)) + let mut uncovered = PrivateStoreCoverage::Unread; + for _ in 0..PRIVATE_KEY_COVERAGE_ATTEMPTS { + match self + .read_private_store_before_sending(identity_id, recipient, crypto) + .await + { + Ok(()) => {} + Err(PlatformWalletError::Sdk( + refusal @ dash_sdk::Error::ProtocolVersionNotActive { required, .. }, + )) => { + return 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, + Err(error) => return Err(error), + } + let (_, identity_height) = self.fetch_and_fold_identity(identity_id).await?; + self.reconcile_private_scan_keys().await?; + match self + .private_store_coverage(identity_id, identity_height) + .await? + { + PrivateStoreCoverage::Covered => return Ok(()), + coverage => uncovered = coverage, + } } + Err(match uncovered { + PrivateStoreCoverage::IdentityBelow { + identity_height, + store_height, + } => PlatformWalletError::Sdk(dash_sdk::Error::StaleNode(StaleNodeError::Height { + expected_height: store_height, + received_height: identity_height, + tolerance_blocks: 0, + })), + PrivateStoreCoverage::Covered | PrivateStoreCoverage::Unread => { + PlatformWalletError::PrivateContactRequestPending(*recipient) + } + }) + } + + /// Whether the private store read `identity_id`'s decisions rest on + /// covers its keys as the fetch proved at `identity_height` shows them + /// ([`Self::read_private_store_before_deciding`]). + async fn private_store_coverage( + &self, + identity_id: &Identifier, + identity_height: u64, + ) -> 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))?; + // No key a request can be sealed to that the wallet derives: the + // private sync does not read for it, and nothing holds it. + if !scans_private_store(managed) { + return Ok(PrivateStoreCoverage::Covered); + } + let dashpay = managed.dashpay(); + if !dashpay.private_contact_requests_read() { + return Ok(PrivateStoreCoverage::Unread); + } + Ok(match dashpay.private_store_read_height() { + Some(store_height) if store_height <= identity_height => PrivateStoreCoverage::Covered, + Some(store_height) => PrivateStoreCoverage::IdentityBelow { + identity_height, + store_height, + }, + None => PrivateStoreCoverage::Unread, + }) } /// Rewind the private read position and the read flag of each of the @@ -1632,9 +1739,7 @@ impl DashPayView<'_, B> { }; 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()) - { + if !scanning_key(key) { continue; } recipient_paths.push((*key_id, path(*key_id)?)); @@ -1729,7 +1834,7 @@ impl DashPayView<'_, B> { // and scanned in batches of those ready, one key derivation a batch. let mut batches = std::pin::pin!(self .sdk - .private_document_pages( + .private_document_pages_with_heights( contract.id(), PRIVATE_CONTACT_REQUEST_DOCUMENT_TYPE_NAME, start, @@ -1756,7 +1861,7 @@ impl DashPayView<'_, B> { } else { let pages: Vec<(u64, &PrivateDocumentEntries)> = fetched .iter() - .map(|page| (page.start_position, &page.entries)) + .map(|(page, _)| (page.start_position, &page.entries)) .collect(); crypto .scan_private_document_pages( @@ -1775,7 +1880,9 @@ impl DashPayView<'_, B> { ))); } let mut reached_end = false; - for scan in scans { + // The height each page was proved at, in the scans' order. + let page_heights: Vec = fetched.iter().map(|(_, height)| *height).collect(); + for (scan, page_height) in scans.into_iter().zip(page_heights) { report.pages_read += 1; let stale: BTreeMap = private_contact_request_identities(&scan) .into_iter() @@ -1953,6 +2060,9 @@ impl DashPayView<'_, B> { else { continue; }; + // Read for it, whatever its ingest does: the height + // its read rests on only rises. + managed.note_private_store_page_read(page_height); if held.contains(&identity_id) && !identity.failed { identity.failed = true; report.unpersisted_identities.push(identity_id); @@ -6523,6 +6633,101 @@ mod tests { .await; } + /// A public request is decided only once the sender identity is proved + /// at or above every store page the read rests on. Bob's store page, + /// proved at H, holds Alice's private request sealed to the decryption + /// key another device added at H; his cached identity lacks it. + /// + /// While every fetch of his identity is proved at H - 1, from a node a + /// block behind, without the key, each round's fetch stays below the page + /// and the request is refused with the SDK's stale node error, retryable, + /// with nothing sent. Once the fetch is proved at H, it brings the key, the + /// read is rewound, the next round reads the store again with it, finds + /// her request and refuses: a public one would publish the pair she kept + /// private. + #[tokio::test] + async fn should_decide_a_public_request_only_with_keys_proved_at_or_above_the_store_read() { + const H: u64 = 20; + 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 stale = without_key(&bob, DECRYPTION_KEY_ID); + let (_manager, bob_wallet) = + wallet(BOB_PHRASE, &stale, &[&alice, &stale], vec![entry]).await; + let mut sdk = (*bob_wallet.identity().sdk).clone(); + sdk.mock() + .set_response_height(H) + .set_response_height_of::(H - 1); + let view = bob_wallet.identity().dashpay(); + let (bob_id, alice_id) = (bob.id(), alice.id()); + let send = || { + view.send_contact_request_with_external_signer( + &bob_id, + &alice_id, + None, + AutoAcceptProofSource::None, + &bob_signer, + &bob_crypto, + ) + }; + + let behind = send().await; + assert!( + matches!( + &behind, + Err(PlatformWalletError::Sdk(dash_sdk::Error::StaleNode( + StaleNodeError::Height { + expected_height: H, + received_height, + .. + } + ))) if *received_height == H - 1 + ), + "{behind:?}" + ); + managed(&bob_wallet, BOB, |managed| { + assert!( + managed.dashpay().private_contact_requests_read(), + "read at H" + ); + assert!( + managed.dashpay().incoming_request(&alice.id()).is_none(), + "her request opens with no key he holds" + ); + assert!( + managed.dashpay().outgoing_request(&alice.id()).is_none(), + "nothing sent" + ); + }) + .await; + + // The identity is proved at H, with the key. + set_identity_known(&bob_wallet, &bob, true).await; + sdk.mock().set_response_height_of::(H); + let caught_up = send().await; + assert!( + matches!( + caught_up, + Err(PlatformWalletError::PrivateContactRequestNeedsPrivateReply(sender)) + if sender == alice.id() + ), + "{caught_up:?}" + ); + managed(&bob_wallet, BOB, |managed| { + assert!(managed + .identity + .public_keys() + .contains_key(&DECRYPTION_KEY_ID)); + assert!( + managed.dashpay().incoming_request(&alice.id()).is_some(), + "her private request is recorded" + ); + }) + .await; + } + /// A public request, or a public acceptance, whose sender identity cannot /// be fetched is refused as the SDK reported the fetch, before the store is /// read again or anything is signed: the keys a read would scan with are 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 a2d45dddadc..56bce9031a2 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 @@ -821,6 +821,14 @@ impl ManagedIdentity { self.dashpay.private_contact_requests_read = true; } + /// Note that a private sync read a page of DashPay's private contact + /// request store, proved at `height`, for this identity + /// (`DashPayState::private_store_read_height`). + pub(crate) fn note_private_store_page_read(&mut self, height: u64) { + let read = &mut self.dashpay.private_store_read_height; + *read = Some(read.map_or(height, |read| read.max(height))); + } + /// [`Self::note_private_contact_requests_read`], for tests outside this /// crate that read an identity's state after a private sync. #[cfg(any(test, feature = "test-utils"))] 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 5fd632c5f17..c8f2364cdfd 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,16 @@ pub struct DashPayState { /// In memory only; a cursor rewind leaves it set. pub(super) private_contact_requests_read: bool, + /// The highest height a page of DashPay's private contact request store + /// was proved at among those a private sync read for this identity since + /// load: what `private_contact_requests_read` rests on. A request sealed + /// to one of its keys is in such a page only if the key was added at or + /// below it, so the store read covers the identity's keys as proved at or + /// above it, and a public request is decided only with those + /// (`DashPayView::read_private_store_before_deciding`). `None` before any + /// page was read. In memory only; never lowered. + pub(super) private_store_read_height: Option, + /// 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 @@ -396,6 +406,12 @@ impl DashPayState { self.private_contact_requests_read } + /// The highest proved height of a store page a private sync read for this + /// identity since load, if one did ([`Self::private_contact_requests_read`]). + pub(crate) fn private_store_read_height(&self) -> Option { + self.private_store_read_height + } + /// 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 c22910bcf6cc2c81ee4473a3b4bd3aaa5fba1de7 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Tue, 10 Nov 2026 08:14:07 +0700 Subject: [PATCH 218/269] fix(sdk): yield the lowest and highest proved heights of a private document page A page of private_document_pages_with_heights completed from more than one read, the first from a node behind, carried only the lowest height of its reads. The completing reads can come from a newer snapshot, and the wallet took that lowest height as the height its store read rests on, so an identity proof between the page's heights passed the public request gate while a request sealed to a key added at the higher one stayed unread. Each page now comes with PrivateDocumentPageHeights { min, max }. The shielded expiry's absence read keeps the minimum: every read a page holds is at or above the floor. The private sync records the maximum as the height an identity's store read rests on, which the identity proof must reach before a public request is decided. The mock SDK gains set_response_height_for, the height of one request's response, for tests that read responses of one type at different heights. Co-Authored-By: Claude Opus 5.5 --- .../network/private_contact_requests.rs | 173 +++++++++++++++++- .../state/managed_identity/dashpay.rs | 7 +- .../src/wallet/shielded/coordinator.rs | 4 +- packages/rs-sdk/src/mock/sdk.rs | 24 ++- .../src/platform/private_document_scan.rs | 160 ++++++++++++---- 5 files changed, 321 insertions(+), 47 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 2ba6690c665..3623259b467 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 @@ -1880,8 +1880,9 @@ impl DashPayView<'_, B> { ))); } let mut reached_end = false; - // The height each page was proved at, in the scans' order. - let page_heights: Vec = fetched.iter().map(|(_, height)| *height).collect(); + // The highest height each page's reads were proved at, in the scans' order: no + // entry a page holds was appended above it, nor sealed to a key added above it. + let page_heights: Vec = fetched.iter().map(|(_, heights)| heights.max).collect(); for (scan, page_height) in scans.into_iter().zip(page_heights) { report.pages_read += 1; let stale: BTreeMap = private_contact_request_identities(&scan) @@ -6728,6 +6729,116 @@ mod tests { .await; } + /// A store page completed from two reads, the first from a node a block + /// behind, counts the higher one: Bob's page from 2 is read at H - 1 up to + /// that node's end, and completed at H with Alice's private request sealed + /// to the decryption key another device added at H. His identity proved at + /// H - 1, between the page's two heights, without the key, does not cover + /// the read: his public request is refused with the SDK's stale node error + /// against H. Once proved at H with the key, the store is read again, her + /// request is found, and the public request is refused. + #[tokio::test] + async fn should_cover_the_highest_read_of_a_completed_store_page_before_a_public_request() { + const H: u64 = 20; + 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 stale = without_key(&bob, DECRYPTION_KEY_ID); + let (_manager, bob_wallet) = wallet(BOB_PHRASE, &stale, &[&alice, &stale], vec![]).await; + // Chunks of two: the page from 0, then the page from 2, read from a + // node whose store held 3 entries and completed from 3. + let filler = |byte: u8| vec![byte; 589]; + let page = |start: u64, entries: Vec>, total: u64| { + let mut page = store_page(start, entries); + page.chunk_power = Some(1); + page.total_count = total; + page + }; + let contract = dashpay_contract().expect("bundled DashPay"); + let query = |start| { + PrivateDocumentEntriesQuery::new( + contract.id(), + PRIVATE_CONTACT_REQUEST_DOCUMENT_TYPE_NAME, + start, + ) + }; + let mut sdk = (*bob_wallet.identity().sdk).clone(); + { + let mut mock = sdk.mock(); + for (start, page) in [ + (0, page(0, vec![filler(0xA0), filler(0xA1)], 4)), + (2, page(2, vec![filler(0xA2)], 3)), + (3, page(3, vec![entry], 4)), + ] { + mock.remove_fetch_expectation::(query(start)) + .await; + mock.expect_fetch(query(start), Some(page)) + .await + .expect("page expectation"); + } + mock.set_response_height(H - 1) + .set_response_height_for::(query(3), H); + } + let view = bob_wallet.identity().dashpay(); + let (bob_id, alice_id) = (bob.id(), alice.id()); + let send = || { + view.send_contact_request_with_external_signer( + &bob_id, + &alice_id, + None, + AutoAcceptProofSource::None, + &bob_signer, + &bob_crypto, + ) + }; + + let between = send().await; + assert!( + matches!( + &between, + Err(PlatformWalletError::Sdk(dash_sdk::Error::StaleNode( + StaleNodeError::Height { + expected_height: H, + received_height, + .. + } + ))) if *received_height == H - 1 + ), + "{between:?}" + ); + managed(&bob_wallet, BOB, |managed| { + assert_eq!(managed.dashpay().private_store_read_height(), Some(H)); + assert!(managed.dashpay().incoming_request(&alice.id()).is_none()); + assert!( + managed.dashpay().outgoing_request(&alice.id()).is_none(), + "nothing sent" + ); + }) + .await; + + // The identity is proved at H, with the key. + set_identity_known(&bob_wallet, &bob, true).await; + sdk.mock().set_response_height_of::(H); + let covered = send().await; + assert!( + matches!( + covered, + Err(PlatformWalletError::PrivateContactRequestNeedsPrivateReply(sender)) + if sender == alice.id() + ), + "{covered:?}" + ); + managed(&bob_wallet, BOB, |managed| { + assert!( + managed.dashpay().incoming_request(&alice.id()).is_some(), + "her private request is recorded" + ); + }) + .await; + } + /// A public request, or a public acceptance, whose sender identity cannot /// be fetched is refused as the SDK reported the fetch, before the store is /// read again or anything is signed: the keys a read would scan with are @@ -8654,6 +8765,64 @@ mod tests { ); } + /// The store read that shows a payment's entry absent counts a page by the + /// lowest of its reads: a page completed at the floor from a first read + /// one block below it does not show the store at the floor, and the read + /// fails; read wholly at the floor, it shows the entry absent. + #[cfg(feature = "shielded")] + #[tokio::test] + async fn should_not_take_a_store_page_completed_from_below_the_floor_as_absence() { + let fixture = ExpiryFixture::new().await; + let floor = ExpiryFixture::ANCHOR_SET_HEIGHT; + let filler = |byte: u8| vec![byte; 64]; + let page = |start: u64, entries: Vec>, total: u64| { + let mut page = store_page(start, entries); + page.chunk_power = Some(1); + page.total_count = total; + page + }; + let contract = dashpay_contract().expect("bundled DashPay"); + let query = |start| { + PrivateDocumentEntriesQuery::new( + contract.id(), + PRIVATE_CONTACT_REQUEST_DOCUMENT_TYPE_NAME, + start, + ) + }; + let mut sdk = (*fixture.wallet.identity().sdk).clone(); + { + let mut mock = sdk.mock(); + for (start, page) in [ + (0, page(0, vec![filler(0xA0), filler(0xA1)], 4)), + (2, page(2, vec![filler(0xA2)], 3)), + (3, page(3, vec![filler(0xA3)], 4)), + ] { + mock.remove_fetch_expectation::(query(start)) + .await; + mock.expect_fetch(query(start), Some(page)) + .await + .expect("page expectation"); + } + mock.set_response_height(floor) + .set_response_height_for::(query(2), floor - 1); + } + let candidates = HashSet::from([[0x72; 32]]); + let below = fixture + .coordinator + .private_contact_requests_held(&candidates, floor) + .await; + assert!(below.is_err(), "{below:?}"); + + sdk.mock() + .set_response_height_for::(query(2), floor); + let held = fixture + .coordinator + .private_contact_requests_held(&candidates, floor) + .await + .expect("read at the floor"); + assert!(held.is_empty(), "absent"); + } + /// A private payment that never executes is proven expired once its /// anchor is pruned and its entry is absent from the store: the /// coordinator discharges its journal record, the pair is freed, a retry 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 c8f2364cdfd..e9d4923cdb5 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,9 +124,10 @@ pub struct DashPayState { /// In memory only; a cursor rewind leaves it set. pub(super) private_contact_requests_read: bool, - /// The highest height a page of DashPay's private contact request store - /// was proved at among those a private sync read for this identity since - /// load: what `private_contact_requests_read` rests on. A request sealed + /// The highest height a read of a page of DashPay's private contact + /// request store was proved at, among the pages a private sync read for + /// this identity since load, a page completed from several reads counting + /// its highest: what `private_contact_requests_read` rests on. A request sealed /// to one of its keys is in such a page only if the key was added at or /// below it, so the store read covers the identity's keys as proved at or /// above it, and a public request is decided only with those diff --git a/packages/rs-platform-wallet/src/wallet/shielded/coordinator.rs b/packages/rs-platform-wallet/src/wallet/shielded/coordinator.rs index ea091e72654..6090fb5e253 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/coordinator.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/coordinator.rs @@ -2127,7 +2127,9 @@ impl NetworkShieldedCoordinator { let mut held = HashSet::new(); let mut read = false; while let Some(page) = pages.next().await { - let (page, height) = page?; + let (page, heights) = page?; + // Every read the page holds, at or above the floor. + let height = heights.min; if height < floor { return Err(PlatformWalletError::ShieldedSyncFailed(format!( "the private contact request store's page from position {} is proved at \ diff --git a/packages/rs-sdk/src/mock/sdk.rs b/packages/rs-sdk/src/mock/sdk.rs index 9205c1055b9..72cefa247ff 100644 --- a/packages/rs-sdk/src/mock/sdk.rs +++ b/packages/rs-sdk/src/mock/sdk.rs @@ -54,6 +54,10 @@ pub struct MockDashPlatformSdk { /// 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>, + /// The block height the metadata of the response to one request answered from an + /// expectation reports, by the request's key ([Self::set_response_height_for]), over + /// `response_heights_of`. + response_heights_for: BTreeMap, } impl MockDashPlatformSdk { @@ -88,6 +92,7 @@ impl MockDashPlatformSdk { response_time_ms: 0, response_height: 0, response_heights_of: BTreeMap::new(), + response_heights_for: BTreeMap::new(), } } @@ -114,6 +119,20 @@ impl MockDashPlatformSdk { self } + /// Sets the block height the metadata of the response to `query`, a fetch of `O` answered + /// from an expectation, reports, over [Self::set_response_height_of]: for code under test + /// that reads responses of one type proved at different heights. + pub fn set_response_height_for::Query>>( + &mut self, + query: Q, + height: u64, + ) -> &mut Self { + let (rich, _) = self + .encode_rich_to_wire::::Query, ::Request>(query, identity); + self.response_heights_for.insert(Key::new(&rich), height); + self + } + pub(crate) fn set_sdk(&mut self, sdk: Sdk) { self.sdk.store(Some(Arc::new(sdk))); } @@ -633,8 +652,9 @@ impl MockDashPlatformSdk { protocol_version: dpp::version::LATEST_VERSION, time_ms: self.response_time_ms, height: self - .response_heights_of - .get(std::any::type_name::()) + .response_heights_for + .get(&key) + .or_else(|| self.response_heights_of.get(std::any::type_name::())) .copied() .unwrap_or(self.response_height), ..Default::default() diff --git a/packages/rs-sdk/src/platform/private_document_scan.rs b/packages/rs-sdk/src/platform/private_document_scan.rs index c7717488172..165df66a837 100644 --- a/packages/rs-sdk/src/platform/private_document_scan.rs +++ b/packages/rs-sdk/src/platform/private_document_scan.rs @@ -438,13 +438,27 @@ pub struct PrivateDocumentPage { pub entries: PrivateDocumentEntries, } +/// The heights of the proofs of the reads a page of +/// [`Sdk::private_document_pages_with_heights`] holds: one read, or a read and those completing +/// it from where an older snapshot of the store ended. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct PrivateDocumentPageHeights { + /// The lowest: every read the page holds was proved at or above it, so the store held at + /// least the page's entries by then, and an entry absent from it was not appended by then. + pub min: u64, + /// The highest: no read the page holds was proved above it, so every entry it holds was + /// appended at or below it. + pub max: u64, +} + 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>>; +type HeightedPageStream = Pin< + Box> + Send>, +>; /// Where a walk over a store's pages is. enum PageWalk { @@ -581,15 +595,15 @@ 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 +/// proof, each page yielded with the lowest and highest heights 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 +) -> impl Stream> + Send where F: Fn(u64) -> HeightedPageFuture + Clone + Send + Sync + 'static, { @@ -621,16 +635,20 @@ where let mut later = read.split_off(&end); heights.append(&mut later); drop(heights); - let height = read + let heights = read .contains_key(&start) - .then(|| read.into_values().min()) + .then(|| { + let min = read.values().min().copied()?; + let max = read.values().max().copied()?; + Some(PrivateDocumentPageHeights { min, max }) + }) .flatten() .ok_or_else(|| { Error::Generic(format!( "no proved height for the private document page from position {start}" )) })?; - Ok((page, height)) + Ok((page, heights)) }) } @@ -815,18 +833,21 @@ 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. 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`]. + /// [`Self::private_document_pages`], with the heights each page's proofs were made at, from + /// the response metadata verified with them ([`PrivateDocumentPageHeights`]). A page + /// completed from more than one read, the first from a node behind, carries the lowest and + /// the highest of their heights: the lowest for a caller that must show every page, and the + /// store's end the first page reports, at or above a height; the highest for one that must + /// know a height no entry of the page was appended above. 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 { + ) -> impl Stream> + Send + { let sdk = self.clone(); let document_type_name = document_type_name.to_string(); let resolve = { @@ -1558,9 +1579,10 @@ mod tests { } /// 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. + /// reads as its lowest and the higher as its highest; every other page, the height of its + /// own as both. #[tokio::test] - async fn should_yield_a_completed_page_at_the_lowest_height_of_its_reads() { + async fn should_yield_a_completed_page_with_the_lowest_and_highest_heights_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 @@ -1571,18 +1593,23 @@ mod tests { 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 pages: Vec<(PrivateDocumentPage, PrivateDocumentPageHeights)> = + 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); + for (page, heights) in &pages { + let expected = if page.start_position == 12 { + PrivateDocumentPageHeights { min: 7, max: 9 } + } else { + PrivateDocumentPageHeights { min: 9, max: 9 } + }; + assert_eq!(*heights, expected, "page from {}", page.start_position); positions.extend(page.entries.entries.iter().map(|entry| entry.position)); } assert_eq!(positions, (0..30).collect::>()); @@ -1602,20 +1629,23 @@ mod tests { 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 pages: Vec<(PrivateDocumentPage, PrivateDocumentPageHeights)> = + 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() + .all(|(_, heights)| *heights == PrivateDocumentPageHeights { min: 9, max: 9 }), "{:?}", pages .iter() - .map(|(page, height)| (page.start_position, *height)) + .map(|(page, heights)| (page.start_position, *heights)) .collect::>() ); assert_eq!(pages.len(), 8); @@ -1871,6 +1901,56 @@ mod tests { ); } + /// Through the SDK, a page completed from where a node behind ended its snapshot carries the + /// height of that read as its lowest and the completing read's as its highest. + #[cfg(feature = "mocks")] + #[tokio::test] + async fn should_stream_a_completed_page_with_the_heights_of_both_its_reads() { + 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); + // The node answering the page from 12 is a block behind, at height 8, within the SDK's + // tolerance: its store held 14 entries. The rest of that chunk, and every other page, + // come at height 9. + let query = |start| PrivateDocumentEntriesQuery::new(contract_id, TYPE_NAME, start); + for start in [0, 4, 8, 12, 14, 16, 20, 24, 28] { + let total = if start == 12 { 14 } else { 30 }; + sdk.mock() + .expect_fetch(query(start), Some(drive_page(4, total, start))) + .await + .expect("expected to register the page"); + } + sdk.mock() + .set_response_height(9) + .set_response_height_for::(query(12), 8); + let pages: Vec<(PrivateDocumentPage, PrivateDocumentPageHeights)> = sdk + .private_document_pages_with_heights(contract_id, TYPE_NAME, 0, 3) + .map(|page| page.expect("every page reads")) + .collect() + .await; + let mut positions = Vec::new(); + for (page, heights) in &pages { + let expected = if page.start_position == 12 { + PrivateDocumentPageHeights { min: 8, max: 9 } + } else { + PrivateDocumentPageHeights { min: 9, max: 9 } + }; + assert_eq!(*heights, expected, "page from {}", page.start_position); + positions.extend(page.entries.entries.iter().map(|entry| entry.position)); + } + assert_eq!(positions, (0..30).collect::>()); + } + /// 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")] @@ -1903,7 +1983,7 @@ mod tests { .map(|page| page.expect("every page reads")) .collect() .await; - let with_heights: Vec<(PrivateDocumentPage, u64)> = sdk + let with_heights: Vec<(PrivateDocumentPage, PrivateDocumentPageHeights)> = sdk .private_document_pages_with_heights(contract_id, TYPE_NAME, 2, 3) .map(|page| page.expect("every page reads")) .collect() @@ -1916,8 +1996,10 @@ mod tests { plain ); // The mock answers every page with the same response metadata. - let heights: Vec = with_heights.iter().map(|(_, height)| *height).collect(); + let heights: Vec = + with_heights.iter().map(|(_, heights)| *heights).collect(); assert_eq!(heights.len(), 4); + assert!(heights.iter().all(|heights| heights.min == heights.max)); assert!( heights.windows(2).all(|pair| pair[0] == pair[1]), "{heights:?}" From 54662479e4f308dbb80e76f45bffc14f9a7629fa Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Tue, 10 Nov 2026 08:15:05 +0700 Subject: [PATCH 219/269] fix(platform-wallet): keep read-back notices whose flush failed until a flush succeeds A private sync that read back a paid private request noted its entry for the journal record to be discharged only after flushing what records it. When that flush failed, the notice was dropped. The sync had already settled the identity's reservation and moved its cursor past the entry, so no later sync could note it again, and the journal record and the wallet-level pair hold stayed for the rest of the session. The wallet persister now keeps those commitments, and every later call that flushes successfully notes them read back. The release of discharged records, which runs at the start of every private sync, before a private request is reserved and before a public one claims its pair, retries them first. Until then the record and the pair hold stay, so nothing is sent to the pair. Co-Authored-By: Claude Opus 5.5 --- .../network/private_contact_requests.rs | 119 ++++++++++++++---- .../src/wallet/persister.rs | 48 +++++++ 2 files changed, 143 insertions(+), 24 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 3623259b467..bca5b47d0a9 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 @@ -422,28 +422,6 @@ 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. Only a shielded build keeps a journal, -/// so only it notes anything. -#[cfg(feature = "shielded")] -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 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). @@ -1356,10 +1334,16 @@ impl DashPayView<'_, B> { /// stored again when it differs from the one last stored /// (`private_marker_stale`): a discharge clears the journal's part also /// for an identity whose own reservation a read-back already settled, so - /// a reload does not hold it on a marker no payment backs. + /// a reload does not hold it on a marker no payment backs. First, the + /// read-back notices a failed flush kept are retried + /// (`WalletPersister::note_read_back_once_durable`). pub(crate) async fn release_discharged_private_requests( &self, ) -> Result<(), PlatformWalletError> { + // Read-back notices a failed flush kept, retried first: their journal + // records are discharged once noted. + #[cfg(feature = "shielded")] + self.persister.note_read_back_once_durable([]); if !self.persister.has_unreleased_discharged_private_entries() { return Ok(()); } @@ -2174,7 +2158,7 @@ impl DashPayView<'_, B> { // pair unheld while no request of it is recorded. #[cfg(feature = "shielded")] if !read_back.is_empty() { - note_read_back_once_durable(&self.persister, read_back); + self.persister.note_read_back_once_durable(read_back); } } else if !identity.failed { identity.failed = true; @@ -2713,6 +2697,8 @@ mod tests { /// While set, a store carrying a private read position record fails /// and keeps nothing. positions_fail: AtomicBool, + /// While set, a flush fails. + flush_fails: AtomicBool, } impl RecordingPersistence { @@ -2721,6 +2707,7 @@ mod tests { capabilities, stored: Mutex::new(Vec::new()), positions_fail: AtomicBool::new(false), + flush_fails: AtomicBool::new(false), }) } @@ -2754,6 +2741,9 @@ mod tests { } fn flush(&self, _wallet_id: WalletId) -> Result<(), PersistenceError> { + if self.flush_fails.load(Ordering::SeqCst) { + return Err(PersistenceError::backend("the disk is full")); + } Ok(()) } @@ -3297,6 +3287,87 @@ mod tests { assert_eq!(records(), before + 1, "unchanged: not stored again"); } + /// A read-back whose records' flush fails keeps its notice: the pair stays + /// held, as the journal record does, and a retry pays nothing. The next + /// step's flush succeeds and notes it, the coordinator discharges the + /// record, the pair is freed, and a retry pays. + #[cfg(feature = "shielded")] + #[tokio::test] + async fn should_keep_a_read_back_notice_whose_flush_failed_until_a_flush_succeeds() { + 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; + fixture + .wallet + .restore_private_contact_payments(&fixture.coordinator) + .await + .expect("restored"); + let persister = fixture.wallet.persister(); + assert!(persister.private_pair_journaled(&alice.id(), &bob.id())); + + // Read back while flushes fail: nothing is noted. + publish(&fixture.wallet, 0, vec![entry.to_vec()]).await; + publish(&fixture.wallet, 1, vec![]).await; + fixture + .persistence + .flush_fails + .store(true, Ordering::SeqCst); + let view = fixture.wallet.identity().dashpay(); + view.sync_private_contact_requests(&alice_crypto, 4) + .await + .expect("the entry is read back"); + assert!( + !persister.has_read_back_private_entries(), + "not durable yet" + ); + fixture + .coordinator + .discharge_read_back_private_payments() + .await; + assert_eq!(fixture.journaled().await, 1, "kept"); + assert!(persister.private_pair_journaled(&alice.id(), &bob.id())); + // A retry, its flush failing again, pays nothing. + let retry = Arc::new(Mutex::new(Vec::new())); + let held = fixture.send_again(keep_entry(&retry)).await; + assert!( + matches!( + held, + Err(PlatformWalletError::PrivateContactRequestPending(_)) + ), + "{held:?}" + ); + assert!(retry.lock().expect("retry").is_empty(), "nothing paid"); + + // The next step's flush succeeds: noted, discharged, freed. + fixture + .persistence + .flush_fails + .store(false, Ordering::SeqCst); + view.sync_private_contact_requests(&alice_crypto, 4) + .await + .expect("the next private sync"); + assert!(persister.has_read_back_private_entries(), "noted"); + fixture + .coordinator + .discharge_read_back_private_payments() + .await; + assert_eq!(fixture.journaled().await, 0, "discharged"); + assert!(!persister.private_pair_journaled(&alice.id(), &bob.id())); + let paid = fixture.send_again(keep_entry(&retry)).await; + assert!( + matches!( + paid, + Err(PlatformWalletError::PrivateContactRequestPending(_)) + ), + "paid, not read back: {paid:?}" + ); + assert_eq!(retry.lock().expect("retry").len(), 1, "the retry pays"); + } + /// An identity reloaded from a position record stored with its marker /// cleared, as a discharge leaves it, is not held, in any build: its /// public request goes on to be built, with no restoration needed. diff --git a/packages/rs-platform-wallet/src/wallet/persister.rs b/packages/rs-platform-wallet/src/wallet/persister.rs index a830e9b12fb..b08f537d4c3 100644 --- a/packages/rs-platform-wallet/src/wallet/persister.rs +++ b/packages/rs-platform-wallet/src/wallet/persister.rs @@ -70,6 +70,15 @@ pub struct WalletPersister { /// discharge, so only it keeps them. #[cfg(feature = "shielded")] read_back_private_entries: Arc>>, + /// Commitments of private contact request entries a private sync read + /// back and recorded, whose records' flush failed: noted read back + /// (`read_back_private_entries`) once a later flush succeeds + /// ([`Self::note_read_back_once_durable`]), at the wallet's next private + /// sync, send or reservation. Shared by the clones the wallet and the + /// coordinator hold. In memory only: a restart loses them with the + /// records the failed flush did not keep. + #[cfg(feature = "shielded")] + unflushed_read_back_private_entries: 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 @@ -123,6 +132,8 @@ impl WalletPersister { contact_payment_gate: Arc::new(tokio::sync::Mutex::new(())), #[cfg(feature = "shielded")] read_back_private_entries: Arc::default(), + #[cfg(feature = "shielded")] + unflushed_read_back_private_entries: Arc::default(), discharged_private_entries: Arc::default(), private_payments_unrestored: Arc::default(), stored_private_markers: Arc::default(), @@ -165,6 +176,43 @@ impl WalletPersister { self.read_back_entries().extend(commitments); } + /// Note `commitments`, of private requests a private sync read back and + /// recorded, for their journal records to be discharged + /// ([`Self::note_read_back_private_entries`]), once what records them is + /// durable: at once for a backend that commits inline, after a flush + /// otherwise. Those a flush failed for are kept, with every one kept + /// before, and noted by the next call whose flush succeeds: an empty one + /// retries them. Their records, and so the pairs they hold, stay until + /// then. + #[cfg(feature = "shielded")] + pub(crate) fn note_read_back_once_durable( + &self, + commitments: impl IntoIterator, + ) { + let mut unflushed = self + .unflushed_read_back_private_entries + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + unflushed.extend(commitments); + if unflushed.is_empty() { + return; + } + if !self.store_commits_inline() { + if let Err(error) = self.flush() { + tracing::warn!( + %error, + kept = unflushed.len(), + "private requests read back are not durably recorded; their journal \ + records stay until a later flush succeeds" + ); + return; + } + } + let durable = std::mem::take(&mut *unflushed); + drop(unflushed); + self.note_read_back_private_entries(durable); + } + /// Take the commitments [`Self::note_read_back_private_entries`] noted: /// a caller that fails to discharge one notes it again. #[cfg(feature = "shielded")] From 9d385c7c6238b38c56752a8a66894c5173785b1e Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Tue, 10 Nov 2026 10:24:44 +0700 Subject: [PATCH 220/269] fix(platform-wallet): count a rewound private store read only once it reaches the store already seen A key change, or a sender unignored, rewinds an identity's read of DashPay's private contact request store: the cursor goes back to 0 and the read is no longer read to the end. The height that read rested on was kept. A new read with the new keys, answered by a node behind, then counted as read to the end at its older snapshot, although the wallet had already seen entries past it that only the earlier keys had scanned. With a current identity proof, a public request could be decided before an entry sealed to the new key was opened. A rewind now resets the read height, as the review suggested, and keeps it as a floor. A read since counts as read to the end only once it reaches that floor. Until it does, a public request's preflight keeps reading from the cursor, and refuses as pending when the reads stop advancing short of it. Co-Authored-By: Claude Opus 5.5 --- .../network/private_contact_requests.rs | 123 ++++++++++++++++++ .../managed_identity/contact_requests.rs | 37 +++++- .../state/managed_identity/dashpay.rs | 15 ++- 3 files changed, 167 insertions(+), 8 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 bca5b47d0a9..6fd8d910c69 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 @@ -6910,6 +6910,129 @@ mod tests { .await; } + /// A read rewound by a key change counts as read to the end only once it + /// reaches the store already seen. Bob read the store at H with keys + /// missing the decryption key another device added, to which Alice's + /// request at position 1 is sealed. His public request brings the key and + /// rewinds the read; a node a block behind then answers the store from its + /// start holding only the entry before hers. That read does not count, so + /// the request is refused as pending, with nothing sent. Once the store is + /// read from the cursor at H, her request is found, and the public request + /// is refused. + #[tokio::test] + async fn should_not_count_a_rewound_read_from_behind_the_store_already_seen() { + const H: u64 = 20; + 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 stale = without_key(&bob, DECRYPTION_KEY_ID); + let (_manager, bob_wallet) = wallet(BOB_PHRASE, &stale, &[&alice, &stale], vec![]).await; + let filler = vec![0xA0; 589]; + let page = |start: u64, entries: Vec>, total: u64| { + let mut page = store_page(start, entries); + page.total_count = total; + page + }; + let contract = dashpay_contract().expect("bundled DashPay"); + let query = |start| { + PrivateDocumentEntriesQuery::new( + contract.id(), + PRIVATE_CONTACT_REQUEST_DOCUMENT_TYPE_NAME, + start, + ) + }; + let sdk = (*bob_wallet.identity().sdk).clone(); + sdk.clone().mock().set_response_height(H); + let serve = |pages: Vec<(u64, PrivateDocumentEntries, u64)>| { + let mut sdk = sdk.clone(); + async move { + let mut mock = sdk.mock(); + for (start, page, height) in pages { + mock.remove_fetch_expectation::(query(start)) + .await; + mock.expect_fetch(query(start), Some(page)) + .await + .expect("page expectation"); + mock.set_response_height_for::(query(start), height); + } + } + }; + serve(vec![( + 0, + page(0, vec![filler.clone(), entry.clone()], 2), + H, + )]) + .await; + let view = bob_wallet.identity().dashpay(); + view.sync_private_contact_requests(&bob_crypto, 4) + .await + .expect("Bob reads the store at H with his older keys"); + assert!(store_read(&bob_wallet, BOB).await); + let (bob_id, alice_id) = (bob.id(), alice.id()); + let send = || { + view.send_contact_request_with_external_signer( + &bob_id, + &alice_id, + None, + AutoAcceptProofSource::None, + &bob_signer, + &bob_crypto, + ) + }; + + // The key reaches Bob's identity; a node a block behind answers. + set_identity_known(&bob_wallet, &bob, true).await; + serve(vec![ + (0, page(0, vec![filler.clone()], 1), H - 1), + (1, page(1, vec![], 1), H - 1), + ]) + .await; + let behind = send().await; + assert!( + matches!( + behind, + Err(PlatformWalletError::PrivateContactRequestPending(recipient)) + if recipient == alice.id() + ), + "{behind:?}" + ); + managed(&bob_wallet, BOB, |managed| { + assert!( + !managed.dashpay().private_contact_requests_read(), + "not counted" + ); + assert_eq!(managed.dashpay().private_store_read_height(), Some(H - 1)); + assert!(managed.dashpay().incoming_request(&alice.id()).is_none()); + assert!( + managed.dashpay().outgoing_request(&alice.id()).is_none(), + "nothing sent" + ); + }) + .await; + + // The store is read from the cursor at H. + serve(vec![(1, page(1, vec![entry], 2), H)]).await; + let caught_up = send().await; + assert!( + matches!( + caught_up, + Err(PlatformWalletError::PrivateContactRequestNeedsPrivateReply(sender)) + if sender == alice.id() + ), + "{caught_up:?}" + ); + managed(&bob_wallet, BOB, |managed| { + assert!(managed.dashpay().private_contact_requests_read()); + assert!( + managed.dashpay().incoming_request(&alice.id()).is_some(), + "her private request is recorded" + ); + }) + .await; + } + /// A public request, or a public acceptance, whose sender identity cannot /// be fetched is refused as the SDK reported the fetch, before the store is /// read again or anything is signed: the keys a read would scan with are 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 56bce9031a2..59e64cf312c 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 @@ -394,12 +394,11 @@ 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; // 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; + self.rewind_private_store_read(); let mut cs = ContactChangeSet::default(); cs.unignored.insert((owner_id, *sender_id)); @@ -816,9 +815,36 @@ impl ManagedIdentity { } /// Note that a private sync read DashPay's private contact request store - /// to its end for this identity, with nothing held back. + /// to its end for this identity, with nothing held back: counted once + /// the read reaches the height a rewind left as its floor + /// (`DashPayState::private_store_read_floor`), so a read from a node + /// behind the store already seen does not count. pub(crate) fn note_private_contact_requests_read(&mut self) { - self.dashpay.private_contact_requests_read = true; + let dashpay = &mut self.dashpay; + if dashpay.private_store_read_floor.is_none_or(|floor| { + dashpay + .private_store_read_height + .is_some_and(|read| read >= floor) + }) { + dashpay.private_contact_requests_read = true; + } + } + + /// Rewind this identity's read of DashPay's private contact request + /// store to its start: the position goes back to 0, the read is no longer + /// read to the end, and the height it rested on is reset, kept as the + /// floor a read since must reach (`DashPayState::private_store_read_floor`). + fn rewind_private_store_read(&mut self) { + let dashpay = &mut self.dashpay; + dashpay.private_contact_requests_position = 0; + dashpay.private_contact_requests_read = false; + if let Some(read) = dashpay.private_store_read_height.take() { + dashpay.private_store_read_floor = Some( + dashpay + .private_store_read_floor + .map_or(read, |floor| floor.max(read)), + ); + } } /// Note that a private sync read a page of DashPay's private contact @@ -923,8 +949,7 @@ impl ManagedIdentity { 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.rewind_private_store_read(); } self.dashpay.private_scan_keys = Some(key_fingerprint); Some(self.private_contact_requests_position_record()) 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 e9d4923cdb5..b4b334e29a8 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 @@ -121,7 +121,8 @@ pub struct DashPayState { /// 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. + /// In memory only; a rewind of the read clears it + /// (`ManagedIdentity::rewind_private_store_read`). pub(super) private_contact_requests_read: bool, /// The highest height a read of a page of DashPay's private contact @@ -132,9 +133,19 @@ pub struct DashPayState { /// below it, so the store read covers the identity's keys as proved at or /// above it, and a public request is decided only with those /// (`DashPayView::read_private_store_before_deciding`). `None` before any - /// page was read. In memory only; never lowered. + /// page was read with the identity's keys since its read was last rewound + /// (`ManagedIdentity::rewind_private_store_read`). In memory only. pub(super) private_store_read_height: Option, + /// The highest `private_store_read_height` a rewind of the read reset: + /// the store was seen at that height, so a read since, with the keys the + /// rewind was for, counts as read to the end + /// (`private_contact_requests_read`) only once it reaches it. A read from + /// a node behind it would otherwise pass over entries the wallet already + /// saw, which the earlier keys could not open. `None` before any rewind of + /// a read. In memory only; never lowered. + pub(super) private_store_read_floor: Option, + /// 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 From fbb4d369d522ac58511eff807f130e001463d0fc Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Tue, 10 Nov 2026 10:24:44 +0700 Subject: [PATCH 221/269] fix(platform-wallet): flush read-back notices without holding their lock note_read_back_once_durable held the lock on the read-back commitments a failed flush kept while the host's synchronous flush ran. A slow flush held up every other notice, and a backend whose flush reached the same persister again deadlocked. The pending commitments are now taken out under the lock, which is released before the flush. A failed flush puts them back with any added meanwhile. Co-Authored-By: Claude Opus 5.5 --- .../src/wallet/persister.rs | 95 ++++++++++++++++--- 1 file changed, 84 insertions(+), 11 deletions(-) diff --git a/packages/rs-platform-wallet/src/wallet/persister.rs b/packages/rs-platform-wallet/src/wallet/persister.rs index b08f537d4c3..e5a2dec6e4c 100644 --- a/packages/rs-platform-wallet/src/wallet/persister.rs +++ b/packages/rs-platform-wallet/src/wallet/persister.rs @@ -183,34 +183,43 @@ impl WalletPersister { /// otherwise. Those a flush failed for are kept, with every one kept /// before, and noted by the next call whose flush succeeds: an empty one /// retries them. Their records, and so the pairs they hold, stay until - /// then. + /// then. The host's flush runs with none of the persister's locks held, + /// so a call during it, from another thread or the flush itself, neither + /// waits nor loses what it adds. #[cfg(feature = "shielded")] pub(crate) fn note_read_back_once_durable( &self, commitments: impl IntoIterator, ) { - let mut unflushed = self - .unflushed_read_back_private_entries - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - unflushed.extend(commitments); - if unflushed.is_empty() { + let pending = { + let mut unflushed = self.unflushed_read_back_entries(); + unflushed.extend(commitments); + std::mem::take(&mut *unflushed) + }; + if pending.is_empty() { return; } if !self.store_commits_inline() { if let Err(error) = self.flush() { tracing::warn!( %error, - kept = unflushed.len(), + kept = pending.len(), "private requests read back are not durably recorded; their journal \ records stay until a later flush succeeds" ); + // With any added meanwhile. + self.unflushed_read_back_entries().extend(pending); return; } } - let durable = std::mem::take(&mut *unflushed); - drop(unflushed); - self.note_read_back_private_entries(durable); + self.note_read_back_private_entries(pending); + } + + #[cfg(feature = "shielded")] + fn unflushed_read_back_entries(&self) -> std::sync::MutexGuard<'_, BTreeSet<[u8; 32]>> { + self.unflushed_read_back_private_entries + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) } /// Take the commitments [`Self::note_read_back_private_entries`] noted: @@ -544,6 +553,10 @@ impl PlatformWalletPersistence for NoPlatformPersistence { #[cfg(test)] mod tests { use super::*; + #[cfg(feature = "shielded")] + use std::sync::{mpsc, OnceLock}; + #[cfg(feature = "shielded")] + use std::time::Duration; /// `persists_durably` is a fail-closed security capability: an /// implementation that does NOT explicitly attest durability must read as @@ -571,4 +584,64 @@ mod tests { assert!(!BareMinimum.persists_durably()); assert!(!NoPlatformPersistence.persists_durably()); } + + /// The host's flush runs with no lock of the persister held: a flush that + /// notes another read-back commitment through the same persister, as a + /// reentrant backend or another thread would, completes, and nothing a + /// failed flush was given is lost: the next flush that succeeds notes + /// them all. + #[cfg(feature = "shielded")] + #[test] + fn should_flush_read_back_notices_without_holding_their_lock() { + struct Reentrant { + persister: OnceLock, + flush_fails: AtomicBool, + reentered: AtomicBool, + } + impl PlatformWalletPersistence for Reentrant { + fn store( + &self, + _wallet_id: WalletId, + _changeset: PlatformWalletChangeSet, + ) -> Result<(), PersistenceError> { + Ok(()) + } + fn flush(&self, _wallet_id: WalletId) -> Result<(), PersistenceError> { + // The first flush notes another commitment before it ends. + if !self.reentered.swap(true, Ordering::SeqCst) { + if let Some(persister) = self.persister.get() { + persister.note_read_back_once_durable([[2; 32]]); + } + } + if self.flush_fails.load(Ordering::SeqCst) { + return Err(PersistenceError::backend("the disk is full")); + } + Ok(()) + } + fn load(&self) -> Result { + Ok(ClientStartState::default()) + } + } + + let (done, finished) = mpsc::channel(); + std::thread::spawn(move || { + let backend = Arc::new(Reentrant { + persister: OnceLock::new(), + flush_fails: AtomicBool::new(true), + reentered: AtomicBool::new(false), + }); + let persister = WalletPersister::new([0; 32], Arc::clone(&backend) as _); + let _ = backend.persister.set(persister.clone()); + persister.note_read_back_once_durable([[1; 32]]); + let noted_on_failure = persister.has_read_back_private_entries(); + backend.flush_fails.store(false, Ordering::SeqCst); + persister.note_read_back_once_durable([]); + let _ = done.send((noted_on_failure, persister.take_read_back_private_entries())); + }); + let (noted_on_failure, noted) = finished + .recv_timeout(Duration::from_secs(10)) + .expect("the flushes complete"); + assert!(!noted_on_failure, "a failed flush notes nothing"); + assert_eq!(noted, BTreeSet::from([[1; 32], [2; 32]]), "nothing lost"); + } } From d4d55bdad20ba6bd4df54c2967f45bb46560d4f7 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Tue, 10 Nov 2026 13:29:57 +0700 Subject: [PATCH 222/269] fix(platform-wallet): raise a private store read's height only by pages read with its keys The private sync noted every page's proved height into each identity's read height before checking that the pass's keys were still the identity's, or that its cursor had not been rewound under the pass. A pass started with older keys and overtaken by a key update, which rewinds the read and keeps the height it rested on as its floor, then raised the read height past that floor with pages the new keys never scanned. A later read from a node behind the store already seen then counted as read to the end. A page's height is now noted only once the identity's cursor advanced over it with its current keys, with neither its ingest failed nor its cursor rewound under the pass. Co-Authored-By: Claude Opus 5.5 --- .../network/private_contact_requests.rs | 128 +++++++++++++++++- 1 file changed, 125 insertions(+), 3 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 6fd8d910c69..4d51350ce8a 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 @@ -2045,9 +2045,6 @@ impl DashPayView<'_, B> { else { continue; }; - // Read for it, whatever its ingest does: the height - // its read rests on only rises. - managed.note_private_store_page_read(page_height); if held.contains(&identity_id) && !identity.failed { identity.failed = true; report.unpersisted_identities.push(identity_id); @@ -2136,6 +2133,17 @@ impl DashPayView<'_, B> { } let before = managed.dashpay().private_contact_requests_position(); identity.advance(managed, read.next_position); + // Read with its keys, from its own cursor: the + // height its read rests on rises. Not by a page of + // a pass whose keys changed under it, or whose + // cursor was rewound under it, which counts for + // nothing: the rewind kept the height the read + // rested on as the floor a read since must reach + // (`ManagedIdentity::rewind_private_store_read`), + // and such a page would pass it. + if !identity.failed && !identity.rewound { + managed.note_private_store_page_read(page_height); + } let after = managed.dashpay().private_contact_requests_position(); // Stored once it moved, once the last private // payment it held settled, or once its marker @@ -7193,6 +7201,120 @@ mod tests { .await; } + /// A pass whose keys changed under it raises nothing its read rests on. + /// Bob read the store at H with keys missing the decryption key another + /// device added, to which Alice's request at position 1 is sealed. A pass + /// started with those keys is overtaken by the key update, which rewinds + /// his read and keeps H as its floor; its page counts for nothing. So a + /// read with the new keys from a node a block behind, which holds only + /// the entry before hers, does not count as read to the end. + #[tokio::test] + async fn should_not_let_a_pass_with_replaced_keys_raise_the_read_height() { + const H: u64 = 20; + 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![]).await; + let filler = vec![0xA0; 589]; + let page = |start: u64, entries: Vec>, total: u64| { + let mut page = store_page(start, entries); + page.total_count = total; + page + }; + let contract = dashpay_contract().expect("bundled DashPay"); + let query = |start| { + PrivateDocumentEntriesQuery::new( + contract.id(), + PRIVATE_CONTACT_REQUEST_DOCUMENT_TYPE_NAME, + start, + ) + }; + let sdk = (*bob_wallet.identity().sdk).clone(); + sdk.clone().mock().set_response_height(H); + let serve = |pages: Vec<(u64, PrivateDocumentEntries, u64)>| { + let mut sdk = sdk.clone(); + async move { + let mut mock = sdk.mock(); + for (start, page, height) in pages { + mock.remove_fetch_expectation::(query(start)) + .await; + mock.expect_fetch(query(start), Some(page)) + .await + .expect("page expectation"); + mock.set_response_height_for::(query(start), height); + } + } + }; + serve(vec![ + (0, page(0, vec![filler.clone(), entry], 2), H), + (2, page(2, vec![], 2), H), + ]) + .await; + let view = bob_wallet.identity().dashpay(); + view.sync_private_contact_requests(&bob_crypto, 4) + .await + .expect("Bob reads the store at H with his older keys"); + assert!(store_read(&bob_wallet, BOB).await); + + // A pass from his cursor, overtaken by the key update before it scans. + 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"); + managed.identity = bob; + managed.reconcile_private_scan_keys(); + }) + }) + }); + view.sync_private_contact_requests(&updating, 4) + .await + .expect("the pass with the older keys"); + managed(&bob_wallet, BOB, |managed| { + assert!( + !managed.dashpay().private_contact_requests_read(), + "rewound" + ); + assert_eq!( + managed.dashpay().private_store_read_height(), + None, + "its page counts for nothing" + ); + }) + .await; + + // A read with the new keys, from a node a block behind. + serve(vec![ + (0, page(0, vec![filler], 1), H - 1), + (1, page(1, vec![], 1), H - 1), + ]) + .await; + view.sync_private_contact_requests(&bob_crypto, 4) + .await + .expect("the read from behind"); + managed(&bob_wallet, BOB, |managed| { + assert_eq!(managed.dashpay().private_store_read_height(), Some(H - 1)); + assert!( + !managed.dashpay().private_contact_requests_read(), + "not read to the end of the store already seen" + ); + assert!(managed.dashpay().incoming_request(&alice.id()).is_none()); + }) + .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. From 94d81d16f632c5cf43547806d20ceb8226f029b0 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Tue, 10 Nov 2026 13:30:20 +0700 Subject: [PATCH 223/269] fix(platform-wallet): hold a re-created wallet's journaled private payment pairs A wallet's removal purges its notes and saved spends but keeps the shielded store's private payment journal, as the payments it records may still execute. A wallet re-created under the same id got a fresh persister with no journal hold and was published without reading the journal, and configure_shielded restored only wallets already held. With its identity loaded again from Platform, a public request before a bind could publish a pair a private payment still holds. Registration now reads the journal before the wallet is published when a shielded store is configured: restored, the pairs are held through the wallet's journal mirror whether or not their senders are loaded yet, and a busy store or a failed read holds the wallet's public contact requests (PrivateContactPaymentsNotRestored) until a restoration succeeds. With no store configured, a wallet re-created under an id the manager held before is held the same way until configure_shielded or a bind reads the journal. The manager's in-broadcast fence map, which keeps an entry for every id it ever registered, tells such a re-creation from a first registration, which is not held. configure_shielded, and a load with a store configured, now restore every wallet that is held or whose id the journal has records for. Co-Authored-By: Claude Opus 5.5 --- .../rs-platform-wallet/src/manager/load.rs | 2 +- .../rs-platform-wallet/src/manager/mod.rs | 28 ++-- .../src/manager/wallet_lifecycle.rs | 35 +++++ .../network/private_contact_requests.rs | 128 ++++++++++++++++++ .../src/wallet/shielded/coordinator.rs | 12 ++ 5 files changed, 194 insertions(+), 11 deletions(-) diff --git a/packages/rs-platform-wallet/src/manager/load.rs b/packages/rs-platform-wallet/src/manager/load.rs index a58b86ce8dd..37f25dbe124 100644 --- a/packages/rs-platform-wallet/src/manager/load.rs +++ b/packages/rs-platform-wallet/src/manager/load.rs @@ -699,7 +699,7 @@ impl PlatformWalletManager

{ // otherwise `configure_shielded` does. #[cfg(feature = "shielded")] if let Some(coordinator) = self.shielded_coordinator().await { - self.restore_held_private_contact_payments(&coordinator) + self.restore_private_contact_payment_holds(&coordinator) .await; } diff --git a/packages/rs-platform-wallet/src/manager/mod.rs b/packages/rs-platform-wallet/src/manager/mod.rs index 49a14217c61..759cd5d9cfd 100644 --- a/packages/rs-platform-wallet/src/manager/mod.rs +++ b/packages/rs-platform-wallet/src/manager/mod.rs @@ -904,8 +904,9 @@ impl PlatformWalletManager

{ /// /// 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 + /// whose public contact requests a load or a re-creation held for them, + /// or whose id the store's journal keeps payments of, with no keys + /// ([`Self::restore_private_contact_payment_holds`]). 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`]. @@ -930,7 +931,7 @@ impl PlatformWalletManager

{ if existing.db_path() == db_path.as_path() { let existing = Arc::clone(existing); drop(slot); - self.restore_held_private_contact_payments(&existing).await; + self.restore_private_contact_payment_holds(&existing).await; return Ok(()); } return Err(crate::error::PlatformWalletError::ShieldedStoreError( @@ -984,27 +985,34 @@ impl PlatformWalletManager

{ ))); *slot = Some(Arc::clone(&coordinator)); drop(slot); - self.restore_held_private_contact_payments(&coordinator) + self.restore_private_contact_payment_holds(&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 + /// manager holds whose public contact requests wait for them, or whose id + /// the store's journal keeps payments of /// ([`PlatformWallet::restore_private_contact_payments`]): a wallet loaded - /// after binding a shielded account in an earlier process, or with an + /// after binding a shielded account in an earlier process, 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. + /// ([`PlatformWallet::private_payments_held`]), or re-created under the id + /// of one removed while its payments were unresolved, which only the + /// journal shows. 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( + pub(crate) async fn restore_private_contact_payment_holds( &self, coordinator: &Arc, ) { let wallets: Vec> = self.wallets.load().values().cloned().collect(); for wallet in wallets { - if !wallet.private_payments_held().await { + if !wallet.private_payments_held().await + && !coordinator + .journals_private_payments_of(wallet.wallet_id()) + .await + { continue; } if let Err(error) = wallet.restore_private_contact_payments(coordinator).await { diff --git a/packages/rs-platform-wallet/src/manager/wallet_lifecycle.rs b/packages/rs-platform-wallet/src/manager/wallet_lifecycle.rs index 02a91ece586..4e1546d166e 100644 --- a/packages/rs-platform-wallet/src/manager/wallet_lifecycle.rs +++ b/packages/rs-platform-wallet/src/manager/wallet_lifecycle.rs @@ -203,6 +203,14 @@ impl PlatformWalletManager

{ // so the replacement inherits them rather than starting clean. A first // registration finds no entry and gets an empty map. let registration_wallet_id = wallet.compute_wallet_id(); + // Whether a generation of this manager held the id before: the fence + // map keeps an entry for every id it registered, removed or not. + #[cfg(feature = "shielded")] + let recreated = self + .in_broadcast_fences + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .contains_key(®istration_wallet_id); let generation = Arc::new(WalletGeneration::with_fences( self.in_broadcast_fences_for(®istration_wallet_id), )); @@ -722,6 +730,33 @@ impl PlatformWalletManager

{ let platform_wallet = Arc::new(platform_wallet); + // Before the wallet is published: the shielded store's private payment + // journal outlives a wallet's removal, so a wallet re-created under the + // id of one removed while its private payments were unresolved holds + // their pairs only once the journal is read. With a store configured, + // it is read now: restored, the pairs are held through the wallet's + // journal mirror whether or not their sender identities are loaded + // yet; a store busy or a read that fails holds the wallet's public + // contact requests until a restoration succeeds. Without one, a wallet + // re-created under an id this manager held is held until + // `configure_shielded` or a bind reads the journal. A first + // registration is not held, so a wallet with no journal is never held + // for want of a store: payments an earlier process left under its id + // are held once `configure_shielded` reads the journal, which it does + // for every wallet whose id has records. + #[cfg(feature = "shielded")] + match self.shielded_coordinator().await { + Some(coordinator) => { + platform_wallet + .restore_private_payments_or_defer(&coordinator) + .await; + } + None if recreated => platform_wallet + .persister() + .hold_until_private_payments_restored(), + None => {} + } + // Register the PlatformWallet handle. `rcu` publishes a new map // snapshot; the closure can rerun under a concurrent-writer // retry, so it must stay pure map manipulation. 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 4d51350ce8a..b80e424b27b 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 @@ -9139,6 +9139,134 @@ mod tests { assert!(held.is_empty(), "absent"); } + /// A wallet removed while a private payment of its identity may still + /// execute keeps that payment in the shielded store's journal, which + /// outlives the removal, and a wallet re-created under its id holds the + /// payment's pair from it. With the store configured, the journal is read + /// as the wallet registers: once its identity is loaded, its public + /// request is refused as pending. With none configured yet, the wallet is + /// held until one is: refused as not restored, then held as pending once + /// `configure_shielded` reads the journal. Configuring the store also + /// reads it for a wallet registered before, which no load held. + #[cfg(feature = "shielded")] + #[tokio::test] + async fn should_hold_the_journaled_pairs_of_a_wallet_re_created_under_its_id() { + use crate::wallet::shielded::PrivatePaymentRecord; + + 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 alice_seed = seed(ALICE_PHRASE); + let (alice_id, bob_id) = (alice.id(), bob.id()); + for configured_first in [true, false] { + let dir = tempfile::tempdir().expect("temporary shielded store"); + let path = dir.path().join("tree.sqlite"); + let manager = PlatformWalletManager::new( + Arc::new( + dashpay_sdk(&[&alice, &bob], vec![], PlatformVersion::latest(), None).await, + ), + Arc::new(NoPersistence), + Arc::new(NoopEventHandler), + ); + let create = || { + manager.create_wallet_from_seed_bytes( + Network::Testnet, + &alice_seed, + WalletAccountCreationOptions::Default, + Some(0), + ) + }; + let first = create().await.expect("the wallet"); + let wallet_id = first.wallet_id(); + // A private payment from Alice to Bob that may still execute. + FileBackedShieldedStore::open_path(&path, 100) + .expect("the store") + .journal_private_payments( + wallet_id, + &[PrivatePaymentRecord { + account_index: 0, + activity_id: [0x71; 32], + anchor: [0x7C; 32], + anchor_height: Some(1), + commitment: [0x72; 32], + payment: PrivateContactPayment { + sender: alice.id().to_buffer(), + recipient: bob.id().to_buffer(), + account_reference: 1, + }, + nullifiers: vec![[0x12; 32]], + }], + ) + .expect("journaled"); + if configured_first { + manager + .configure_shielded(&path) + .await + .expect("the shielded store is configured"); + assert!( + first + .persister() + .private_pair_journaled(&alice.id(), &bob.id()), + "configuring reads the journal of a wallet no load held" + ); + } + + manager.remove_wallet(&wallet_id).await.expect("removed"); + let recreated = create().await.expect("re-created under its id"); + assert_eq!(recreated.wallet_id(), wallet_id); + { + let iw = recreated.identity(); + let mut wm = iw.wallet_manager.write().await; + wm.get_wallet_info_mut(&wallet_id) + .expect("wallet info") + .identity_manager + .add_identity(alice.clone(), 0, wallet_id, &iw.persister) + .expect("Alice, loaded again"); + } + let view = recreated.identity().dashpay(); + let public = || { + view.send_contact_request_with_external_signer( + &alice_id, + &bob_id, + None, + AutoAcceptProofSource::None, + &alice_signer, + &alice_crypto, + ) + }; + if !configured_first { + let held = public().await; + assert!( + matches!( + held, + Err(PlatformWalletError::PrivateContactPaymentsNotRestored) + ), + "{held:?}" + ); + manager + .configure_shielded(&path) + .await + .expect("the shielded store is configured"); + } + assert!( + recreated + .persister() + .private_pair_journaled(&alice.id(), &bob.id()), + "held from the journal, configured first: {configured_first}" + ); + let pending = public().await; + assert!( + matches!( + pending, + Err(PlatformWalletError::PrivateContactRequestPending(recipient)) + if recipient == bob.id() + ), + "configured first: {configured_first}: {pending:?}" + ); + } + } + /// A private payment that never executes is proven expired once its /// anchor is pruned and its entry is absent from the store: the /// coordinator discharges its journal record, the pair is freed, a retry diff --git a/packages/rs-platform-wallet/src/wallet/shielded/coordinator.rs b/packages/rs-platform-wallet/src/wallet/shielded/coordinator.rs index 6090fb5e253..86a812ba70c 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/coordinator.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/coordinator.rs @@ -486,6 +486,18 @@ impl NetworkShieldedCoordinator { &self.db_path } + /// Whether the store's private payment journal keeps a record of + /// `wallet_id`'s, or does not read: a wallet re-created under an id keeps + /// the unresolved private payments of the one removed under it, which only + /// the journal shows (`PlatformWalletManager::restore_private_contact_payment_holds`). + pub(crate) async fn journals_private_payments_of(&self, wallet_id: WalletId) -> bool { + self.store + .read() + .await + .private_payment_journal(wallet_id) + .map_or(true, |records| !records.is_empty()) + } + /// Reference to the shared store. The full sync / spend /// surfaces land on the coordinator itself; this accessor /// exists for tests and for migration scaffolding in Phase 1. From 23ee059e64257d00d062cca0a7b03455c8f98c72 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Tue, 10 Nov 2026 15:51:28 +0700 Subject: [PATCH 224/269] fix(platform-wallet): read the store before every contact request and hold wallets until their journal is read Before every contact request or acceptance, public or private, the wallet now reads DashPay's private contact request store from the cursor until its own pass reaches the store's end, then fetches the identity with a proof and checks key coverage. Previously a flag set once per load ended the read. The claim of a public pair re-checks the read under the lifecycle gate. The direct private send and the private accept use the same check, so a reply another device sealed under a key not yet folded in is adopted, not paid again. A read that does not finish returns the new PrivateContactStoreNotRead (FFI 74) instead of 71. A pass reaches the store's end only at the highest total any of its pages proved. A later page from a node behind no longer ends it, and a stream that stops short of that total leaves the pass incomplete. A rewound read counts only when the page that reached the end was proved at or above the floor (its lowest height). The highest page height stays the bound for key coverage. In a build with the shielded feature, a wallet published (created or loaded) with no shielded store configured is held (72) when its persister keeps the private scan state, until the journal is read. Only such a persister lets a private payment be paid. This replaces the in-memory re-creation test, which missed a new manager or process and held a retried first registration. With a store configured, registration restores before publishing, and a store configured in between is caught once the wallet is published. configure_shielded never waits on the store when called again: it restores the wallets still held without waiting. A contact request retries a failed or deferred restoration once, without waiting, before it is refused with 72. Readiness reports the last restoration's failure, counts payments the journal keeps, and applies discharges first. DashPayView::private_pair_held reads a pair as a send would. Journal records are now format 4. Each carries the SHA-256 of its whole entry and the sender's store position at payment. The expiry's read matches whole entries, not a 32-byte prefix anyone can append, and starts at the lowest position. Format 3 rows read with neither. FFI: 74 (ErrorPrivateContactStoreNotRead), 75 (ErrorSdkPinnedBelowProtocolVersion, split from 70), 68 and 74 kept through map_spend_result, and private_payments_restore_failed on the readiness struct. Co-Authored-By: Claude Opus 5.5 --- .../src/contact_request.rs | 56 +- packages/rs-platform-wallet-ffi/src/error.rs | 191 ++- .../src/shielded_send.rs | 5 + packages/rs-platform-wallet/src/error.rs | 27 +- .../rs-platform-wallet/src/manager/load.rs | 23 +- .../rs-platform-wallet/src/manager/mod.rs | 102 +- .../src/manager/wallet_lifecycle.rs | 42 +- .../identity/network/contact_requests.rs | 32 +- .../network/private_contact_requests.rs | 1176 +++++++++++++---- .../managed_identity/contact_requests.rs | 25 +- .../src/wallet/persister.rs | 76 ++ .../src/wallet/platform_wallet.rs | 75 +- .../src/wallet/shielded/coordinator.rs | 68 +- .../src/wallet/shielded/file_store.rs | 57 +- .../src/wallet/shielded/mod.rs | 4 +- .../src/wallet/shielded/operations.rs | 38 +- .../src/wallet/shielded/store.rs | 123 +- 17 files changed, 1667 insertions(+), 453 deletions(-) diff --git a/packages/rs-platform-wallet-ffi/src/contact_request.rs b/packages/rs-platform-wallet-ffi/src/contact_request.rs index e1c955270f2..b7d819fcedd 100644 --- a/packages/rs-platform-wallet-ffi/src/contact_request.rs +++ b/packages/rs-platform-wallet-ffi/src/contact_request.rs @@ -233,22 +233,30 @@ pub extern "C" fn contact_request_destroy(request_handle: Handle) -> PlatformWal #[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. + /// Whether the wallet's private payments are restored for the identity. + /// While false, its contact requests and acceptances, public or private, + /// are refused with `ErrorPrivateContactPaymentsNotRestored` (72) until + /// the shielded store is configured or the wallet's shielded account + /// bound, or a contact request's own retry of a failed restoration + /// succeeds; 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. + /// its end for the identity with its current keys. Every contact request + /// and acceptance reads it again from the cursor before it is decided, and + /// is refused with `ErrorPrivateContactStoreNotRead` (74) 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`. + /// unresolved: the marker its private scan state stores, or a payment the + /// shielded store's journal keeps unresolved. A journal record discharged + /// since is applied before the read. pub unresolved_private_payment: bool, + /// Whether the last restoration of the wallet's private payments failed, + /// while `private_payments_restored` is false for it: then 72 holds until + /// a restoration succeeds, not only until one runs. The reason is in the + /// wallet's log; an export for it is left to the FFI PR. + pub private_payments_restore_failed: bool, } impl From for PrivateContactReadinessFFI { @@ -257,6 +265,7 @@ impl From for PrivateContactReadinessFFI { private_payments_restored: readiness.private_payments_restored, private_store_read: readiness.private_store_read, unresolved_private_payment: readiness.unresolved_private_payment, + private_payments_restore_failed: readiness.private_payments_restore_failure.is_some(), } } } @@ -310,6 +319,7 @@ mod tests { private_payments_restored: true, private_store_read: false, unresolved_private_payment: false, + private_payments_restore_failure: None, }); assert_eq!( restored, @@ -317,12 +327,14 @@ mod tests { private_payments_restored: true, private_store_read: false, unresolved_private_payment: false, + private_payments_restore_failed: false, } ); let read = PrivateContactReadinessFFI::from(PrivateContactReadiness { private_payments_restored: false, private_store_read: true, unresolved_private_payment: false, + private_payments_restore_failure: None, }); assert_eq!( read, @@ -330,12 +342,14 @@ mod tests { private_payments_restored: false, private_store_read: true, unresolved_private_payment: false, + private_payments_restore_failed: false, } ); let unresolved = PrivateContactReadinessFFI::from(PrivateContactReadiness { private_payments_restored: false, private_store_read: false, unresolved_private_payment: true, + private_payments_restore_failure: None, }); assert_eq!( unresolved, @@ -343,6 +357,22 @@ mod tests { private_payments_restored: false, private_store_read: false, unresolved_private_payment: true, + private_payments_restore_failed: false, + } + ); + let failed = PrivateContactReadinessFFI::from(PrivateContactReadiness { + private_payments_restored: false, + private_store_read: false, + unresolved_private_payment: false, + private_payments_restore_failure: Some("the journal does not read".to_string()), + }); + assert_eq!( + failed, + PrivateContactReadinessFFI { + private_payments_restored: false, + private_store_read: false, + unresolved_private_payment: false, + private_payments_restore_failed: true, } ); } @@ -377,6 +407,7 @@ mod tests { private_payments_restored: true, private_store_read: true, unresolved_private_payment: true, + private_payments_restore_failed: false, }; let unknown = unsafe { platform_wallet_get_private_contact_readiness( @@ -445,6 +476,7 @@ mod tests { private_payments_restored: true, private_store_read: false, unresolved_private_payment: false, + private_payments_restore_failed: false, } ); // The identity's own state, not a fresh identity's: once a private @@ -485,6 +517,7 @@ mod tests { private_payments_restored: true, private_store_read: true, unresolved_private_payment: false, + private_payments_restore_failed: false, } ); with_identity(&|managed| { @@ -503,6 +536,7 @@ mod tests { private_payments_restored: false, private_store_read: true, unresolved_private_payment: true, + private_payments_restore_failed: false, } ); let stranger = unsafe { diff --git a/packages/rs-platform-wallet-ffi/src/error.rs b/packages/rs-platform-wallet-ffi/src/error.rs index 9953a3afab7..d393f88b1a9 100644 --- a/packages/rs-platform-wallet-ffi/src/error.rs +++ b/packages/rs-platform-wallet-ffi/src/error.rs @@ -660,40 +660,48 @@ pub enum PlatformWalletFFIResultCode { /// 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: a refusal - /// from the network's version lifts when the network upgrades, one from a - /// pin only once the host raises or removes the pin (and the network - /// supports the required version too); a network upgrade alone does not - /// lift it. 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. + /// DashPay's private contact request store before protocol version 15). + /// Expected on such a network, and lifts when the network upgrades. A + /// refusal from an SDK pinned below the required version is + /// [`Self::ErrorSdkPinnedBelowProtocolVersion`] (75). 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 /// 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. + /// reserved it), or was paid for and is not yet read back from the store, + /// or a private payment the shielded store's journal keeps unresolved + /// holds the pair. Nothing new was paid. Pending, not failed: do not + /// submit it again. A later private contact request sync + /// (`platform_wallet_sync_private_contact_requests`) settles it once the + /// entry is read back, and the shielded sync once the payment provably + /// never executes. 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. + /// wallet, not one contact, is held, as its unconfirmed private payments + /// may hold pairs no reservation shows yet, which only a restoration from + /// the shielded store's journal shows. No contact request, public or + /// private, is sent or accepted, nothing signed or broadcast, until they + /// are restored. In a build with the `shielded` feature: the wallet was + /// published (created or loaded) while no shielded store was configured, + /// its persister declaring the private scan state (bit 15); it was loaded + /// after binding a shielded account in an earlier process; or its last + /// restoration failed, or waits for a busy store. In every build: the + /// identity was loaded with its private scan state marking a private + /// payment that may be unresolved. Configuring the shielded store restores + /// them with no keys, as binding the wallet's shielded account does, and a + /// contact request retries a failed restoration once without waiting; a + /// private sync does not. A host whose persister declares the private + /// scan state configures the shielded store when the manager is + /// activated, before loading or creating wallets: until it does, and + /// while a failed configure is not retried, 72 holds. /// `platform_wallet_get_private_contact_readiness` reads whether they are - /// restored. A build without the `shielded` feature never restores them. + /// restored and whether the last restoration failed. A build without the + /// `shielded` feature never restores them. ErrorPrivateContactPaymentsNotRestored = 72, /// Maps `PlatformWalletError::PrivateContactRecoveryUnsupported`: the @@ -707,6 +715,23 @@ pub enum PlatformWalletFFIResultCode { /// retryable as it is: a host persisting the scan state declares both. ErrorPrivateContactRecoveryUnsupported = 73, + /// Maps `PlatformWalletError::PrivateContactStoreNotRead`: DashPay's + /// private contact request store was not read to its end for the sending + /// identity, with the keys Platform proves it holds, before a contact + /// request or acceptance, public or private, was decided. Nothing was + /// reserved, sealed, paid or broadcast. Calling again continues the read + /// from where it stopped: a node behind the store the wallet already saw, + /// or keys that kept changing, stop it. Not a pending request (71). + ErrorPrivateContactStoreNotRead = 74, + + /// The SDK refused the request without sending it because the SDK itself + /// is pinned below the protocol version the request needs + /// (`dash_sdk::Error::ProtocolVersionNotActive` with `pinned`), whatever + /// the network runs. A network upgrade does not lift it: only raising or + /// removing the pin does. A network proven below the version is + /// [`Self::ErrorProtocolVersionNotActive`] (70). + ErrorSdkPinnedBelowProtocolVersion = 75, + /// The named thing does not exist. /// /// Originally (and still mostly) the code for every `Option` returned as an @@ -1172,6 +1197,11 @@ impl From for PlatformWalletFFIResult { PlatformWalletError::PrivateContactRecoveryUnsupported => { PlatformWalletFFIResultCode::ErrorPrivateContactRecoveryUnsupported } + // The private store read before deciding did not finish: nothing + // reserved or sent, and calling again continues it. + PlatformWalletError::PrivateContactStoreNotRead(..) => { + PlatformWalletFFIResultCode::ErrorPrivateContactStoreNotRead + } // 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 @@ -1288,13 +1318,17 @@ impl From for PlatformWalletFFIResult { // (a context provider configured so it cannot give quorum keys: // this SDK's own setup), as neither says the network runs an // earlier version. + // A pin below the required version is the host's own setting, which + // only it can lift: its own code, whatever the network's query + // showed. + PlatformWalletError::Sdk(dash_sdk::Error::ProtocolVersionNotActive { + pinned: true, + .. + }) => PlatformWalletFFIResultCode::ErrorSdkPinnedBelowProtocolVersion, PlatformWalletError::Sdk(dash_sdk::Error::ProtocolVersionNotActive { - cause, - pinned, + cause: None, .. - }) if *pinned || cause.is_none() => { - PlatformWalletFFIResultCode::ErrorProtocolVersionNotActive - } + }) => PlatformWalletFFIResultCode::ErrorProtocolVersionNotActive, _ => PlatformWalletFFIResultCode::ErrorUnknown, }; let result = PlatformWalletFFIResult::err(code, error.to_string()); @@ -2858,7 +2892,9 @@ mod private_contact_request_error_tests { /// 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. + /// the version could not be confirmed, is still the network failing; and + /// an SDK pinned below the version, which only the host lifts, has its own + /// code. #[test] fn should_tell_a_store_the_network_does_not_serve_yet_by_its_own_code() { let refused = |cause: Option>| { @@ -2886,24 +2922,30 @@ mod private_contact_request_error_tests { assert_eq!(transient.code, PlatformWalletFFIResultCode::ErrorUnknown); // A pinned SDK refuses whatever its query for the network's version - // showed: the lasting code, with a cause too. - let pinned = PlatformWalletFFIResult::from(PlatformWalletError::Sdk( - dash_sdk::Error::ProtocolVersionNotActive { - method: "get_private_document_entries", - required: 15, - feature: None, - network: 14, - pinned: true, - cause: Some(Box::new(dash_sdk::Error::Generic( - "no node answered".to_string(), - ))), - }, - )); - assert_eq!( - pinned.code, - PlatformWalletFFIResultCode::ErrorProtocolVersionNotActive - ); - assert_eq!(pinned.code as u32, 70); + // showed, with or without a cause: its own code, which a network + // upgrade does not lift. + for cause in [ + None, + Some(Box::new(dash_sdk::Error::Generic( + "no node answered".to_string(), + ))), + ] { + let pinned = PlatformWalletFFIResult::from(PlatformWalletError::Sdk( + dash_sdk::Error::ProtocolVersionNotActive { + method: "get_private_document_entries", + required: 15, + feature: None, + network: 14, + pinned: true, + cause, + }, + )); + assert_eq!( + pinned.code, + PlatformWalletFFIResultCode::ErrorSdkPinnedBelowProtocolVersion + ); + assert_eq!(pinned.code as u32, 75); + } // A provider that cannot give quorum keys at all is this SDK's setup, // not the network's version: the catch-all too. @@ -3015,4 +3057,53 @@ mod private_contact_request_error_tests { ); } } + + /// The private store read that did not finish before a contact request + /// was decided reaches the host by its own code, not as a pending request, + /// through the blanket conversion and the spend contract alike; and so + /// does a pair whose requests went privately, which the in-kind accept + /// raises, beside the codes the spend contract already kept. + #[test] + fn should_report_an_unfinished_private_store_read_by_its_own_code() { + let not_read = || PlatformWalletError::PrivateContactStoreNotRead([7; 32].into()); + let result = PlatformWalletFFIResult::from(not_read()); + assert_eq!( + result.code, + PlatformWalletFFIResultCode::ErrorPrivateContactStoreNotRead + ); + assert_eq!(result.code as u32, 74); + #[cfg(feature = "shielded")] + { + let through_spend = |error: PlatformWalletError| { + map_spend_result(Err(error), "private contact request").code as u32 + }; + assert_eq!(through_spend(not_read()), 74); + assert_eq!( + through_spend(PlatformWalletError::PrivateContactRequestNeedsPrivateReply( + [7; 32].into() + )), + 68 + ); + assert_eq!( + through_spend(PlatformWalletError::PrivateContactRequestPending( + [7; 32].into() + )), + 71 + ); + assert_eq!( + through_spend(PlatformWalletError::PrivateContactPaymentsNotRestored), + 72 + ); + assert_eq!( + through_spend(PlatformWalletError::PrivateContactRecoveryUnsupported), + 73 + ); + assert_eq!( + through_spend(PlatformWalletError::WalletRemovedBeforeSend( + "removed".to_string() + )), + 98 + ); + } + } } diff --git a/packages/rs-platform-wallet-ffi/src/shielded_send.rs b/packages/rs-platform-wallet-ffi/src/shielded_send.rs index ac60ad885d5..c67edeafcdc 100644 --- a/packages/rs-platform-wallet-ffi/src/shielded_send.rs +++ b/packages/rs-platform-wallet-ffi/src/shielded_send.rs @@ -634,7 +634,12 @@ pub(crate) fn map_spend_result( // read before any payment keeps the blanket conversion's split: a // lasting refusal is `ErrorProtocolVersionNotActive`, a transient one // the catch-all. + // So does a private store read that did not finish before deciding + // (nothing reserved or sent), and a pair whose requests came or went + // privately, which the in-kind accept can raise. Err(e @ PlatformWalletError::PrivateContactRequestPending(..)) + | Err(e @ PlatformWalletError::PrivateContactStoreNotRead(..)) + | Err(e @ PlatformWalletError::PrivateContactRequestNeedsPrivateReply(..)) | Err(e @ PlatformWalletError::PrivateContactPaymentsNotRestored) | Err(e @ PlatformWalletError::PrivateContactRecoveryUnsupported) | Err(e @ PlatformWalletError::WalletRemovedBeforeSend(..)) diff --git a/packages/rs-platform-wallet/src/error.rs b/packages/rs-platform-wallet/src/error.rs index b6a9b15a716..2d7000483f8 100644 --- a/packages/rs-platform-wallet/src/error.rs +++ b/packages/rs-platform-wallet/src/error.rs @@ -680,20 +680,31 @@ 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, 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, 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. + /// paid for and is not yet read back from DashPay's store, or a private + /// payment the shielded store's journal keeps unresolved holds the pair: + /// no other one is sent, privately or publicly, until the private sync + /// reads it back or its payment provably never executes, as the store + /// keeps every entry appended. Pending, not failed: it is not submitted + /// again. #[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), + /// DashPay's private contact request store was not read to its end for + /// this identity with the keys Platform proves it holds, before a contact + /// request from it, public or private, or an acceptance, was decided: a + /// request in the store unread could hold the pair. Nothing was reserved, + /// sealed, paid or broadcast. Calling again goes on with the read from + /// where it stopped (a node behind the store already seen, or keys that + /// kept changing, stop it). + #[error( + "DashPay's private contact request store was not read to its end for {0}; nothing was \ + sent, and trying again continues the read" + )] + PrivateContactStoreNotRead(Identifier), + /// 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 diff --git a/packages/rs-platform-wallet/src/manager/load.rs b/packages/rs-platform-wallet/src/manager/load.rs index 37f25dbe124..7c37ee0525a 100644 --- a/packages/rs-platform-wallet/src/manager/load.rs +++ b/packages/rs-platform-wallet/src/manager/load.rs @@ -569,8 +569,17 @@ impl PlatformWalletManager

{ // 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. + // So does one whose persister keeps the private scan state: only it + // lets a private payment be paid, so only its wallet can have one + // the journal keeps that no marker shows. Restored below once the + // load is published when a store is configured, by + // `configure_shielded` otherwise. #[cfg(feature = "shielded")] - if bound_shielded.contains(&wallet_id) { + if bound_shielded.contains(&wallet_id) + || platform_wallet + .persister() + .persists_private_contact_scan_state() + { platform_wallet .persister() .hold_until_private_payments_restored(); @@ -695,11 +704,12 @@ impl PlatformWalletManager

{ } // Past the rollback point. A store configured before this load - // restores the held wallets' private payments now, with no keys; - // otherwise `configure_shielded` does. + // restores the held wallets' private payments now, with no keys and + // without waiting on the store, which a scan may hold; otherwise + // `configure_shielded` does. #[cfg(feature = "shielded")] if let Some(coordinator) = self.shielded_coordinator().await { - self.restore_private_contact_payment_holds(&coordinator) + self.restore_private_payments_without_waiting(&coordinator, true) .await; } @@ -1958,7 +1968,7 @@ mod private_payment_hold_tests { use crate::test_support::NoopTestEventHandler; use crate::wallet::identity::ManagedIdentity; use crate::wallet::platform_wallet::WalletId; - use crate::wallet::shielded::store::PendingRedrive; + use crate::wallet::shielded::store::{PendingRedrive, PrivatePaymentEntry}; use crate::wallet::shielded::{ FileBackedShieldedStore, PrivateContactPayment, ShieldedStore, SubwalletId, }; @@ -2105,8 +2115,9 @@ mod private_payment_hold_tests { recipient: RECIPIENT, account_reference: 0, }, - &[[0xC1; 32]], + &[PrivatePaymentEntry::for_commitment([0xC1; 32])], 0, + None, ) .expect("the payment saved"); } diff --git a/packages/rs-platform-wallet/src/manager/mod.rs b/packages/rs-platform-wallet/src/manager/mod.rs index 759cd5d9cfd..83ec1db5b3d 100644 --- a/packages/rs-platform-wallet/src/manager/mod.rs +++ b/packages/rs-platform-wallet/src/manager/mod.rs @@ -931,7 +931,10 @@ impl PlatformWalletManager

{ if existing.db_path() == db_path.as_path() { let existing = Arc::clone(existing); drop(slot); - self.restore_private_contact_payment_holds(&existing).await; + // A repeat call, on the main thread of a host binding again: + // a scan may hold the store, so nothing waits on it. + self.restore_private_payments_without_waiting(&existing, false) + .await; return Ok(()); } return Err(crate::error::PlatformWalletError::ShieldedStoreError( @@ -990,17 +993,19 @@ impl PlatformWalletManager

{ 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, or whose id - /// the store's journal keeps payments of - /// ([`PlatformWallet::restore_private_contact_payments`]): a wallet loaded - /// after binding a shielded account in an earlier process, with an - /// identity marking a private payment that may be unresolved - /// ([`PlatformWallet::private_payments_held`]), or re-created under the id - /// of one removed while its payments were unresolved, which only the - /// journal shows. A wallet whose restoration fails stays held, until a - /// bind or a later call restores it. + /// Restore from `coordinator`'s store, just opened, with no keys, the + /// reservations of the unconfirmed private contact request payments of + /// every wallet this manager holds whose contact requests wait for them, + /// or whose id the store's journal keeps payments of + /// ([`PlatformWallet::restore_private_contact_payments`]): a wallet + /// published before the store was configured with a persister that keeps + /// the private scan state, one loaded after binding a shielded account in + /// an earlier process, one with an identity marking a private payment that + /// may be unresolved ([`PlatformWallet::private_payments_held`]), or one + /// whose id another process journaled payments under. Nothing else holds a + /// store just opened, so this waits for nothing long. A wallet whose + /// restoration fails stays held, until a bind, a contact request's own + /// retry or a later call restores it. #[cfg(feature = "shielded")] pub(crate) async fn restore_private_contact_payment_holds( &self, @@ -1009,9 +1014,7 @@ impl PlatformWalletManager

{ let wallets: Vec> = self.wallets.load().values().cloned().collect(); for wallet in wallets { if !wallet.private_payments_held().await - && !coordinator - .journals_private_payments_of(wallet.wallet_id()) - .await + && !coordinator.may_journal_private_payments_of(wallet.wallet_id()) { continue; } @@ -1020,13 +1023,78 @@ impl PlatformWalletManager

{ 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" + contact requests stay held until a restoration succeeds" ); } } } + /// [`Self::restore_private_contact_payment_holds`] for a store configured + /// before, which a shielded scan may hold for minutes: none waits on the + /// store ([`PlatformWallet::restore_private_payments_or_defer`]), a busy + /// store deferring it to a task, the wallet held until it runs. The + /// wallets still held are restored, and with `journaled_too` those whose + /// id the journal may keep payments of + /// (`NetworkShieldedCoordinator::may_journal_private_payments_of`, a busy + /// store counting as may): a load passes it, a repeat configure does not, + /// so a configure during a scan holds no wallet that was not held. + #[cfg(feature = "shielded")] + pub(crate) async fn restore_private_payments_without_waiting( + &self, + coordinator: &Arc, + journaled_too: bool, + ) { + let wallets: Vec> = self.wallets.load().values().cloned().collect(); + for wallet in wallets { + if wallet.private_payments_held().await + || (journaled_too + && coordinator.may_journal_private_payments_of(wallet.wallet_id())) + { + wallet.restore_private_payments_or_defer(coordinator).await; + } + } + } + + /// Before `wallet` is published: hold its contact requests until its + /// private payments are restored, or restore them now. The shielded + /// store's journal outlives a wallet's removal and a process's end, so a + /// wallet created or loaded under an id may have payments that may still + /// execute, which no marker shows and only the journal does. With a store + /// configured, the journal is read now, without waiting on the store + /// ([`PlatformWallet::restore_private_payments_or_defer`]): restored, the + /// pairs are held through the wallet's journal mirror whether or not their + /// senders are loaded yet; a busy store or a read that fails holds the + /// wallet until a restoration succeeds. With none, a wallet whose persister + /// keeps the private scan state is held until `configure_shielded` or a + /// bind reads the journal: only such a persister lets a private payment be + /// paid, so only its wallet can have journal records no marker shows. One + /// that does not is never held for want of a store. + #[cfg(feature = "shielded")] + pub(crate) async fn hold_or_restore_private_payments(&self, wallet: &PlatformWallet) { + match self.shielded_coordinator().await { + Some(coordinator) => { + wallet.restore_private_payments_or_defer(&coordinator).await; + } + None if wallet.persister().persists_private_contact_scan_state() => { + wallet.persister().hold_until_private_payments_restored(); + } + None => {} + } + } + + /// After `wallet` is published: a store configured since + /// [`Self::hold_or_restore_private_payments`] found none restored the + /// wallets published then, which this one was not yet, so it is restored + /// here, without waiting on the store, while it is still held. + #[cfg(feature = "shielded")] + pub(crate) async fn restore_private_payments_configured_since(&self, wallet: &PlatformWallet) { + if let Some(coordinator) = self.shielded_coordinator().await { + if wallet.private_payments_held().await { + wallet.restore_private_payments_or_defer(&coordinator).await; + } + } + } + /// 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/manager/wallet_lifecycle.rs b/packages/rs-platform-wallet/src/manager/wallet_lifecycle.rs index 4e1546d166e..17ff6aeb896 100644 --- a/packages/rs-platform-wallet/src/manager/wallet_lifecycle.rs +++ b/packages/rs-platform-wallet/src/manager/wallet_lifecycle.rs @@ -203,14 +203,6 @@ impl PlatformWalletManager

{ // so the replacement inherits them rather than starting clean. A first // registration finds no entry and gets an empty map. let registration_wallet_id = wallet.compute_wallet_id(); - // Whether a generation of this manager held the id before: the fence - // map keeps an entry for every id it registered, removed or not. - #[cfg(feature = "shielded")] - let recreated = self - .in_broadcast_fences - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .contains_key(®istration_wallet_id); let generation = Arc::new(WalletGeneration::with_fences( self.in_broadcast_fences_for(®istration_wallet_id), )); @@ -730,32 +722,11 @@ impl PlatformWalletManager

{ let platform_wallet = Arc::new(platform_wallet); - // Before the wallet is published: the shielded store's private payment - // journal outlives a wallet's removal, so a wallet re-created under the - // id of one removed while its private payments were unresolved holds - // their pairs only once the journal is read. With a store configured, - // it is read now: restored, the pairs are held through the wallet's - // journal mirror whether or not their sender identities are loaded - // yet; a store busy or a read that fails holds the wallet's public - // contact requests until a restoration succeeds. Without one, a wallet - // re-created under an id this manager held is held until - // `configure_shielded` or a bind reads the journal. A first - // registration is not held, so a wallet with no journal is never held - // for want of a store: payments an earlier process left under its id - // are held once `configure_shielded` reads the journal, which it does - // for every wallet whose id has records. + // Before the wallet is published: hold or restore its private + // payments (`PlatformWalletManager::hold_or_restore_private_payments`). #[cfg(feature = "shielded")] - match self.shielded_coordinator().await { - Some(coordinator) => { - platform_wallet - .restore_private_payments_or_defer(&coordinator) - .await; - } - None if recreated => platform_wallet - .persister() - .hold_until_private_payments_restored(), - None => {} - } + self.hold_or_restore_private_payments(&platform_wallet) + .await; // Register the PlatformWallet handle. `rcu` publishes a new map // snapshot; the closure can rerun under a concurrent-writer @@ -765,6 +736,11 @@ impl PlatformWalletManager

{ wallets.insert(wallet_id, Arc::clone(&platform_wallet)); wallets }); + // A store configured since the check above restored the wallets it + // found published, which this one was not yet. + #[cfg(feature = "shielded")] + self.restore_private_payments_configured_since(&platform_wallet) + .await; // Re-seed the lock-free balance atomic from the wallet's inner // balance now that the wallet is in `self.wallets`. 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 a1e31101350..3c5f0e51905 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 @@ -777,12 +777,8 @@ impl DashPayView<'_, B> { // removed meanwhile is refused at the gate. // Boxed: the read's future (a private sync) is large, and the send's // callers nest it in their own. - let read = Box::pin(self.read_private_store_before_deciding( - sender_identity_id, - recipient_identity_id, - crypto, - )) - .await; + let read = + Box::pin(self.read_private_store_before_deciding(sender_identity_id, crypto)).await; let claim = { let _lifecycle = self.generation.payment_guard().await; if !self.is_current_generation().await { @@ -790,10 +786,15 @@ impl DashPayView<'_, B> { self.wallet_id, ))); } - read?; + let store_read = read?; self.release_discharged_private_requests().await?; match self - .claim_public_pair(sender_identity_id, recipient_identity_id, adopt_reply) + .claim_public_pair( + sender_identity_id, + recipient_identity_id, + adopt_reply, + store_read, + ) .await? { Some(claim) => claim, @@ -832,13 +833,18 @@ impl DashPayView<'_, B> { /// (`WalletPersister::private_pair_journaled`), 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. + /// refusal reads what the wallet tracks, which the store read before the + /// send brought up to date (`DashPayView::read_private_store_before_deciding`). + /// When that read applied (`store_read`), the sender's read must still be + /// to the store's end here, in the same lock: a rewind since (keys that + /// changed, a sender unignored) refuses with + /// [`PlatformWalletError::PrivateContactStoreNotRead`]. pub(super) async fn claim_public_pair( &self, sender: &Identifier, recipient: &Identifier, adopt_reply: bool, + store_read: bool, ) -> Result, PlatformWalletError> { let mut wm = self.wallet_manager.write().await; let managed = wm @@ -851,6 +857,9 @@ impl DashPayView<'_, B> { if adopt_reply && managed.dashpay().outgoing_request(recipient).is_some() { return Ok(None); } + if store_read && !managed.dashpay().private_contact_requests_read() { + return Err(PlatformWalletError::PrivateContactStoreNotRead(*sender)); + } if managed.dashpay().private_reply_pending(recipient) || self.persister.private_pair_journaled(sender, recipient) { @@ -4110,8 +4119,7 @@ impl DashPayView<'_, B> { S: Signer + Send + Sync, C: ContactCryptoProvider + Sync, { - self.read_private_store_before_deciding(&request.recipient_id, &request.sender_id, crypto) - .await?; + Box::pin(self.read_private_store_before_deciding(&request.recipient_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 b80e424b27b..14b615c47d1 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 @@ -41,7 +41,8 @@ use dash_sdk::platform::dashpay::{ }; use dash_sdk::platform::private_document_entries::{PrivateDocumentEntries, PrivateDocumentEntry}; use dash_sdk::platform::private_document_scan::{ - PrivateDocumentScanKeys, MAX_CONCURRENT_PRIVATE_DOCUMENT_PAGE_FETCHES, + PrivateDocumentPageHeights, PrivateDocumentScanKeys, + MAX_CONCURRENT_PRIVATE_DOCUMENT_PAGE_FETCHES, }; use dpp::data_contract::accessors::v0::DataContractV0Getters; use dpp::data_contract::document_type::decode_private_document_payload; @@ -107,23 +108,26 @@ 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)] +#[derive(Clone, 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 + /// Whether the reservations of the wallet's private payments are restored + /// for the identity. While false, its contact requests, sent or accepted, + /// public or private, are refused with + /// [`PlatformWalletError::PrivateContactPaymentsNotRestored`]: in a build + /// with the `shielded` feature, the wallet was published with no shielded + /// store configured while its persister keeps the private scan state, it + /// bound a shielded account before it was loaded, or its last restoration + /// failed or waits for a busy store; in every build, 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. + /// them, and a contact request retries a failed restoration once; 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. + /// its end for the identity with its current keys. Every contact request + /// and acceptance reads it again from the cursor before it is decided, and + /// is refused with [`PlatformWalletError::PrivateContactStoreNotRead`] + /// 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 position record stores @@ -131,7 +135,13 @@ pub struct PrivateContactReadiness { /// 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). + /// A payment the shielded store's journal keeps unresolved counts too, as + /// a send holds its pair (`DashPayView::private_pair_held`). pub unresolved_private_payment: bool, + /// Why the last restoration of the wallet's private payments failed, while + /// `private_payments_restored` is false for it and none succeeded since: + /// `None` when none failed, so the payments are only not restored yet. + pub private_payments_restore_failure: Option, } /// What a pass over DashPay's private contact request store did. @@ -155,6 +165,11 @@ pub struct PrivateContactSyncReport { /// 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, + /// Identities this pass read the store to its end for, with their current + /// keys and nothing held back + /// (`DashPayState::private_contact_requests_read`). One read to the end + /// by an earlier pass is not listed. + pub read_to_end: Vec, } /// How many pages of the store a send reads back after paying: one window of @@ -272,6 +287,7 @@ pub struct PaymentStarted { marked: Arc, never_delivered: Arc, account_reference: Option, + store_position: Option, } impl PaymentStarted { @@ -305,12 +321,22 @@ impl PaymentStarted { self.account_reference } - /// The same token, naming the request with `account_reference`. - fn for_request(&self, account_reference: u32) -> Self { + /// The sender identity's private store read position when the send paid: + /// its entry is appended at or after it, so the payment's journal record + /// keeps it for the expiry's read of the store to start there + /// (`PrivatePaymentRecord::store_position`). + pub fn store_position(&self) -> Option { + self.store_position + } + + /// The same token, naming the request with `account_reference`, sent from + /// `store_position`. + fn for_request(&self, account_reference: u32, store_position: u64) -> Self { Self { marked: Arc::clone(&self.marked), never_delivered: Arc::clone(&self.never_delivered), account_reference: Some(account_reference), + store_position: Some(store_position), } } } @@ -930,13 +956,13 @@ impl DashPayView<'_, B> { // 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? + if self + .private_payments_still_unrestored(sender_identity_id) + .await? { return Err(PlatformWalletError::PrivateContactPaymentsNotRestored); } - self.read_private_store_before_sending(sender_identity_id, recipient_identity_id, crypto) - .await?; + Box::pin(self.read_private_store_and_keys(sender_identity_id, crypto)).await?; self.release_discharged_private_requests().await?; // Held beyond the sender identity: a journaled payment's pair outlives // the identity's removal, and holds for a sender loaded after it was @@ -1009,38 +1035,36 @@ impl DashPayView<'_, B> { sent.map(Some) } - /// 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 + /// Read DashPay's private contact request store for `identity_id` from its + /// cursor until a pass of this call reaches the store's end, with its + /// current keys and nothing held back (one proved page at the end when the + /// cursor is there), before a contact request from it, public or private, + /// or an acceptance, is decided: a request that landed since the last read, + /// from another device of the identity or from a contact, then holds the + /// pair here too. A request that lands after this read is not seen. 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( + /// [`PlatformWalletError::PrivateContactStoreNotRead`] when they stop + /// advancing or run out first. A sync error comes back as it is. Returns + /// whether the store was read for the identity: one 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 and reads nothing, so it is not held. + async fn read_private_store_to_end( &self, identity_id: &Identifier, - recipient: &Identifier, crypto: &C, - ) -> Result<(), PlatformWalletError> + ) -> Result where C: ContactCryptoProvider + Sync, { - // Keys added since the store was read clear the flag first. + // Keys added since the store was read rewind it first. self.reconcile_private_scan_keys().await?; - 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(()); + return Ok(false); } let mut position = identities .iter() @@ -1051,105 +1075,59 @@ impl DashPayView<'_, B> { 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.read_to_end.contains(identity_id) { + return Ok(true); } if report.next_position <= position { break; } position = report.next_position; } - Err(PlatformWalletError::PrivateContactRequestPending( - *recipient, + Err(PlatformWalletError::PrivateContactStoreNotRead( + *identity_id, )) } - /// [`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 - /// 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. - /// - /// 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. - /// 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`). - /// - /// Then the store read is shown to cover the identity's keys: after it, - /// `identity_id` is fetched from Platform, proved, and its keys folded - /// into the cached identity (`IdentityWallet::refresh_identity`'s fold). - /// A DashPay encryption or decryption key another device added, to which - /// a request in the store may be sealed, changes the identity's scanning - /// keys, which rewinds its read (`Self::reconcile_private_scan_keys`), and - /// the store is read again with it. A key added at a height is in no - /// request in a page proved below it, so the read covers the keys once - /// the fetch is proved at or above every page it read + /// [`Self::read_private_store_to_end`], shown to cover the identity's keys: + /// after the read, `identity_id` is fetched from Platform, proved, and its + /// keys folded into the cached identity (`IdentityWallet::refresh_identity`'s + /// fold). A DashPay encryption or decryption key another device added, to + /// which a request in the store may be sealed, changes the identity's + /// scanning keys, which rewinds its read (`Self::reconcile_private_scan_keys`), + /// and the store is read again with it. A key added at a height is in no + /// request in a page proved below it, so the read covers the keys once the + /// fetch is proved at or above every page it read /// (`DashPayState::private_store_read_height`); a fetch proved below them, - /// from a node behind, is made again. Up to - /// [`PRIVATE_KEY_COVERAGE_ATTEMPTS`] rounds, then refused: with the SDK's - /// stale node error (`StaleNodeError::Height`) when the fetch stayed - /// below the pages, and with - /// [`PlatformWalletError::PrivateContactRequestPending`] when the keys + /// from a node behind, is made again. Up to [`PRIVATE_KEY_COVERAGE_ATTEMPTS`] + /// rounds, then refused: with the SDK's stale node error + /// (`StaleNodeError::Height`) when the fetch stayed below the pages, and + /// with [`PlatformWalletError::PrivateContactStoreNotRead`] when the keys /// kept changing so the read never finished with them. A fetch that fails - /// refuses the request as the SDK reported it. Nothing is sent before. - /// One proved identity fetch per public request or acceptance when the - /// first round covers it. - pub(super) async fn read_private_store_before_deciding( + /// refuses as the SDK reported it. Nothing is reserved or sent before. One + /// proved identity fetch when the first round covers it. Returns whether + /// the store was read for the identity ([`Self::read_private_store_to_end`]). + /// + /// Run before every contact request and acceptance, public or private, so + /// a reply another device sealed under a key this device had not folded in + /// yet is adopted rather than paid for again. + async fn read_private_store_and_keys( &self, identity_id: &Identifier, - recipient: &Identifier, crypto: &C, - ) -> Result<(), PlatformWalletError> + ) -> Result where C: ContactCryptoProvider + Sync, { - if self.persister.private_payments_unrestored() - || self.private_payments_unrestored(identity_id).await? - { - return Err(PlatformWalletError::PrivateContactPaymentsNotRestored); - } let mut uncovered = PrivateStoreCoverage::Unread; for _ in 0..PRIVATE_KEY_COVERAGE_ATTEMPTS { - match self - .read_private_store_before_sending(identity_id, recipient, crypto) - .await - { - Ok(()) => {} - Err(PlatformWalletError::Sdk( - refusal @ dash_sdk::Error::ProtocolVersionNotActive { required, .. }, - )) => { - return 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)) - } - }; - } - Err(error) => return Err(error), - } + let read = self.read_private_store_to_end(identity_id, crypto).await?; let (_, identity_height) = self.fetch_and_fold_identity(identity_id).await?; self.reconcile_private_scan_keys().await?; match self .private_store_coverage(identity_id, identity_height) .await? { - PrivateStoreCoverage::Covered => return Ok(()), + PrivateStoreCoverage::Covered => return Ok(read), coverage => uncovered = coverage, } } @@ -1163,11 +1141,67 @@ impl DashPayView<'_, B> { tolerance_blocks: 0, })), PrivateStoreCoverage::Covered | PrivateStoreCoverage::Unread => { - PlatformWalletError::PrivateContactRequestPending(*recipient) + PlatformWalletError::PrivateContactStoreNotRead(*identity_id) } }) } + /// [`Self::read_private_store_and_keys`], before a public request or reply + /// from `identity_id` is chosen or sent, so a private request, whether paid + /// for in an earlier process, on another device, or by a contact, holds the + /// pair here too. Returns whether the store was read for the identity: the + /// claim of the pair checks, under the wallet's lifecycle gate, that the + /// read is still to the end then (a rewind in between refuses). + /// + /// A network that serves no private documents yet holds none, so 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. + /// + /// Refused first, with nothing read, while the wallet's unconfirmed + /// private payments 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. + /// That is so for a wallet a shielded build publishes with no shielded + /// store configured when its persister keeps the private scan state, for + /// one it loads with a persisted viewing key, for one whose restoration + /// failed, 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, + crypto: &C, + ) -> Result + where + C: ContactCryptoProvider + Sync, + { + if self.private_payments_still_unrestored(identity_id).await? { + return Err(PlatformWalletError::PrivateContactPaymentsNotRestored); + } + match Box::pin(self.read_private_store_and_keys(identity_id, crypto)).await { + Err(PlatformWalletError::Sdk( + refusal @ dash_sdk::Error::ProtocolVersionNotActive { required, .. }, + )) => match self.sdk.network_proven_below(required).await { + Ok(true) => Ok(false), + 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, + } + } + /// Whether the private store read `identity_id`'s decisions rest on /// covers its keys as the fetch proved at `identity_height` shows them /// ([`Self::read_private_store_before_deciding`]). @@ -1270,6 +1304,30 @@ impl DashPayView<'_, B> { Ok(()) } + /// Whether a contact request from `identity_id` waits for the wallet's + /// private payments to be restored: the wallet is held + /// (`WalletPersister::private_payments_unrestored`), or the identity was + /// loaded marking a private payment no restoration has restored since + /// ([`Self::private_payments_unrestored`]). When it is, one more + /// restoration runs first, without waiting on the shielded store, once the + /// wallet built one with a shielded coordinator + /// (`WalletPersister::retry_private_payment_restore`): a restoration that + /// failed, or was deferred, lifts at the next send rather than only at a + /// later configure or bind. + pub(crate) async fn private_payments_still_unrestored( + &self, + identity_id: &Identifier, + ) -> Result { + let held = self.persister.private_payments_unrestored() + || self.private_payments_unrestored(identity_id).await?; + #[cfg(feature = "shielded")] + if held && self.persister.retry_private_payment_restore().await { + return Ok(self.persister.private_payments_unrestored() + || self.private_payments_unrestored(identity_id).await?); + } + Ok(held) + } + /// 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`]). @@ -1295,7 +1353,13 @@ impl DashPayView<'_, B> { &self, identity_id: &Identifier, ) -> Result { + // A discharge reads as a send would see it. + self.release_discharged_private_requests().await?; let wallet_held = self.persister.private_payments_unrestored(); + let journaled = self + .persister + .private_sender_journaled(identity_id) + .is_some(); let wm = self.wallet_manager.read().await; let dashpay = wm .get_wallet_info(&self.wallet_id) @@ -1304,13 +1368,47 @@ impl DashPayView<'_, B> { .managed_identity(identity_id) .ok_or(PlatformWalletError::IdentityNotFound(*identity_id))? .dashpay(); + let private_payments_restored = !wallet_held && !dashpay.private_payments_unrestored(); Ok(PrivateContactReadiness { - private_payments_restored: !wallet_held && !dashpay.private_payments_unrestored(), + private_payments_restored, private_store_read: dashpay.private_contact_requests_read(), - unresolved_private_payment: dashpay.private_payments_unresolved(), + unresolved_private_payment: dashpay.private_payments_unresolved() || journaled, + private_payments_restore_failure: self + .persister + .private_payment_restore_failure() + .filter(|_| !private_payments_restored), }) } + /// Whether a contact request from `sender` to `recipient`, public or + /// private, is held for a private one, as a send decides it: a private + /// request to the recipient is reserved (being paid for, or paid and not + /// read back), or a private payment the shielded store's journal keeps + /// unresolved holds the pair, whether or not the sender identity was + /// removed and added again since (`WalletPersister::private_pair_journaled`). + /// A journal record discharged since is applied first, so a discharge reads + /// as freed with no send or sync in between. The per-contact read a host + /// shows; `DashPayState::private_reply_pending` reads only the identity's + /// own reservation. + pub async fn private_pair_held( + &self, + sender: &Identifier, + recipient: &Identifier, + ) -> Result { + self.release_discharged_private_requests().await?; + let journaled = self.persister.private_pair_journaled(sender, recipient); + let wm = self.wallet_manager.read().await; + let reserved = wm + .get_wallet_info(&self.wallet_id) + .ok_or_else(|| PlatformWalletError::WalletNotFound(hex::encode(self.wallet_id)))? + .identity_manager + .managed_identity(sender) + .ok_or(PlatformWalletError::IdentityNotFound(*sender))? + .dashpay() + .private_reply_pending(recipient); + Ok(reserved || journaled) + } + /// 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. @@ -1373,22 +1471,6 @@ impl DashPayView<'_, B> { 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( - &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, @@ -1561,7 +1643,9 @@ impl DashPayView<'_, B> { // whatever happens to this send. let paid = pay( entry, - reservation.payment_started.for_request(account_reference), + reservation + .payment_started + .for_request(account_reference, marker.position), ) .await; reservation.payment_may_have_executed = @@ -1624,12 +1708,18 @@ impl DashPayView<'_, B> { /// `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`]. - /// A reply whose payment is unconfirmed since before a restart holds the - /// pair from the moment the wallet binds to its shielded coordinator. + /// As for the send, refused first for a persister that cannot keep a + /// private payment's recovery marker + /// ([`PlatformWalletError::PrivateContactRecoveryUnsupported`]) and while + /// the wallet's private payments are not restored + /// ([`PlatformWalletError::PrivateContactPaymentsNotRestored`]); then the + /// store is read from the cursor to its end with the keys Platform proves + /// the identity holds (`Self::read_private_store_and_keys`), so a reply + /// paid for in an earlier process or on another device is adopted rather + /// than paid for again; while that read cannot finish, the accept is + /// refused with [`PlatformWalletError::PrivateContactStoreNotRead`]. A + /// reply whose payment is unconfirmed since before a restart holds the + /// pair from the moment the wallet's private payments are restored. pub async fn accept_private_contact_request( &self, request: &ContactRequest, @@ -1644,11 +1734,21 @@ impl DashPayView<'_, B> { F: FnOnce(BinaryData, PaymentStarted) -> 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, || { - self.send_private_reply(request, shielded_address, signer, crypto, pay) - }) + if !self.persister.persists_private_contact_scan_state() { + return Err(PlatformWalletError::PrivateContactRecoveryUnsupported); + } + if self + .private_payments_still_unrestored(&request.recipient_id) + .await? + { + return Err(PlatformWalletError::PrivateContactPaymentsNotRestored); + } + Box::pin(self.read_private_store_and_keys(&request.recipient_id, crypto)).await?; + Box::pin( + self.accept_contact_request_replying_with(request, crypto, || { + self.send_private_reply(request, shielded_address, signer, crypto, pay) + }), + ) .await } @@ -1660,11 +1760,11 @@ impl DashPayView<'_, B> { /// a host calls: which reply a request takes is the wallet's decision. /// `pay` runs only for a private reply. /// - /// 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 + /// The store is read from the cursor to its end first, before the reply + /// is chosen, whatever the request: a reply paid for in an earlier process + /// or on another device, 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`]. + /// is refused with [`PlatformWalletError::PrivateContactStoreNotRead`]. /// On a network that serves no private documents yet, nothing is read. pub async fn accept_contact_request_replying_in_kind( &self, @@ -1680,13 +1780,19 @@ impl DashPayView<'_, B> { F: FnOnce(BinaryData, PaymentStarted) -> Fut + Send, Fut: Future> + Send, { - self.read_private_store_before_deciding(&request.recipient_id, &request.sender_id, crypto) - .await?; + Box::pin(self.read_private_store_before_deciding(&request.recipient_id, crypto)).await?; + // Boxed: each accept's future holds a store read and a payment. if self.needs_private_reply(request).await { - self.accept_private_contact_request(request, shielded_address, signer, crypto, pay) - .await + Box::pin(self.accept_private_contact_request( + request, + shielded_address, + signer, + crypto, + pay, + )) + .await } else { - self.accept_contact_request_with_external_signer(request, signer, crypto) + Box::pin(self.accept_contact_request_with_external_signer(request, signer, crypto)) .await } } @@ -1812,6 +1918,10 @@ impl DashPayView<'_, B> { // was appended or later. let mut recipients: BTreeMap = BTreeMap::new(); report.next_position = start; + // The highest store size a page of this pass showed: the pass reaches + // the store's end only there. A later page from a node behind, its + // store ending at that page's end, shows an older end. + let mut pass_total: u64 = 0; let persists_scan_state = self.persister.persists_private_contact_scan_state(); // The pages are fetched many at once while earlier ones are processed, @@ -1864,10 +1974,12 @@ impl DashPayView<'_, B> { ))); } let mut reached_end = false; - // The highest height each page's reads were proved at, in the scans' order: no - // entry a page holds was appended above it, nor sealed to a key added above it. - let page_heights: Vec = fetched.iter().map(|(_, heights)| heights.max).collect(); - for (scan, page_height) in scans.into_iter().zip(page_heights) { + // The heights each page's reads were proved at, in the scans' order: no entry a + // page holds was appended above the highest, nor sealed to a key added above it, + // and the page shows the store at least as of the lowest. + let page_heights: Vec = + fetched.iter().map(|(_, heights)| *heights).collect(); + for (scan, heights) in scans.into_iter().zip(page_heights) { report.pages_read += 1; let stale: BTreeMap = private_contact_request_identities(&scan) .into_iter() @@ -1888,6 +2000,8 @@ impl DashPayView<'_, B> { &proved_identities, self.sdk.version(), )?; + pass_total = pass_total.max(read.total_count); + let reaches_end = read.next_position >= pass_total; // 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(); @@ -2142,7 +2256,7 @@ impl DashPayView<'_, B> { // (`ManagedIdentity::rewind_private_store_read`), // and such a page would pass it. if !identity.failed && !identity.rewound { - managed.note_private_store_page_read(page_height); + managed.note_private_store_page_read(heights.max); } let after = managed.dashpay().private_contact_requests_position(); // Stored once it moved, once the last private @@ -2178,12 +2292,13 @@ impl DashPayView<'_, B> { // sender unignored): the entries before it were read // with the sender ignored, and the next pass reads // them again. - if read.is_complete() + if reaches_end && !identity.failed && !identity.rewound && identity.keys_current(managed) + && managed.note_private_contact_requests_read(heights.min) { - managed.note_private_contact_requests_read(); + report.read_to_end.push(identity_id); } tear_down_stale_external_accounts(wallet, info, &identity_id, rotated); candidates.push(( @@ -2199,7 +2314,7 @@ impl DashPayView<'_, B> { } report.next_position = read.next_position; - if read.is_complete() { + if reaches_end { // 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(); @@ -2831,6 +2946,12 @@ mod tests { .add_identity(own.clone(), 0, wallet.wallet_id(), &iw.persister) .expect("own identity"); } + // As a host that configured an empty shielded store before creating + // the wallet: a wallet published with none, its persister keeping the + // private scan state, is otherwise held until one is. The tests of + // that hold build their wallets without this. + #[cfg(feature = "shielded")] + wallet.persister().private_payments_restored(); (manager, wallet) } @@ -2870,34 +2991,9 @@ mod tests { .await .expect("identity expectation"); } - let total_count = entries.len() as u64; - let page = PrivateDocumentEntries { - chunk_power: None, - 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: 0, - 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"); + // The mock proves identities at height 0, which an entry's identities + // must be proved at or after: every entry is at height 0. + expect_store_from(&mut sdk, 0, entries).await; sdk } @@ -3171,6 +3267,7 @@ mod tests { private_payments_restored: false, private_store_read: true, unresolved_private_payment: true, + private_payments_restore_failure: None, } ); assert!(restarted.private_payments_held().await, "still held"); @@ -3202,6 +3299,7 @@ mod tests { private_payments_restored: true, private_store_read: true, unresolved_private_payment: false, + private_payments_restore_failure: None, } ); } else { @@ -3377,8 +3475,9 @@ mod tests { } /// An identity reloaded from a position record stored with its marker - /// cleared, as a discharge leaves it, is not held, in any build: its - /// public request goes on to be built, with no restoration needed. + /// cleared, as a discharge leaves it, is not held, in any build (in one + /// with the `shielded` feature once the store is configured, which an + /// empty journal releases): its public request goes on to be built. #[tokio::test] async fn should_not_hold_an_identity_reloaded_with_its_marker_cleared() { let alice = identity(ALICE_PHRASE, ALICE); @@ -3396,6 +3495,17 @@ mod tests { .load_from_persistor() .await .expect("the relaunch loads"); + // A host keeping the private scan state configures the shielded store + // at activation: until then, a loaded wallet is held. + #[cfg(feature = "shielded")] + let _store = { + let dir = tempfile::tempdir().expect("temporary shielded store"); + manager + .configure_shielded(dir.path().join("tree.sqlite")) + .await + .expect("the shielded store is configured"); + dir + }; let restarted = manager .get_wallet(&before.wallet_id()) .await @@ -4171,22 +4281,35 @@ mod tests { } } - /// From now on, `wallet`'s mock SDK answers the page from `start` with - /// `entries`: the store as it is once they are appended. + /// From now on, `wallet`'s mock SDK answers the page from `start`, and + /// from every position after it to the store's end, with `entries` from + /// there: the store as it is once they are appended, read from any cursor + /// in it, its end included (every contact request reads the store from + /// its cursor first). 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(); + expect_store_from(&mut sdk, start, entries).await; + } + + /// `sdk`'s mock answers the page from `start`, and from every position + /// after it to the store's end, with `entries` from there + /// ([`publish`]). + async fn expect_store_from(sdk: &mut dash_sdk::Sdk, start: u64, entries: Vec>) { + let contract = dashpay_contract().expect("bundled DashPay"); 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"); + for offset in 0..=entries.len() { + let from = start + offset as u64; + let query = PrivateDocumentEntriesQuery::new( + contract.id(), + PRIVATE_CONTACT_REQUEST_DOCUMENT_TYPE_NAME, + from, + ); + mock.remove_fetch_expectation::(query.clone()) + .await; + mock.expect_fetch(query, Some(store_page(from, entries[offset..].to_vec()))) + .await + .expect("page expectation"); + } } /// A `pay` that appends the entry to the store `wallet` reads, after @@ -6850,6 +6973,7 @@ mod tests { (0, page(0, vec![filler(0xA0), filler(0xA1)], 4)), (2, page(2, vec![filler(0xA2)], 3)), (3, page(3, vec![entry], 4)), + (4, page(4, vec![], 4)), ] { mock.remove_fetch_expectation::(query(start)) .await; @@ -6858,7 +6982,8 @@ mod tests { .expect("page expectation"); } mock.set_response_height(H - 1) - .set_response_height_for::(query(3), H); + .set_response_height_for::(query(3), H) + .set_response_height_for::(query(4), H); } let view = bob_wallet.identity().dashpay(); let (bob_id, alice_id) = (bob.id(), alice.id()); @@ -6924,7 +7049,7 @@ mod tests { /// request at position 1 is sealed. His public request brings the key and /// rewinds the read; a node a block behind then answers the store from its /// start holding only the entry before hers. That read does not count, so - /// the request is refused as pending, with nothing sent. Once the store is + /// the request is refused as not read, with nothing sent. Once the store is /// read from the cursor at H, her request is found, and the public request /// is refused. #[tokio::test] @@ -6967,11 +7092,10 @@ mod tests { } } }; - serve(vec![( - 0, - page(0, vec![filler.clone(), entry.clone()], 2), - H, - )]) + serve(vec![ + (0, page(0, vec![filler.clone(), entry.clone()], 2), H), + (2, page(2, vec![], 2), H), + ]) .await; let view = bob_wallet.identity().dashpay(); view.sync_private_contact_requests(&bob_crypto, 4) @@ -7001,8 +7125,8 @@ mod tests { assert!( matches!( behind, - Err(PlatformWalletError::PrivateContactRequestPending(recipient)) - if recipient == alice.id() + Err(PlatformWalletError::PrivateContactStoreNotRead(identity)) + if identity == bob.id() ), "{behind:?}" ); @@ -7097,6 +7221,230 @@ mod tests { .await; } + /// A public request reads the store from its cursor every time, not once + /// per load: Bob read the store while it was empty, and Alice's private + /// request landed afterwards. His next public request to her reads it and + /// is refused, as a public one would publish the pair she kept private, + /// with no restart in between. + #[tokio::test] + async fn should_read_an_entry_landed_since_the_last_read_before_a_public_request() { + 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![]).await; + let view = bob_wallet.identity().dashpay(); + view.sync_private_contact_requests(&bob_crypto, 4) + .await + .expect("Bob reads the empty store"); + assert!(store_read(&bob_wallet, BOB).await); + + publish(&bob_wallet, 0, vec![entry]).await; + 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(sender)) + if sender == alice.id() + ), + "{public:?}" + ); + managed(&bob_wallet, BOB, |managed| { + assert!(managed.dashpay().incoming_request(&alice.id()).is_some()); + assert!( + managed.dashpay().outgoing_request(&alice.id()).is_none(), + "nothing sent" + ); + }) + .await; + } + + /// `wallet`'s mock answers the store as `chunks` of two entries each, + /// every page showing `total` entries and the chunk size, the page from + /// each start proved at the height given with it. + async fn serve_chunks(wallet: &PlatformWallet, total: u64, chunks: &[(u64, u64)]) { + let contract = dashpay_contract().expect("bundled DashPay"); + let mut sdk = (*wallet.identity().sdk).clone(); + let mut mock = sdk.mock(); + for (start, height) in chunks { + let query = PrivateDocumentEntriesQuery::new( + contract.id(), + PRIVATE_CONTACT_REQUEST_DOCUMENT_TYPE_NAME, + *start, + ); + let mut page = store_page(*start, vec![vec![0xA0 + *start as u8; 589]; 2]); + page.chunk_power = Some(1); + page.total_count = total; + mock.remove_fetch_expectation::(query.clone()) + .await; + mock.expect_fetch(query.clone(), Some(page)) + .await + .expect("page expectation"); + mock.set_response_height_for::(query, *height); + } + } + + /// A read rewound to its start counts as read to the end only when the + /// page that reaches the end was proved at or above the floor, whatever + /// the pages before it: after a rewind with floor H, a walk whose first + /// and last pages come from a node at H - 1 and whose middle page comes + /// from H does not set the read, though its highest page is at H. + #[tokio::test] + async fn should_count_a_rewound_read_by_the_height_of_the_page_reaching_the_end() { + const H: u64 = 20; + let alice = identity(ALICE_PHRASE, ALICE); + let bob = identity(BOB_PHRASE, BOB); + let bob_crypto = SeedCryptoProvider::from_seed(seed(BOB_PHRASE), Network::Testnet); + let stale = without_key(&bob, DECRYPTION_KEY_ID); + let (_manager, bob_wallet) = wallet(BOB_PHRASE, &stale, &[&alice, &bob], vec![]).await; + (*bob_wallet.identity().sdk) + .clone() + .mock() + .set_response_height(H); + serve_chunks(&bob_wallet, 6, &[(0, H), (2, H), (4, H)]).await; + let view = bob_wallet.identity().dashpay(); + view.sync_private_contact_requests(&bob_crypto, 16) + .await + .expect("Bob reads the store at H with his older keys"); + assert!(store_read(&bob_wallet, BOB).await); + + // His key reaches the cache: the read is rewound, H its floor. + bob_wallet + .identity() + .refresh_identity(&bob.id()) + .await + .expect("the refresh folds in the key"); + view.reconcile_private_scan_keys() + .await + .expect("reconciled"); + assert!(!store_read(&bob_wallet, BOB).await, "rewound"); + + serve_chunks(&bob_wallet, 6, &[(0, H - 1), (2, H), (4, H - 1)]).await; + let report = view + .sync_private_contact_requests(&bob_crypto, 16) + .await + .expect("the read from 0"); + assert_eq!(report.next_position, 6, "the walk reached the end"); + managed(&bob_wallet, BOB, |managed| { + assert_eq!(managed.dashpay().private_store_read_height(), Some(H)); + assert!( + !managed.dashpay().private_contact_requests_read(), + "its end came from below the floor" + ); + }) + .await; + assert!(!report.read_to_end.contains(&bob.id())); + } + + /// A pass reaches the store's end only at the highest size a page of it + /// showed: after a first page showing six entries, a full chunk from a + /// node behind, its store ending at that chunk's end, leaves the pass + /// incomplete and the store not read to its end. + #[tokio::test] + async fn should_not_end_a_pass_at_a_later_page_from_a_node_behind() { + let alice = identity(ALICE_PHRASE, ALICE); + let bob = identity(BOB_PHRASE, BOB); + let bob_crypto = SeedCryptoProvider::from_seed(seed(BOB_PHRASE), Network::Testnet); + let (_manager, bob_wallet) = wallet(BOB_PHRASE, &bob, &[&alice, &bob], vec![]).await; + serve_chunks(&bob_wallet, 6, &[(0, 0)]).await; + serve_chunks(&bob_wallet, 4, &[(2, 0)]).await; + let report = bob_wallet + .identity() + .dashpay() + .sync_private_contact_requests(&bob_crypto, 2) + .await + .expect("two pages"); + assert_eq!(report.next_position, 4); + assert!(!report.complete, "not at the end the first page showed"); + assert!(!store_read(&bob_wallet, BOB).await); + } + + /// A private accept is decided with the keys Platform proves the identity + /// holds now, as a public one is: Bob's other device replied privately to + /// Alice under the encryption key this device has not folded in, which + /// alone recovers the reply from the store. This device's private accept + /// fetches Bob's identity, folds the key in, reads the store again, + /// recovers the reply and adopts it, paying nothing. + #[tokio::test] + async fn should_adopt_a_reply_sealed_under_a_key_another_device_added() { + 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 (request, received) = alice_to_bob([0x55; 43]).await; + + // Bob's other device, holding every key, replies privately. + let (_other_manager, other) = + wallet(BOB_PHRASE, &bob, &[&alice, &bob], vec![request.clone()]).await; + other + .identity() + .dashpay() + .sync_private_contact_requests(&bob_crypto, 4) + .await + .expect("the other device reads Alice's request"); + let reply = Arc::new(Mutex::new(Vec::new())); + let replied = other + .identity() + .dashpay() + .accept_private_contact_request( + &received, + None, + &bob_signer, + &bob_crypto, + keep_entry(&reply), + ) + .await; + assert!( + matches!( + replied, + Err(PlatformWalletError::PrivateContactRequestPending(_)) + ), + "paid, not read back: {replied:?}" + ); + let reply = reply.lock().expect("reply").remove(0).to_vec(); + + // This device lacks the encryption key the reply was sealed under. + let stale = without_key(&bob, ENCRYPTION_KEY_ID); + let (_manager, bob_wallet) = + wallet(BOB_PHRASE, &stale, &[&alice, &bob], vec![request, reply]).await; + let view = bob_wallet.identity().dashpay(); + view.sync_private_contact_requests(&bob_crypto, 4) + .await + .expect("this device reads the store with its older keys"); + managed(&bob_wallet, BOB, |managed| { + assert!(managed.dashpay().incoming_request(&alice.id()).is_some()); + assert!( + managed.dashpay().outgoing_request(&alice.id()).is_none(), + "the reply does not open with its keys" + ); + }) + .await; + + let adopted = view + .accept_private_contact_request(&received, None, &bob_signer, &bob_crypto, no_payment) + .await + .expect("the reply is adopted"); + assert_eq!(adopted.contact_identity_id, alice.id()); + managed(&bob_wallet, BOB, |managed| { + assert!(managed + .identity + .public_keys() + .contains_key(&ENCRYPTION_KEY_ID)); + assert!(managed.dashpay().outgoing_request(&alice.id()).is_some()); + }) + .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 @@ -8799,6 +9147,8 @@ mod tests { account_reference: 1, }, nullifiers: vec![fixture.input.nullifier], + entry_hash: None, + store_position: None, }; { let mut store = fixture.coordinator.store().write().await; @@ -9034,8 +9384,17 @@ mod tests { let kept = Arc::new( scan_state_snapshot(&fixture.wallet, &alice.id(), marker().expect("stored")).await, ); + // The store as the earlier process left it, read from its cursor. let manager = PlatformWalletManager::new( - Arc::new(dashpay_sdk(&[&alice, &bob], vec![], PlatformVersion::latest(), None).await), + Arc::new( + dashpay_sdk( + &[&alice, &bob], + vec![entry.to_vec()], + PlatformVersion::latest(), + None, + ) + .await, + ), Arc::clone(&kept), Arc::new(NoopEventHandler), ); @@ -9043,6 +9402,17 @@ mod tests { .load_from_persistor() .await .expect("the relaunch loads"); + // A host keeping the private scan state configures the shielded store + // at activation: until then, a loaded wallet is held. + #[cfg(feature = "shielded")] + let _store = { + let dir = tempfile::tempdir().expect("temporary shielded store"); + manager + .configure_shielded(dir.path().join("tree.sqlite")) + .await + .expect("the shielded store is configured"); + dir + }; let restarted = manager .get_wallet(&fixture.wallet.wallet_id()) .await @@ -9125,7 +9495,14 @@ mod tests { let candidates = HashSet::from([[0x72; 32]]); let below = fixture .coordinator - .private_contact_requests_held(&candidates, floor) + .private_contact_requests_held( + &(candidates) + .iter() + .map(|commitment| (*commitment, None)) + .collect(), + 0, + floor, + ) .await; assert!(below.is_err(), "{below:?}"); @@ -9133,7 +9510,14 @@ mod tests { .set_response_height_for::(query(2), floor); let held = fixture .coordinator - .private_contact_requests_held(&candidates, floor) + .private_contact_requests_held( + &(candidates) + .iter() + .map(|commitment| (*commitment, None)) + .collect(), + 0, + floor, + ) .await .expect("read at the floor"); assert!(held.is_empty(), "absent"); @@ -9145,9 +9529,10 @@ mod tests { /// payment's pair from it. With the store configured, the journal is read /// as the wallet registers: once its identity is loaded, its public /// request is refused as pending. With none configured yet, the wallet is - /// held until one is: refused as not restored, then held as pending once + /// held until one is, as its persister keeps the private scan state: + /// refused as not restored, then held as pending once /// `configure_shielded` reads the journal. Configuring the store also - /// reads it for a wallet registered before, which no load held. + /// reads it for a wallet registered before. #[cfg(feature = "shielded")] #[tokio::test] async fn should_hold_the_journaled_pairs_of_a_wallet_re_created_under_its_id() { @@ -9196,6 +9581,8 @@ mod tests { account_reference: 1, }, nullifiers: vec![[0x12; 32]], + entry_hash: None, + store_position: None, }], ) .expect("journaled"); @@ -9267,13 +9654,299 @@ mod tests { } } + /// Journal, in the shielded store at `path`, a private payment from Alice to + /// Bob made by wallet `wallet_id` that may still execute. + #[cfg(feature = "shielded")] + fn journal_alice_to_bob(path: &std::path::Path, wallet_id: WalletId) { + use crate::wallet::shielded::PrivatePaymentRecord; + + let (alice, bob) = (identity(ALICE_PHRASE, ALICE), identity(BOB_PHRASE, BOB)); + FileBackedShieldedStore::open_path(path, 100) + .expect("the store") + .journal_private_payments( + wallet_id, + &[PrivatePaymentRecord { + account_index: 0, + activity_id: [0x71; 32], + anchor: [0x7C; 32], + anchor_height: Some(1), + commitment: [0x72; 32], + payment: PrivateContactPayment { + sender: alice.id().to_buffer(), + recipient: bob.id().to_buffer(), + account_reference: 1, + }, + nullifiers: vec![[0x12; 32]], + entry_hash: None, + store_position: None, + }], + ) + .expect("journaled"); + } + + /// A manager over `persistence`, with Alice and Bob known to its SDK. + #[cfg(feature = "shielded")] + async fn manager_on( + persistence: Arc

, + ) -> PlatformWalletManager

{ + let (alice, bob) = (identity(ALICE_PHRASE, ALICE), identity(BOB_PHRASE, BOB)); + PlatformWalletManager::new( + Arc::new(dashpay_sdk(&[&alice, &bob], vec![], PlatformVersion::latest(), None).await), + persistence, + Arc::new(NoopEventHandler), + ) + } + + /// `manager`'s wallet of `phrase`, created as a host creates one, holding + /// `own` as loaded from Platform: a fresh managed identity, no marker. + #[cfg(feature = "shielded")] + async fn create_holding( + manager: &PlatformWalletManager

, + phrase: &str, + own: &Identity, + ) -> Arc { + let wallet = manager + .create_wallet_from_seed_bytes( + Network::Testnet, + &seed(phrase), + WalletAccountCreationOptions::Default, + Some(0), + ) + .await + .expect("the wallet"); + 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 + .add_identity(own.clone(), 0, wallet.wallet_id(), &iw.persister) + .expect("the identity"); + drop(wm); + wallet + } + + /// A wallet published with no shielded store configured, its persister + /// keeping the private scan state, is held until the journal is read, + /// whatever its history: a fresh wallet is refused as not restored, and + /// the store, configured, releases it when its journal holds nothing. A + /// wallet removed while a private payment of its may still execute, and + /// registered again from the same seed by a new manager, as after a + /// restart, before the store is configured, with its identity loaded + /// afresh from Platform (no marker, no history in this manager), is + /// refused as not restored too, and as pending once the store's journal is + /// read. A persister that does not keep the scan state, so cannot pay a + /// private request, is never held for want of a store. + #[cfg(feature = "shielded")] + #[tokio::test] + async fn should_hold_a_wallet_published_with_no_shielded_store_until_its_journal_is_read() { + 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_SCAN_STATE_RESTART, + ) + }; + let (alice_id, bob_id) = (alice.id(), bob.id()); + let (alice_crypto, alice_signer) = (&alice_crypto, &alice_signer); + let public = |wallet: Arc| async move { + wallet + .identity() + .dashpay() + .send_contact_request_with_external_signer( + &alice_id, + &bob_id, + None, + AutoAcceptProofSource::None, + alice_signer, + alice_crypto, + ) + .await + }; + let not_restored = |result: &Result| { + matches!( + result, + Err(PlatformWalletError::PrivateContactPaymentsNotRestored) + ) + }; + + // A fresh wallet, an empty journal. + let dir = tempfile::tempdir().expect("temporary shielded store"); + let path = dir.path().join("tree.sqlite"); + let manager = manager_on(attesting()).await; + let fresh = create_holding(&manager, ALICE_PHRASE, &alice).await; + assert!(fresh.private_payments_held().await, "held with no store"); + let held = public(Arc::clone(&fresh)).await; + assert!(not_restored(&held), "{held:?}"); + manager + .configure_shielded(&path) + .await + .expect("the shielded store is configured"); + assert!( + !fresh.private_payments_held().await, + "an empty journal releases it" + ); + + // Removed with a payment journaled, registered again by a new manager + // before the store is configured. + let dir = tempfile::tempdir().expect("temporary shielded store"); + let path = dir.path().join("tree.sqlite"); + let before = manager_on(attesting()).await; + let removed = create_holding(&before, ALICE_PHRASE, &alice).await; + let wallet_id = removed.wallet_id(); + journal_alice_to_bob(&path, wallet_id); + before.remove_wallet(&wallet_id).await.expect("removed"); + drop(removed); + drop(before); + let after = manager_on(attesting()).await; + let again = create_holding(&after, ALICE_PHRASE, &alice).await; + assert_eq!(again.wallet_id(), wallet_id); + let held = public(Arc::clone(&again)).await; + assert!(not_restored(&held), "before configure: {held:?}"); + after + .configure_shielded(&path) + .await + .expect("the shielded store is configured"); + assert!(again + .persister() + .private_pair_journaled(&alice.id(), &bob.id())); + let pending = public(Arc::clone(&again)).await; + assert!( + matches!( + pending, + Err(PlatformWalletError::PrivateContactRequestPending(recipient)) + if recipient == bob.id() + ), + "after configure: {pending:?}" + ); + + // A persister that does not keep the scan state. + let manager = manager_on(RecordingPersistence::attesting( + PersistenceCapabilities::NONE, + )) + .await; + let unheld = create_holding(&manager, ALICE_PHRASE, &alice).await; + assert!( + !unheld.private_payments_held().await, + "never held for want of a store" + ); + } + + /// With the store configured, a wallet removed and created again under its + /// id, its journal holding nothing, is not held: no history of the id is + /// kept, so a registration retried after one that failed or was removed is + /// a first one. + #[cfg(feature = "shielded")] + #[tokio::test] + async fn should_not_hold_a_wallet_created_again_over_an_empty_journal() { + let alice = identity(ALICE_PHRASE, ALICE); + let dir = tempfile::tempdir().expect("temporary shielded store"); + let manager = manager_on(RecordingPersistence::attesting( + PersistenceCapabilities::PRIVATE_CONTACT_SCAN_STATE_RESTART, + )) + .await; + manager + .configure_shielded(dir.path().join("tree.sqlite")) + .await + .expect("the shielded store is configured"); + let first = create_holding(&manager, ALICE_PHRASE, &alice).await; + assert!(!first.private_payments_held().await); + let wallet_id = first.wallet_id(); + manager.remove_wallet(&wallet_id).await.expect("removed"); + let again = create_holding(&manager, ALICE_PHRASE, &alice).await; + assert!( + !again.private_payments_held().await, + "created again, not held" + ); + } + + /// A registration racing the store's configuration ends restored, however + /// the two interleave: one that found no store holds the wallet, and the + /// configuration either finds it published or the registration finds the + /// store once it published it. + #[cfg(feature = "shielded")] + #[tokio::test(flavor = "multi_thread", worker_threads = 4)] + async fn should_restore_a_wallet_registered_while_the_store_is_configured() { + let alice = identity(ALICE_PHRASE, ALICE); + for _ in 0..6 { + let dir = tempfile::tempdir().expect("temporary shielded store"); + let path = dir.path().join("tree.sqlite"); + let manager = Arc::new( + manager_on(RecordingPersistence::attesting( + PersistenceCapabilities::PRIVATE_CONTACT_SCAN_STATE_RESTART, + )) + .await, + ); + let configuring = { + let manager = Arc::clone(&manager); + tokio::spawn(async move { manager.configure_shielded(&path).await }) + }; + let wallet = create_holding(&manager, ALICE_PHRASE, &alice).await; + configuring + .await + .expect("the configuration runs") + .expect("the shielded store is configured"); + let restored = tokio::time::timeout(Duration::from_secs(5), async { + while wallet.private_payments_held().await { + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await; + assert!(restored.is_ok(), "the wallet stays held"); + } + } + + /// A repeat `configure_shielded`, as a host makes on every bind from its + /// main thread, returns at once while a scan holds the store's writer: a + /// wallet still held is restored once the writer is released. + #[cfg(feature = "shielded")] + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn should_configure_again_without_waiting_on_a_busy_store() { + let alice = identity(ALICE_PHRASE, ALICE); + let bob = identity(BOB_PHRASE, BOB); + let dir = tempfile::tempdir().expect("temporary shielded store"); + let path = dir.path().join("tree.sqlite"); + let manager = manager_on(RecordingPersistence::attesting( + PersistenceCapabilities::PRIVATE_CONTACT_SCAN_STATE_RESTART, + )) + .await; + manager + .configure_shielded(&path) + .await + .expect("the shielded store is configured"); + let coordinator = manager.shielded_coordinator().await.expect("configured"); + let scan = coordinator.store().write().await; + // Registered while the scan holds the store: held, its restoration + // deferred. + let held = create_holding(&manager, BOB_PHRASE, &bob).await; + assert!(held.private_payments_held().await); + let again = + tokio::time::timeout(Duration::from_secs(1), manager.configure_shielded(&path)).await; + assert!( + matches!(again, Ok(Ok(()))), + "the repeat configure does not wait on the store" + ); + drop(scan); + let restored = tokio::time::timeout(Duration::from_secs(5), async { + while held.private_payments_held().await { + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await; + assert!(restored.is_ok(), "restored once the store frees"); + let _ = alice; + } + /// A private payment that never executes is proven expired once its /// 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. + /// never executed. A still-recorded anchor keeps the record and the hold. + /// An entry in the store keeps the record, for its read-back to discharge: + /// the next send reads the store first and records the request, so a new + /// private request to the pair is then a rotation, which pays. #[cfg(feature = "shielded")] #[tokio::test] async fn should_free_a_pair_once_its_private_payment_provably_expired() { @@ -9313,10 +9986,25 @@ mod tests { }; let journal = fixture.journal_snapshot().await; fixture.discharge(&journal, &recorded, Some(height)).await; - if matches!(case, Case::Expired | Case::InputSpent) { - fixture.assert_discharged(&format!("{case:?}")).await; - } else { - fixture.assert_kept(&format!("{case:?}")).await; + match case { + Case::Expired | Case::InputSpent => { + fixture.assert_discharged(&format!("{case:?}")).await; + } + Case::AnchorRecorded => fixture.assert_kept(&format!("{case:?}")).await, + Case::EntryAppended => { + assert_eq!(fixture.journaled().await, 1, "kept for its read-back"); + let rotation = Arc::new(Mutex::new(Vec::new())); + let rotated = fixture.send_again(keep_entry(&rotation)).await; + assert!( + matches!( + rotated, + Err(PlatformWalletError::PrivateContactRequestPending(_)) + ), + "read back, then rotated: {rotated:?}" + ); + assert_eq!(rotation.lock().expect("rotation").len(), 1); + assert!(fixture.wallet.persister().has_read_back_private_entries()); + } } } } @@ -9430,7 +10118,14 @@ mod tests { publish(&fixture.wallet, 0, entries).await; let kept = fixture .coordinator - .private_contact_requests_held(&HashSet::from([held, absent]), 0) + .private_contact_requests_held( + &(HashSet::from([held, absent])) + .iter() + .map(|commitment| (*commitment, None)) + .collect(), + 0, + 0, + ) .await .expect("the store reads"); assert_eq!(kept, HashSet::from([held])); @@ -10366,7 +11061,7 @@ mod tests { let old_claim = old_wallet .identity() .dashpay() - .claim_public_pair(&alice.id(), &bob.id(), false) + .claim_public_pair(&alice.id(), &bob.id(), false, false) .await .expect("claimed") .expect("a claim"); @@ -10397,7 +11092,7 @@ mod tests { let new_claim = new_wallet .identity() .dashpay() - .claim_public_pair(&alice.id(), &bob.id(), false) + .claim_public_pair(&alice.id(), &bob.id(), false, false) .await .expect("claimed") .expect("a claim"); @@ -10539,11 +11234,11 @@ mod tests { ); } - /// 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. + /// A private send reads the store to its end first. While that read cannot + /// finish, here because the recipient of a request the store holds from + /// Alice cannot be read, it is refused as not read, with nothing reserved, + /// 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); @@ -10591,8 +11286,8 @@ mod tests { set_identity_known(&restarted, &bob, false).await; let held = send!(no_payment); assert!( - matches!(held, Err(PlatformWalletError::PrivateContactRequestPending(recipient)) - if recipient == bob.id()), + matches!(held, Err(PlatformWalletError::PrivateContactStoreNotRead(identity)) + if identity == alice.id()), "{held:?}" ); assert!(!store_read(&restarted, ALICE).await); @@ -10641,10 +11336,11 @@ mod tests { .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. + /// Every private send reads the store from its cursor before it pays, not + /// only the first since load: once a page read fails, a later send pays + /// nothing and the read's error comes back. #[tokio::test] - async fn should_read_the_private_store_once_since_load() { + async fn should_read_the_private_store_before_every_private_send() { let alice = identity(ALICE_PHRASE, ALICE); let bob = identity(BOB_PHRASE, BOB); let carol = identity(BOB_PHRASE, [0xC1; 32]); @@ -10714,11 +11410,11 @@ mod tests { 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:?}" + matches!(sent, Err(PlatformWalletError::Sdk(_))), + "the read before paying fails: {sent:?}" ); - assert_eq!(second.lock().expect("second").len(), 1, "no read first"); + assert!(second.lock().expect("second").is_empty(), "nothing paid"); + assert!(!pending_to(&alice_wallet, ALICE, &carol).await); } /// Once the account reference's rotation version is at its last, a 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 59e64cf312c..243092f26c7 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 @@ -815,19 +815,22 @@ impl ManagedIdentity { } /// Note that a private sync read DashPay's private contact request store - /// to its end for this identity, with nothing held back: counted once - /// the read reaches the height a rewind left as its floor - /// (`DashPayState::private_store_read_floor`), so a read from a node - /// behind the store already seen does not count. - pub(crate) fn note_private_contact_requests_read(&mut self) { + /// to its end for this identity, with nothing held back, the page that + /// reached the end proved at `end_height` or above (the lowest height of + /// its reads): counted only when that is at or above the height a rewind + /// left as its floor (`DashPayState::private_store_read_floor`), so a read + /// whose end comes from a node behind the store already seen does not + /// count, whatever the heights of the pages before it. Returns whether it + /// counted. + pub(crate) fn note_private_contact_requests_read(&mut self, end_height: u64) -> bool { let dashpay = &mut self.dashpay; - if dashpay.private_store_read_floor.is_none_or(|floor| { - dashpay - .private_store_read_height - .is_some_and(|read| read >= floor) - }) { + let counted = dashpay + .private_store_read_floor + .is_none_or(|floor| end_height >= floor); + if counted { dashpay.private_contact_requests_read = true; } + counted } /// Rewind this identity's read of DashPay's private contact request @@ -859,7 +862,7 @@ impl ManagedIdentity { /// crate that read an identity's state after a private sync. #[cfg(any(test, feature = "test-utils"))] pub fn note_private_contact_requests_read_for_tests(&mut self) { - self.note_private_contact_requests_read(); + self.dashpay.private_contact_requests_read = true; } /// Advance the private contact request cursor to `next_position` once diff --git a/packages/rs-platform-wallet/src/wallet/persister.rs b/packages/rs-platform-wallet/src/wallet/persister.rs index e5a2dec6e4c..c4efc9209c7 100644 --- a/packages/rs-platform-wallet/src/wallet/persister.rs +++ b/packages/rs-platform-wallet/src/wallet/persister.rs @@ -98,8 +98,31 @@ pub struct WalletPersister { /// last store or its flush failed. Shared by the clones the wallet and /// the coordinator hold. In memory only. stored_private_markers: Arc>>, + /// Why the last restoration of this wallet's private payments failed, + /// while no later one succeeded (`PlatformWalletError::PrivateContactPaymentsNotRestored` + /// then holds for it, not only for one not run yet). Shared by the + /// clones the wallet and its views hold. In memory only. + private_payment_restore_failure: Arc>>, + /// How the wallet's views run one more restoration of its private + /// payments, without waiting on the shielded store, before a contact + /// request is refused as not restored: installed by the wallet whenever + /// it builds a restoration with a shielded coordinator. Shared by the + /// clones the wallet and its views hold. In memory only. + #[cfg(feature = "shielded")] + private_payment_restore_retry: Arc>>, } +/// One more restoration of a wallet's private payments, run on the persister +/// it is given (`WalletPersister::retry_private_payment_restore`): taking it +/// as an argument, rather than holding one, keeps the persister that stores +/// it from holding itself. +#[cfg(feature = "shielded")] +pub(crate) type PrivatePaymentRestoreRetry = Arc< + dyn Fn(WalletPersister) -> std::pin::Pin + Send>> + + Send + + Sync, +>; + /// The commitments [`WalletPersister::note_discharged_private_entries`] noted, /// and the pairs the journal's unresolved payments hold, all updated under one /// lock. @@ -137,6 +160,59 @@ impl WalletPersister { discharged_private_entries: Arc::default(), private_payments_unrestored: Arc::default(), stored_private_markers: Arc::default(), + private_payment_restore_failure: Arc::default(), + #[cfg(feature = "shielded")] + private_payment_restore_retry: Arc::default(), + } + } + + /// Note how the last restoration of this wallet's private payments went: + /// `Some(reason)` when it failed, `None` once one succeeded + /// ([`Self::private_payment_restore_failure`]). + #[cfg(feature = "shielded")] + pub(crate) fn note_private_payment_restore(&self, failure: Option) { + *self + .private_payment_restore_failure + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) = failure; + } + + /// Why the last restoration of this wallet's private payments failed, if + /// it did and none succeeded since. + pub(crate) fn private_payment_restore_failure(&self) -> Option { + self.private_payment_restore_failure + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .clone() + } + + /// Install how the wallet's views run one more restoration of its private + /// payments ([`Self::retry_private_payment_restore`]). + #[cfg(feature = "shielded")] + pub(crate) fn set_private_payment_restore_retry(&self, retry: PrivatePaymentRestoreRetry) { + *self + .private_payment_restore_retry + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(retry); + } + + /// Run one more restoration of this wallet's private payments, which waits + /// on nothing: a busy shielded store leaves the wallet held. Returns + /// whether one ran, as none does before the wallet built a restoration + /// with a shielded coordinator. + #[cfg(feature = "shielded")] + pub(crate) async fn retry_private_payment_restore(&self) -> bool { + let retry = self + .private_payment_restore_retry + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .clone(); + match retry { + Some(retry) => { + retry(self.clone()).await; + true + } + None => false, } } diff --git a/packages/rs-platform-wallet/src/wallet/platform_wallet.rs b/packages/rs-platform-wallet/src/wallet/platform_wallet.rs index b7352868ba9..309bb465180 100644 --- a/packages/rs-platform-wallet/src/wallet/platform_wallet.rs +++ b/packages/rs-platform-wallet/src/wallet/platform_wallet.rs @@ -17,6 +17,8 @@ use super::asset_lock::manager::AssetLockManager; use super::asset_lock::tracked::TrackedAssetLock; use super::core::{CoreWallet, WalletBalance, WalletGeneration}; use super::identity::{IdentityManager, IdentityWallet}; +#[cfg(feature = "shielded")] +use super::persister::PrivatePaymentRestoreRetry; use super::persister::WalletPersister; #[cfg(feature = "shielded")] use super::platform_addresses::merge_platform_payment_candidate_addresses; @@ -50,7 +52,9 @@ use { super::shielded::operations::{ discharge_read_back_private_payments, private_document_create_from_pool_with_metadata, }, - super::shielded::store::{PendingRedrive, PrivateContactPayment, PrivatePaymentRecord}, + super::shielded::store::{ + PendingRedrive, PrivateContactPayment, PrivatePaymentEntry, PrivatePaymentRecord, + }, super::shielded::{ FileBackedShieldedStore, NetworkShieldedCoordinator, OrchardKeySet, ShieldedStore, }, @@ -66,6 +70,7 @@ use { dpp::shielded::builder::OrchardProver, dpp::state_transition::private_document_create_from_shielded_pool_transition::accessors::PrivateDocumentCreateFromShieldedPoolTransitionAccessorsV0, dpp::state_transition::StateTransition, + sha2::{Digest, Sha256}, }; /// Unique identifier for a wallet (32-byte hash). @@ -1902,10 +1907,50 @@ impl PlatformWallet { /// The handles a restoration of this wallet's private contact request /// payments runs on ([`Self::restore_private_contact_payments`]). #[cfg(feature = "shielded")] + /// + /// Installs on the persister one more restoration from `coordinator`, for + /// the wallet's views to run, without waiting on the store, before a + /// contact request is refused as not restored + /// (`WalletPersister::retry_private_payment_restore`): a failed or + /// deferred restoration then lifts at the next send. fn private_payment_restore( &self, coordinator: &Arc, ) -> PrivatePaymentRestore { + let retry: PrivatePaymentRestoreRetry = { + let coordinator = Arc::downgrade(coordinator); + let wallet_manager = Arc::clone(&self.wallet_manager); + let generation = Arc::clone(&self.generation); + let restores = Arc::clone(&self.private_payment_restores); + let (wallet_id, network) = (self.wallet_id, self.sdk.network); + Arc::new(move |persister: WalletPersister| { + let restore = coordinator + .upgrade() + .map(|coordinator| PrivatePaymentRestore { + coordinator, + wallet_manager: Arc::clone(&wallet_manager), + persister, + generation: Arc::clone(&generation), + restores: Arc::clone(&restores), + wallet_id, + network, + }); + Box::pin(async move { + let Some(restore) = restore else { + return; + }; + if let Err(error) = restore.restore_and_release(false).await { + tracing::warn!( + wallet_id = %hex::encode(wallet_id), + %error, + "private contact request payments not restored before a send; \ + the wallet's contact requests stay held" + ); + } + }) + }) + }; + self.persister.set_private_payment_restore_retry(retry); PrivatePaymentRestore { coordinator: Arc::clone(coordinator), wallet_manager: Arc::clone(&self.wallet_manager), @@ -2889,7 +2934,7 @@ impl DerefMut for WalletStateWriteGuard<'_> { fn associated_payment_commitments( redrive: &PendingRedrive, dashpay_id: Identifier, -) -> Result, PlatformWalletError> { +) -> Result, PlatformWalletError> { let corrupted = |reason: String| PlatformWalletError::ShieldedRecoveryCorrupted { account_index: None, reason, @@ -2928,7 +2973,7 @@ fn associated_payment_commitments( .entries() .iter() .map(|entry| { - entry + let commitment = entry .as_slice() .get(..32) .and_then(|commitment| <[u8; 32]>::try_from(commitment).ok()) @@ -2938,7 +2983,11 @@ fn associated_payment_commitments( commitment" .to_string(), ) - }) + })?; + Ok(PrivatePaymentEntry { + commitment, + entry_hash: Sha256::digest(entry.as_slice()).into(), + }) }) .collect() } @@ -3002,10 +3051,16 @@ impl PrivatePaymentRestore { match &restored { Ok(Some(_)) => { self.restores_lock().restored_from = Arc::downgrade(self.coordinator.store()); + self.persister.note_private_payment_restore(None); self.persister.private_payments_restored(); } Ok(None) if self.restored_from_this_store() => {} - Ok(None) | Err(_) => self.persister.hold_until_private_payments_restored(), + Ok(None) => self.persister.hold_until_private_payments_restored(), + Err(error) => { + self.persister + .note_private_payment_restore(Some(error.to_string())); + self.persister.hold_until_private_payments_restored(); + } } restored } @@ -3219,8 +3274,8 @@ where no request, so its pair cannot be restored", )); }; - for commitment in associated_payment_commitments(&redrive, dashpay_id)? { - if !journaled.contains(&commitment) { + for entry in associated_payment_commitments(&redrive, dashpay_id)? { + if !journaled.contains(&entry.commitment) { migrated.push(PrivatePaymentRecord { account_index: id.account_index, activity_id: redrive.activity_id, @@ -3229,9 +3284,13 @@ where // heights were kept, whose expiry is then never proven, // so its pair stays held. anchor_height: redrive.anchor_height, - commitment, + commitment: entry.commitment, payment: *payment, nullifiers: redrive.nullifiers.clone(), + entry_hash: Some(entry.entry_hash), + // Not kept with a record saved before the journal: its + // expiry reads the store from its start. + store_position: None, }); } } diff --git a/packages/rs-platform-wallet/src/wallet/shielded/coordinator.rs b/packages/rs-platform-wallet/src/wallet/shielded/coordinator.rs index 86a812ba70c..3720a2deefe 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/coordinator.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/coordinator.rs @@ -55,6 +55,7 @@ use std::collections::{BTreeMap, BTreeSet, HashSet}; use std::path::PathBuf; use std::sync::Arc; +use sha2::{Digest, Sha256}; use tokio_util::sync::CancellationToken; use crate::changeset::merge::Merge; @@ -486,16 +487,17 @@ impl NetworkShieldedCoordinator { &self.db_path } - /// Whether the store's private payment journal keeps a record of - /// `wallet_id`'s, or does not read: a wallet re-created under an id keeps - /// the unresolved private payments of the one removed under it, which only - /// the journal shows (`PlatformWalletManager::restore_private_contact_payment_holds`). - pub(crate) async fn journals_private_payments_of(&self, wallet_id: WalletId) -> bool { - self.store - .read() - .await - .private_payment_journal(wallet_id) - .map_or(true, |records| !records.is_empty()) + /// Whether the store's private payment journal may keep a record of + /// `wallet_id`'s: it does, it does not read, or the store is busy, as this + /// waits on nothing. A wallet created or loaded under an id another process + /// journaled payments under has them unresolved, which only the journal + /// shows (`PlatformWalletManager::restore_private_contact_payment_holds`). + pub(crate) fn may_journal_private_payments_of(&self, wallet_id: WalletId) -> bool { + self.store.try_read().map_or(true, |store| { + store + .private_payment_journal(wallet_id) + .map_or(true, |records| !records.is_empty()) + }) } /// Reference to the shared store. The full sync / spend @@ -2060,12 +2062,19 @@ impl NetworkShieldedCoordinator { if candidates.is_empty() { return; } - let commitments: HashSet<[u8; 32]> = candidates + let commitments: BTreeMap<[u8; 32], Option<[u8; 32]>> = candidates .iter() - .map(|(_, record)| record.commitment) + .map(|(_, record)| (record.commitment, record.entry_hash)) .collect(); + // Each entry is appended at or after its sender's read position when + // it paid; a record that kept none is read for from the store's start. + let start = candidates + .iter() + .map(|(_, record)| record.store_position) + .try_fold(u64::MAX, |lowest, position| position.map(|p| lowest.min(p))) + .unwrap_or(0); let held = match self - .private_contact_requests_held(&commitments, recorded_height) + .private_contact_requests_held(&commitments, start, recorded_height) .await { Ok(held) => held, @@ -2119,21 +2128,26 @@ impl NetworkShieldedCoordinator { } /// 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. + /// read now from `start` through the end its first page shows, raw: an + /// entry starts with its commitment, and is taken as the candidate's only + /// when the whole entry hashes to the hash the candidate's journal record + /// kept (`PrivatePaymentRecord::entry_hash`), as anyone who saw the + /// commitment can append an entry starting with it; a record saved before + /// the hash was kept matches on the 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]>, + candidates: &BTreeMap<[u8; 32], Option<[u8; 32]>>, + start: u64, floor: u64, ) -> Result, PlatformWalletError> { let contract_id = dashpay_contract()?.id(); let mut pages = std::pin::pin!(self.sdk.private_document_pages_with_heights( contract_id, PRIVATE_CONTACT_REQUEST_DOCUMENT_TYPE_NAME, - 0, + start, MAX_CONCURRENT_PRIVATE_DOCUMENT_PAGE_FETCHES, )); let mut held = HashSet::new(); @@ -2151,12 +2165,18 @@ impl NetworkShieldedCoordinator { } read = true; for entry in page.entries.entries { - if let Some(commitment) = entry + let Some(commitment) = entry .data .get(..32) .and_then(|commitment| <[u8; 32]>::try_from(commitment).ok()) - .filter(|commitment| candidates.contains(commitment)) - { + else { + continue; + }; + let matches = candidates.get(&commitment).is_some_and(|hash| match hash { + Some(hash) => <[u8; 32]>::from(Sha256::digest(&entry.data)) == *hash, + None => true, + }); + if matches { held.insert(commitment); } } @@ -4810,6 +4830,8 @@ mod tests { account_reference: 0, }, nullifiers: vec![[0x62; 32], [0x65; 32]], + entry_hash: None, + store_position: None, }, )]; let floors = heightless_anchor_floors(&store, &stale, &journal); 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 f75d5e6b467..0fda2b8f3b4 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/file_store.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/file_store.rs @@ -23,7 +23,7 @@ use grovedb_commitment_tree::{ClientPersistentCommitmentTree, Position, Retentio use super::balance::{ShieldedBalanceSource, ShieldedLocalAccountBalance}; use super::store::{ - PendingRedrive, PrivateContactPayment, PrivatePaymentRecord, ShieldedNote, + PendingRedrive, PrivateContactPayment, PrivatePaymentEntry, PrivatePaymentRecord, ShieldedNote, ShieldedOutgoingNote, ShieldedStore, StalePendingSpend, SubwalletId, SubwalletState, }; use crate::error::PlatformWalletError; @@ -1125,19 +1125,22 @@ impl ShieldedStore for FileBackedShieldedStore { id: SubwalletId, redrive: PendingRedrive, payment: PrivateContactPayment, - commitments: &[[u8; 32]], + entries: &[PrivatePaymentEntry], anchor_height: u64, + store_position: Option, ) -> Result<(), Self::Error> { - let records: Vec = commitments + let records: Vec = entries .iter() - .map(|commitment| PrivatePaymentRecord { + .map(|entry| PrivatePaymentRecord { account_index: id.account_index, activity_id: redrive.activity_id, anchor: redrive.anchor, anchor_height: Some(anchor_height), - commitment: *commitment, + commitment: entry.commitment, payment, nullifiers: redrive.nullifiers.clone(), + entry_hash: Some(entry.entry_hash), + store_position, }) .collect(); self.arm_redrive_paying_for(id, redrive, Some(payment), &records) @@ -1837,7 +1840,14 @@ mod tests { { let mut store = FileBackedShieldedStore::open_path(&path, 100).expect("open"); store - .arm_private_contact_payment(id, record(1, 3), payment, &[[0xC1; 32]], 0) + .arm_private_contact_payment( + id, + record(1, 3), + payment, + &[PrivatePaymentEntry::for_commitment([0xC1; 32])], + 0, + None, + ) .expect("arm the payment"); store.arm_redrive(id, record(4, 6)).expect("arm a spend"); store.bump_redrive_attempts(id, &[1u8; 32]).expect("bump"); @@ -1907,8 +1917,9 @@ mod tests { id, record(activity, nullifier), payment, - &[[activity; 32]], + &[PrivatePaymentEntry::for_commitment([activity; 32])], 0, + None, ) .expect("armed"); }; @@ -2138,7 +2149,14 @@ mod tests { let arm = || { let mut store = FileBackedShieldedStore::open_path(&path, 100).unwrap(); store - .arm_private_contact_payment(id, record.clone(), payment, &[[0xC2; 32]], 0) + .arm_private_contact_payment( + id, + record.clone(), + payment, + &[PrivatePaymentEntry::for_commitment([0xC2; 32])], + 0, + None, + ) .unwrap(); }; let set = |assignment: &str| { @@ -3049,6 +3067,8 @@ mod tests { commitment: [0xC3; 32], payment, nullifiers: redrive.nullifiers.clone(), + entry_hash: Some([0; 32]), + store_position: None, }; let mut store = FileBackedShieldedStore::open_path(&path, 100).expect("open"); assert!(!store @@ -3058,7 +3078,14 @@ mod tests { store.mark_pending(id, nullifier).expect("reserved"); } store - .arm_private_contact_payment(id, redrive.clone(), payment, &[[0xC3; 32]], 0) + .arm_private_contact_payment( + id, + redrive.clone(), + payment, + &[PrivatePaymentEntry::for_commitment([0xC3; 32])], + 0, + None, + ) .expect("armed"); assert!(store .private_payment_journal_open(wallet_id) @@ -3169,7 +3196,14 @@ mod tests { 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) + .arm_private_contact_payment( + id, + redrive.clone(), + payment, + &[PrivatePaymentEntry::for_commitment(commitment)], + 0, + None, + ) .expect("armed"); match case { "write fails" => store @@ -3261,8 +3295,9 @@ mod tests { anchor_height: None, }, payment, - &[[0xD4; 32]], + &[PrivatePaymentEntry::for_commitment([0xD4; 32])], 0, + None, ) .expect("armed"); store diff --git a/packages/rs-platform-wallet/src/wallet/shielded/mod.rs b/packages/rs-platform-wallet/src/wallet/shielded/mod.rs index db547420497..f66300eac15 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/mod.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/mod.rs @@ -71,8 +71,8 @@ pub use prover::CachedOrchardProver; pub use seed_pool::{SeedPoolOutcome, SeedPoolProgress, DEFAULT_SEED_POOL_TARGET_NOTES}; pub use store::{ IdentityDebitRecoveryRecord, IdentityDebitRecoveryStatus, InMemoryShieldedStore, - PrivateContactPayment, PrivatePaymentRecord, ShieldedNote, ShieldedOutgoingNote, ShieldedStore, - SubwalletId, + PrivateContactPayment, PrivatePaymentEntry, 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 eeb1776448a..9105c5dd007 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/operations.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/operations.rs @@ -29,7 +29,8 @@ use super::note_selection::{ select_notes_with_fee, ShieldedFeeKind, }; use super::store::{ - PendingRedrive, PrivateContactPayment, ShieldedNote, ShieldedStore, SubwalletId, + PendingRedrive, PrivateContactPayment, PrivatePaymentEntry, ShieldedNote, ShieldedStore, + SubwalletId, }; use crate::broadcast_outcome::{ broadcast_definitely_failed, broadcast_tracking_delivery, carries_consensus_rejection, @@ -40,6 +41,7 @@ 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; +use sha2::{Digest, Sha256}; use std::collections::{BTreeMap, HashSet}; use std::future::Future; @@ -3868,7 +3870,7 @@ pub(crate) async fn arm_redrive_before_broadcast( let journal = match private_contact { Some(payment) => Some(( payment, - private_document_commitments(state_transition).ok_or_else(|| { + private_document_entries(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" @@ -3889,12 +3891,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( + Some((payment, entries)) => store.arm_private_contact_payment( id, redrive, payment, - &commitments, + &entries, anchor_recorded_at, + payment_started.and_then(PaymentStarted::store_position), ), None => store.arm_redrive(id, redrive), } @@ -3940,6 +3943,30 @@ pub(crate) fn discharge_read_back_private_payments( } } +/// [`private_document_commitments`], each with the SHA-256 of its whole entry, +/// as a private contact request payment's journal records keep them. +pub(crate) fn private_document_entries( + 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; + }; + let commitments = private_document_commitments(state_transition)?; + Some( + commitments + .into_iter() + .zip(transition.entries()) + .map(|(commitment, entry)| PrivatePaymentEntry { + commitment, + entry_hash: Sha256::digest(entry.as_slice()).into(), + }) + .collect(), + ) +} + /// 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 @@ -5368,8 +5395,9 @@ mod redrive_tests { anchor_height: None, }, payment, - &[[0xC4; 32]], + &[PrivatePaymentEntry::for_commitment([0xC4; 32])], 0, + None, ) .expect("the newer payment saved"); drop(held); diff --git a/packages/rs-platform-wallet/src/wallet/shielded/store.rs b/packages/rs-platform-wallet/src/wallet/shielded/store.rs index 055d01ce9b5..bce8c76235f 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/store.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/store.rs @@ -257,6 +257,39 @@ pub struct PrivatePaymentRecord { pub payment: PrivateContactPayment, /// The nullifiers of the notes its spend consumes; never empty. pub nullifiers: Vec<[u8; 32]>, + /// SHA-256 of the whole entry it appends: the expiry's read of the store + /// takes the payment as executed only for an entry that hashes to it, not + /// for any entry that merely starts with [`Self::commitment`], which + /// anyone who saw the commitment can append. `None` for a record saved + /// before it was kept (format version 3), which matches on the commitment. + pub entry_hash: Option<[u8; 32]>, + /// The sender identity's private store read position when the payment was + /// made: its entry is appended at or after it, so the expiry's read of the + /// store starts there. `None` for a record saved before it was kept, or + /// with no position known, which is read from the store's start. + pub store_position: Option, +} + +/// An entry a private contact request payment appends, as its journal record +/// keeps it ([`ShieldedStore::arm_private_contact_payment`]). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct PrivatePaymentEntry { + /// The entry's first 32 bytes, which key its record. + pub commitment: [u8; 32], + /// SHA-256 of the whole entry ([`PrivatePaymentRecord::entry_hash`]). + pub entry_hash: [u8; 32], +} + +impl PrivatePaymentEntry { + /// An entry of `commitment` with a placeholder hash, for tests whose + /// expiry never reads the store. + #[cfg(test)] + pub(crate) fn for_commitment(commitment: [u8; 32]) -> Self { + Self { + commitment, + entry_hash: [0; 32], + } + } } impl PrivatePaymentRecord { @@ -266,8 +299,11 @@ impl PrivatePaymentRecord { /// 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; + /// wallet's holds stay. Version 3, written before the entry's hash and the + /// store position were kept, reads back with neither. + const FORMAT_VERSION: u8 = 4; + /// The version before [`Self::FORMAT_VERSION`], still read. + const FORMAT_VERSION_WITHOUT_ENTRY_HASH: u8 = 3; /// The bytes before the nullifiers: version, account index, activity id, /// anchor, anchor height, commitment, sender, recipient, account reference /// and nullifier count. @@ -277,7 +313,9 @@ impl PrivatePaymentRecord { /// 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 + /// little-endian `u16`, the nullifiers, then the entry's hash and the + /// store position, each a presence byte (0 or 1) and, when present, the + /// 32 bytes or the position little-endian. `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()?; @@ -303,15 +341,29 @@ impl PrivatePaymentRecord { for nullifier in &self.nullifiers { bytes.extend_from_slice(nullifier); } + match &self.entry_hash { + Some(hash) => { + bytes.push(1); + bytes.extend_from_slice(hash); + } + None => bytes.push(0), + } + match self.store_position { + Some(position) => { + bytes.push(1); + bytes.extend_from_slice(&position.to_le_bytes()); + } + None => bytes.push(0), + } 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. + /// The record [`Self::to_bytes`] wrote, or one of version 3, or `None` + /// for any other bytes: another 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 { + if version != Self::FORMAT_VERSION && version != Self::FORMAT_VERSION_WITHOUT_ENTRY_HASH { return None; } let (account_index, rest) = rest.split_first_chunk::<4>()?; @@ -324,10 +376,40 @@ impl PrivatePaymentRecord { 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>(); + let (nullifiers, rest) = rest.split_at_checked(32 * count)?; + let (nullifiers, remainder) = nullifiers.as_chunks::<32>(); if count == 0 || nullifiers.len() != count || !remainder.is_empty() { return None; } + let (entry_hash, store_position) = if version == Self::FORMAT_VERSION { + let (&has_hash, rest) = rest.split_first()?; + let (entry_hash, rest) = match has_hash { + 0 => (None, rest), + 1 => { + let (hash, rest) = rest.split_first_chunk::<32>()?; + (Some(*hash), rest) + } + _ => return None, + }; + let (&has_position, rest) = rest.split_first()?; + let (store_position, rest) = match has_position { + 0 => (None, rest), + 1 => { + let (position, rest) = rest.split_first_chunk::<8>()?; + (Some(u64::from_le_bytes(*position)), rest) + } + _ => return None, + }; + if !rest.is_empty() { + return None; + } + (entry_hash, store_position) + } else { + if !rest.is_empty() { + return None; + } + (None, None) + }; Some(Self { account_index: u32::from_le_bytes(*account_index), activity_id: *activity_id, @@ -341,6 +423,8 @@ impl PrivatePaymentRecord { account_reference: u32::from_le_bytes(*account_reference), }, nullifiers: nullifiers.to_vec(), + entry_hash, + store_position, }) } } @@ -534,8 +618,9 @@ pub trait ShieldedStore: Send + Sync + 'static { /// [`Self::arm_redrive`] for a spend paying for the DashPay private /// 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 + /// spend appends (`entries`, each with the hash of the whole entry), + /// which outlives it ([`PrivatePaymentRecord`]), with the sender's private + /// store read position when it paid (`store_position`). 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. @@ -546,8 +631,9 @@ pub trait ShieldedStore: Send + Sync + 'static { id: SubwalletId, redrive: PendingRedrive, payment: PrivateContactPayment, - commitments: &[[u8; 32]], + entries: &[PrivatePaymentEntry], anchor_height: u64, + store_position: Option, ) -> Result<(), Self::Error>; /// The armed records of `wallet_id` that pay for a private contact @@ -1397,28 +1483,31 @@ impl ShieldedStore for InMemoryShieldedStore { id: SubwalletId, redrive: PendingRedrive, payment: PrivateContactPayment, - commitments: &[[u8; 32]], + entries: &[PrivatePaymentEntry], anchor_height: u64, + store_position: Option, ) -> 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() { + if !entries.is_empty() { self.private_payment_journals.insert(id.wallet_id); } - for commitment in commitments { + for entry in entries { self.private_payment_journal.insert( - (id.wallet_id, *commitment), + (id.wallet_id, entry.commitment), PrivatePaymentRecord { account_index: id.account_index, activity_id: redrive.activity_id, anchor: redrive.anchor, anchor_height: Some(anchor_height), - commitment: *commitment, + commitment: entry.commitment, payment, nullifiers: redrive.nullifiers.clone(), + entry_hash: Some(entry.entry_hash), + store_position, }, ); } @@ -2166,6 +2255,8 @@ mod tests { account_reference: 3, }, nullifiers: vec![[0x66; 32]], + entry_hash: None, + store_position: None, }; for anchor_height in [None, Some(0), Some(41)] { let bytes = record(anchor_height).to_bytes().expect("encodes"); From aa0e2a28c1f951fc58f999381f70b13cf8699da2 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Tue, 10 Nov 2026 16:08:20 +0700 Subject: [PATCH 225/269] fix(platform-wallet): sync the shielded recovery records to the drive itself on Apple platforms synchronous=FULL syncs with fsync, which on Apple platforms does not flush the drive's own cache. The recovery connection now also sets fullfsync, so a journal record reported durable survives a power loss there too. SQLite ignores the pragma elsewhere. Co-Authored-By: Claude Opus 5.5 --- .../src/wallet/shielded/file_store.rs | 26 +++++++++++++++++++ 1 file changed, 26 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 0fda2b8f3b4..1b22d0a32f7 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/file_store.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/file_store.rs @@ -821,6 +821,12 @@ impl FileBackedShieldedStore { .map_err(|e| { FileShieldedStoreError::Storage(format!("PRAGMA synchronous=FULL: {e}")) })?; + // On Apple platforms `synchronous=FULL` syncs with `fsync`, which does + // not flush the drive's own cache; `fullfsync` makes it `F_FULLFSYNC`, + // so a record that returned durable survives a power loss there too. + // Elsewhere SQLite ignores it. + conn.pragma_update(None, "fullfsync", "ON") + .map_err(|e| FileShieldedStoreError::Storage(format!("PRAGMA fullfsync=ON: {e}")))?; Ok(conn) } @@ -3125,6 +3131,26 @@ mod tests { let _ = std::fs::remove_file(&path); } + /// The connection the durable records are written through syncs to the + /// drive itself on Apple platforms (`fullfsync`), not only to the OS, with + /// `synchronous=FULL`. + #[test] + fn should_open_the_recovery_connection_with_full_syncs() { + let path = temp_tree_path("recovery_pragmas"); + let conn = + FileBackedShieldedStore::open_recovery_connection(&path).expect("the connection"); + let fullfsync: i64 = conn + .pragma_query_value(None, "fullfsync", |row| row.get(0)) + .expect("fullfsync"); + let synchronous: i64 = conn + .pragma_query_value(None, "synchronous", |row| row.get(0)) + .expect("synchronous"); + assert_eq!(fullfsync, 1); + assert_eq!(synchronous, 2, "FULL"); + drop(conn); + let _ = std::fs::remove_file(&path); + } + /// The bound on the trees that anchors named without their heights came /// from is the largest tree the store has held: none for a store that /// held no tree when it was opened, kept as it was at open while the tree From c7606fc33c785ad2900820bb3a53f7b035b16f46 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Tue, 10 Nov 2026 16:08:33 +0700 Subject: [PATCH 226/269] fix(platform-wallet): drop read-back notices a backend lost and discharge records a restoration finds settled A read-back notice is kept for a later flush only when the flush failed transiently, the backend keeping what it buffered. On any other failure the records the notice rests on are gone, so the notice is dropped and the journal record, and the pair it holds, stays. The read is not rewound: a read again finds the request already tracked, stores nothing, and would note it again over a record that never reached disk. The cache of stored markers is cleared, so the next comparison stores them again. A restoration that finds a journal record its sender's tracked request already records or supersedes, read back by a process that ended before discharging it, now discharges it, so its store is not read for it on every launch. A busy store leaves it for the next restoration. Co-Authored-By: Claude Opus 5.5 --- .../network/private_contact_requests.rs | 112 +++++++++++++++++- .../src/wallet/persister.rs | 58 ++++++--- .../src/wallet/platform_wallet.rs | 33 +++++- 3 files changed, 181 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 14b615c47d1..830f30381d2 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 @@ -464,6 +464,12 @@ pub(crate) struct ReservedPrivatePayments { /// hold on each pair until its record is discharged. #[cfg_attr(not(feature = "shielded"), allow(dead_code))] pub(crate) unresolved: Vec<([u8; 32], Identifier, Identifier)>, + /// The commitments of the payments whose request the sender's tracked + /// request to its recipient records or supersedes, as a private sync that + /// read the entry back settled it: their journal records are discharged, + /// rather than read for again on every launch. + #[cfg_attr(not(feature = "shielded"), allow(dead_code))] + pub(crate) settled: Vec<[u8; 32]>, } /// The one step every restoration of a private contact request payment's @@ -482,7 +488,9 @@ pub(crate) struct ReservedPrivatePayments { /// 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; +/// or supersedes, as a private sync that recorded it settled it, returned in +/// [`ReservedPrivatePayments::settled`] for its journal record to be +/// discharged; /// - one whose sender the wallet does not manage, returned in /// [`ReservedPrivatePayments::unresolved`] for the wallet to hold its pair. /// @@ -514,6 +522,7 @@ pub(crate) fn reserve_unresolved_private_payments( .outgoing_request(&recipient) .is_some_and(|tracked| records_or_supersedes(tracked, account_reference)) { + reserved.settled.push(commitment); continue; } reserved.unresolved.push((commitment, sender, recipient)); @@ -543,7 +552,7 @@ pub(crate) async fn restore_journaled_private_payments( wallet_id: WalletId, payments: &[JournaledPrivatePayment], journal_authoritative: bool, -) -> Result, PlatformWalletError> { +) -> Result<(Vec<(Identifier, Identifier)>, Vec<[u8; 32]>), PlatformWalletError> { let mut wm = wallet_manager.write().await; let info = wm .get_wallet_info_mut(&wallet_id) @@ -551,6 +560,7 @@ pub(crate) async fn restore_journaled_private_payments( let ReservedPrivatePayments { restored, unresolved, + settled, } = reserve_unresolved_private_payments( info, generation, wallet_id, persister, payments, None, )?; @@ -599,7 +609,7 @@ pub(crate) async fn restore_journaled_private_payments( ), }); } - Ok(restored) + Ok((restored, settled)) } /// Store `owner`'s private read position record again, for a backend that @@ -2407,6 +2417,7 @@ impl DashPayView<'_, B> { journal_authoritative, ) .await + .map(|(restored, _)| restored) } /// Whether an earlier private payment from `sender` to `recipient` that @@ -2591,7 +2602,7 @@ mod tests { use super::*; use crate::changeset::{ ClientStartState, ClientWalletStartState, IdentityManagerStartState, - PersistenceCapabilities, PersistenceError, PlatformWalletChangeSet, + PersistenceCapabilities, PersistenceError, PersistenceErrorKind, PlatformWalletChangeSet, PlatformWalletPersistence, }; use crate::events::{EventHandler, PlatformEventHandler}; @@ -2820,8 +2831,12 @@ mod tests { /// While set, a store carrying a private read position record fails /// and keeps nothing. positions_fail: AtomicBool, - /// While set, a flush fails. + /// While set, a flush fails, transiently: the backend keeps what it + /// buffered. flush_fails: AtomicBool, + /// While set, a flush fails fatally: the backend drops what it + /// buffered. + flush_fails_fatally: AtomicBool, } impl RecordingPersistence { @@ -2831,6 +2846,7 @@ mod tests { stored: Mutex::new(Vec::new()), positions_fail: AtomicBool::new(false), flush_fails: AtomicBool::new(false), + flush_fails_fatally: AtomicBool::new(false), }) } @@ -2865,7 +2881,13 @@ mod tests { fn flush(&self, _wallet_id: WalletId) -> Result<(), PersistenceError> { if self.flush_fails.load(Ordering::SeqCst) { - return Err(PersistenceError::backend("the disk is full")); + return Err(PersistenceError::backend_with_kind( + PersistenceErrorKind::Transient, + "the disk is busy", + )); + } + if self.flush_fails_fatally.load(Ordering::SeqCst) { + return Err(PersistenceError::backend("the database is corrupt")); } Ok(()) } @@ -3474,6 +3496,84 @@ mod tests { assert_eq!(retry.lock().expect("retry").len(), 1, "the retry pays"); } + /// A read-back whose records' flush fails for good, the backend dropping + /// what it buffered, drops its notice: the journal record stays, the pair + /// held, and a later flush that succeeds notes nothing. + #[cfg(feature = "shielded")] + #[tokio::test] + async fn should_drop_a_read_back_notice_whose_records_the_backend_dropped() { + 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; + fixture + .wallet + .restore_private_contact_payments(&fixture.coordinator) + .await + .expect("restored"); + let persister = fixture.wallet.persister(); + publish(&fixture.wallet, 0, vec![entry.to_vec()]).await; + fixture + .persistence + .flush_fails_fatally + .store(true, Ordering::SeqCst); + let view = fixture.wallet.identity().dashpay(); + view.sync_private_contact_requests(&alice_crypto, 4) + .await + .expect("the entry is read back"); + fixture + .persistence + .flush_fails_fatally + .store(false, Ordering::SeqCst); + view.sync_private_contact_requests(&alice_crypto, 4) + .await + .expect("the next private sync"); + assert!(!persister.has_read_back_private_entries(), "dropped"); + fixture + .coordinator + .discharge_read_back_private_payments() + .await; + assert_eq!(fixture.journaled().await, 1, "the record stays"); + assert!(persister.private_pair_journaled(&alice.id(), &bob.id())); + } + + /// A restoration that finds a journal record its sender's tracked request + /// already records, as a private sync read it back in a process that ended + /// before the shielded sync discharged it, discharges the record. + #[cfg(feature = "shielded")] + #[tokio::test] + async fn should_discharge_a_journal_record_a_restoration_finds_settled() { + 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; + publish(&fixture.wallet, 0, vec![entry.to_vec()]).await; + fixture + .wallet + .identity() + .dashpay() + .sync_private_contact_requests(&alice_crypto, 4) + .await + .expect("the entry is read back"); + // The process ends before the shielded sync discharges it. + let _ = fixture.wallet.persister().take_read_back_private_entries(); + assert_eq!(fixture.journaled().await, 1); + fixture + .wallet + .restore_private_contact_payments(&fixture.coordinator) + .await + .expect("restored"); + assert_eq!( + fixture.journaled().await, + 0, + "discharged by the restoration" + ); + } + /// An identity reloaded from a position record stored with its marker /// cleared, as a discharge leaves it, is not held, in any build (in one /// with the `shielded` feature once the store is configured, which an diff --git a/packages/rs-platform-wallet/src/wallet/persister.rs b/packages/rs-platform-wallet/src/wallet/persister.rs index c4efc9209c7..474c65af6a6 100644 --- a/packages/rs-platform-wallet/src/wallet/persister.rs +++ b/packages/rs-platform-wallet/src/wallet/persister.rs @@ -256,12 +256,13 @@ impl WalletPersister { /// recorded, for their journal records to be discharged /// ([`Self::note_read_back_private_entries`]), once what records them is /// durable: at once for a backend that commits inline, after a flush - /// otherwise. Those a flush failed for are kept, with every one kept - /// before, and noted by the next call whose flush succeeds: an empty one - /// retries them. Their records, and so the pairs they hold, stay until - /// then. The host's flush runs with none of the persister's locks held, - /// so a call during it, from another thread or the flush itself, neither - /// waits nor loses what it adds. + /// otherwise. Those a transient flush failure left are kept, with every + /// one kept before, and noted by the next call whose flush succeeds: an + /// empty one retries them. Those any other failure left are dropped, as the + /// backend dropped the records they rest on. Their journal records, and so + /// the pairs they hold, stay until noted. The host's flush runs with none + /// of the persister's locks held, so a call during it, from another thread + /// or the flush itself, neither waits nor loses what it adds. #[cfg(feature = "shielded")] pub(crate) fn note_read_back_once_durable( &self, @@ -277,14 +278,36 @@ impl WalletPersister { } if !self.store_commits_inline() { if let Err(error) = self.flush() { - tracing::warn!( - %error, - kept = pending.len(), - "private requests read back are not durably recorded; their journal \ - records stay until a later flush succeeds" - ); - // With any added meanwhile. - self.unflushed_read_back_entries().extend(pending); + if error.is_transient() { + tracing::warn!( + %error, + kept = pending.len(), + "private requests read back are not durably recorded; their journal \ + records stay until a later flush succeeds" + ); + // A transient failure keeps the buffer: with any added + // meanwhile. + self.unflushed_read_back_entries().extend(pending); + } else { + // Any other failure dropped the buffer, the records these + // notices rest on with it: the notices go, so their journal + // records, and the pairs they hold, stay. Their read is not + // rewound: a read again would find the requests already + // tracked, store nothing, and note them again over records + // that never reached disk. The markers stored into that + // buffer are forgotten, so the next comparison stores them + // again (`Self::private_marker_stored`). + tracing::error!( + %error, + dropped = pending.len(), + "private requests read back are not durably recorded, and the backend \ + dropped what it buffered; their journal records stay" + ); + self.stored_private_markers + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .clear(); + } return; } } @@ -630,6 +653,8 @@ impl PlatformWalletPersistence for NoPlatformPersistence { mod tests { use super::*; #[cfg(feature = "shielded")] + use crate::changeset::PersistenceErrorKind; + #[cfg(feature = "shielded")] use std::sync::{mpsc, OnceLock}; #[cfg(feature = "shielded")] use std::time::Duration; @@ -690,7 +715,10 @@ mod tests { } } if self.flush_fails.load(Ordering::SeqCst) { - return Err(PersistenceError::backend("the disk is full")); + return Err(PersistenceError::backend_with_kind( + PersistenceErrorKind::Transient, + "the disk is busy", + )); } Ok(()) } diff --git a/packages/rs-platform-wallet/src/wallet/platform_wallet.rs b/packages/rs-platform-wallet/src/wallet/platform_wallet.rs index 309bb465180..8d9530c343f 100644 --- a/packages/rs-platform-wallet/src/wallet/platform_wallet.rs +++ b/packages/rs-platform-wallet/src/wallet/platform_wallet.rs @@ -3155,7 +3155,7 @@ impl PrivatePaymentRestore { }; read_private_payment_journal(&mut *store, &self.persister, self.wallet_id, dashpay_id)? }; - let restored = restore_journaled_private_payments( + let (restored, settled) = restore_journaled_private_payments( &self.wallet_manager, &self.generation, &self.persister, @@ -3164,6 +3164,37 @@ impl PrivatePaymentRestore { journal_authoritative, ) .await?; + // A record whose request the tracked one records or supersedes was + // read back by an earlier process, which ended before it discharged + // it: discharged now, so its store is not read for it on every launch. + // A busy store leaves it for the next restoration. + if !settled.is_empty() { + let store = if wait { + Some(self.coordinator.store().write().await) + } else { + self.coordinator.store().try_write().ok() + }; + if let Some(mut store) = store { + let discharged: Vec<[u8; 32]> = settled + .into_iter() + .filter(|commitment| { + store + .discharge_private_payment(self.wallet_id, commitment) + .inspect_err(|error| { + tracing::warn!( + wallet_id = %hex::encode(self.wallet_id), + %error, + "a settled private payment's journal record was not \ + discharged; the next restoration discharges it" + ) + }) + .is_ok() + }) + .collect(); + drop(store); + self.persister.note_discharged_private_entries(discharged); + } + } // 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 From 9b5df14b831c4cbffbf3c1720778ed61efee740a Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Tue, 10 Nov 2026 16:08:33 +0700 Subject: [PATCH 227/269] test(platform-wallet): cover forged entries, the expiry's start position and mixed-height store walks An entry that only starts with a journal record's commitment does not show the record's payment executed, and the record expires. The expiry's read of the store starts at the position the record kept. Two mixed-height walks, a full page from a node behind after a larger total and a rewound read whose middle page alone reaches the floor, each leave a private request at the position a per-page end would omit; the public request is refused once that position is read. Co-Authored-By: Claude Opus 5.5 --- .../network/private_contact_requests.rs | 238 ++++++++++++++++++ 1 file changed, 238 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 830f30381d2..4a3cac49f49 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 @@ -3540,6 +3540,91 @@ mod tests { assert!(persister.private_pair_journaled(&alice.id(), &bob.id())); } + /// An entry that only starts with a journal record's commitment, as anyone + /// who saw the commitment can append, does not show the record's payment + /// executed: the record expires once its anchor is proven pruned. + #[cfg(feature = "shielded")] + #[tokio::test] + async fn should_not_take_an_entry_starting_with_a_commitment_as_its_payment() { + let fixture = ExpiryFixture::new().await; + let height = ExpiryFixture::ANCHOR_SET_HEIGHT; + fixture.prove_at(height); + let entry = fixture.pay_unconfirmed(ExpiryFixture::ANCHOR, height).await; + let mut forged = entry.to_vec()[..32].to_vec(); + forged.extend_from_slice(&[0xEE; 64]); + publish(&fixture.wallet, 0, vec![forged]).await; + let journal = fixture.journal_snapshot().await; + assert!( + journal[0].1.entry_hash.is_some(), + "a new record keeps the hash" + ); + fixture + .discharge(&journal, &HashSet::new(), Some(height)) + .await; + fixture.assert_discharged("forged entry").await; + } + + /// The expiry's read of the store starts at the sender's read position the + /// record kept: a store whose earlier pages no longer answer is read from + /// there, and the record discharged. + #[cfg(feature = "shielded")] + #[tokio::test] + async fn should_read_the_store_for_an_expiry_from_the_position_the_record_kept() { + use crate::wallet::shielded::PrivatePaymentRecord; + + let alice = identity(ALICE_PHRASE, ALICE); + let bob = identity(BOB_PHRASE, BOB); + let fixture = ExpiryFixture::new().await; + let height = ExpiryFixture::ANCHOR_SET_HEIGHT; + fixture.prove_at(height); + let wallet_id = fixture.wallet.wallet_id(); + let record = PrivatePaymentRecord { + account_index: 0, + activity_id: [0x71; 32], + anchor: ExpiryFixture::ANCHOR, + anchor_height: Some(height), + commitment: [0x72; 32], + payment: PrivateContactPayment { + sender: alice.id().to_buffer(), + recipient: bob.id().to_buffer(), + account_reference: 1, + }, + nullifiers: vec![fixture.input.nullifier], + entry_hash: Some([0x73; 32]), + store_position: Some(2), + }; + fixture + .coordinator + .store() + .write() + .await + .journal_private_payments(wallet_id, std::slice::from_ref(&record)) + .expect("journaled"); + let contract = dashpay_contract().expect("bundled DashPay"); + { + let mut sdk = (*fixture.wallet.identity().sdk).clone(); + sdk.mock() + .remove_fetch_expectation::( + PrivateDocumentEntriesQuery::new( + contract.id(), + PRIVATE_CONTACT_REQUEST_DOCUMENT_TYPE_NAME, + 0, + ), + ) + .await; + } + publish(&fixture.wallet, 2, vec![]).await; + let journal = fixture.journal_snapshot().await; + fixture + .discharge(&journal, &HashSet::new(), Some(height)) + .await; + assert_eq!( + fixture.journaled().await, + 0, + "read from position 2, discharged" + ); + } + /// A restoration that finds a journal record its sender's tracked request /// already records, as a private sync read it back in a process that ended /// before the shielded sync discharged it, discharges the record. @@ -3574,6 +3659,159 @@ mod tests { ); } + /// The store's end as the pass's highest proved total: with chunks of two, + /// the first page proves five entries at H, and the full second page, from + /// a node a block behind, proves only four and its own end. The public + /// request reads on to position 4, where Alice's private request is, and is + /// refused. + #[tokio::test] + async fn should_read_past_a_full_page_from_a_node_behind_before_a_public_request() { + const H: u64 = 20; + 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![]).await; + serve_pages( + &bob_wallet, + H, + vec![ + (0, vec![vec![0xA0; 589], vec![0xA1; 589]], 5, H), + (2, vec![vec![0xA2; 589], vec![0xA3; 589]], 4, H - 1), + (4, vec![entry], 5, H), + ], + ) + .await; + let public = bob_wallet + .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(sender)) + if sender == alice.id() + ), + "{public:?}" + ); + managed(&bob_wallet, BOB, |managed| { + assert!(managed.dashpay().incoming_request(&alice.id()).is_some()); + }) + .await; + } + + /// After a key rewind with floor H, with chunks of two: a first page at + /// H - 1 proving six entries, a middle page at H proving seven and a third + /// page at H - 1 proving six. The read goes on past the third page to the + /// seven the middle page proved, and position 6 holds Alice's private + /// request to Bob's new key: the public request is refused. A pass ending + /// at the third page would not count either, its height below the floor + /// though the middle page reached it. + #[tokio::test] + async fn should_not_end_a_rewound_read_at_an_older_walk_target_before_a_public_request() { + const H: u64 = 20; + 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 stale = without_key(&bob, DECRYPTION_KEY_ID); + let (_manager, bob_wallet) = wallet(BOB_PHRASE, &stale, &[&alice, &bob], vec![]).await; + let filler = |byte: u8| vec![vec![byte; 589], vec![byte + 1; 589]]; + serve_pages( + &bob_wallet, + H, + vec![ + (0, filler(0xA0), 7, H), + (2, filler(0xA2), 7, H), + (4, filler(0xA4), 7, H), + (6, vec![entry.clone()], 7, H), + (7, vec![], 7, H), + ], + ) + .await; + let view = bob_wallet.identity().dashpay(); + view.sync_private_contact_requests(&bob_crypto, 16) + .await + .expect("Bob reads the store at H with his older keys"); + assert!(store_read(&bob_wallet, BOB).await); + + // The key reaches Bob's identity at the send, which rewinds his read. + set_identity_known(&bob_wallet, &bob, true).await; + serve_pages( + &bob_wallet, + H, + vec![ + (0, filler(0xA0), 6, H - 1), + (2, filler(0xA2), 7, H), + (4, filler(0xA4), 6, H - 1), + (6, vec![entry], 7, H), + ], + ) + .await; + 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(sender)) + if sender == alice.id() + ), + "{public:?}" + ); + managed(&bob_wallet, BOB, |managed| { + assert!(managed.dashpay().incoming_request(&alice.id()).is_some()); + }) + .await; + } + + /// `wallet`'s mock answers each page `(start, entries, total, height)` with + /// those entries, in chunks of two, showing `total` and proved at + /// `height`; every other response is proved at `base`. + async fn serve_pages( + wallet: &PlatformWallet, + base: u64, + pages: Vec<(u64, Vec>, u64, u64)>, + ) { + let contract = dashpay_contract().expect("bundled DashPay"); + let mut sdk = (*wallet.identity().sdk).clone(); + let mut mock = sdk.mock(); + mock.set_response_height(base); + for (start, entries, total, height) in pages { + let query = PrivateDocumentEntriesQuery::new( + contract.id(), + PRIVATE_CONTACT_REQUEST_DOCUMENT_TYPE_NAME, + start, + ); + let mut page = store_page(start, entries); + page.chunk_power = Some(1); + page.total_count = total; + mock.remove_fetch_expectation::(query.clone()) + .await; + mock.expect_fetch(query.clone(), Some(page)) + .await + .expect("page expectation"); + mock.set_response_height_for::(query, height); + } + } + /// An identity reloaded from a position record stored with its marker /// cleared, as a discharge leaves it, is not held, in any build (in one /// with the `shielded` feature once the store is configured, which an From 83d86fabd1caaa266cde180a518ca4dd9a17e7ae Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Tue, 10 Nov 2026 16:16:45 +0700 Subject: [PATCH 228/269] test(platform-wallet): cover the restoration retry before a send, its failure reason and the claim's read check A restoration left unfinished, deferred and its task gone, is run again by the next contact request; one that failed is reported with its reason while the wallet is held. A request read back while its journal record waits for its discharge counts as unresolved in the readiness and the pair's hold alike. A public pair's claim refuses a read rewound since the send made it. Co-Authored-By: Claude Opus 5.5 --- .../network/private_contact_requests.rs | 231 ++++++++++++++++++ 1 file changed, 231 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 4a3cac49f49..d8536029ea4 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 @@ -3625,6 +3625,57 @@ mod tests { ); } + /// A private request read back while its journal record waits for the + /// shielded sync to discharge it still counts as unresolved, in the + /// readiness and the pair's hold alike, as a send holds the pair until + /// the discharge; both clear with it. + #[cfg(feature = "shielded")] + #[tokio::test] + async fn should_report_a_journaled_payment_unresolved_until_its_record_is_discharged() { + 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; + fixture + .wallet + .restore_private_contact_payments(&fixture.coordinator) + .await + .expect("restored"); + publish(&fixture.wallet, 0, vec![entry.to_vec()]).await; + let view = fixture.wallet.identity().dashpay(); + view.sync_private_contact_requests(&alice_crypto, 4) + .await + .expect("the entry is read back"); + let readiness = view + .private_contact_readiness(&alice.id()) + .await + .expect("the readiness"); + assert!( + readiness.unresolved_private_payment, + "its record is not discharged yet" + ); + assert!(view + .private_pair_held(&alice.id(), &bob.id()) + .await + .expect("the hold")); + fixture + .coordinator + .discharge_read_back_private_payments() + .await; + let readiness = view + .private_contact_readiness(&alice.id()) + .await + .expect("the readiness"); + assert!(!readiness.unresolved_private_payment, "discharged"); + assert!(!view + .private_pair_held(&alice.id(), &bob.id()) + .await + .expect("the hold")); + } + /// A restoration that finds a journal record its sender's tracked request /// already records, as a private sync read it back in a process that ended /// before the shielded sync discharged it, discharges the record. @@ -3659,6 +3710,44 @@ mod tests { ); } + /// A public pair's claim checks again, in its lock, that the read the send + /// made before it still reaches the store's end: one rewound since, as + /// keys that changed or a sender unignored leave it, refuses it as not + /// read; one that still stands claims it. + #[tokio::test] + async fn should_refuse_claiming_a_public_pair_once_the_read_before_it_is_rewound() { + let alice = identity(ALICE_PHRASE, ALICE); + let bob = identity(BOB_PHRASE, BOB); + let (_manager, wallet) = wallet(ALICE_PHRASE, &alice, &[&alice, &bob], vec![]).await; + let view = wallet.identity().dashpay(); + let refused = view + .claim_public_pair(&alice.id(), &bob.id(), false, true) + .await; + assert!( + matches!( + refused, + Err(PlatformWalletError::PrivateContactStoreNotRead(sender)) + if sender == alice.id() + ), + "{:?}", + refused.err() + ); + { + 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(&alice.id()) + .expect("managed identity") + .note_private_contact_requests_read_for_tests(); + } + let claimed = view + .claim_public_pair(&alice.id(), &bob.id(), false, true) + .await; + assert!(matches!(claimed, Ok(Some(_))), "{:?}", claimed.err()); + } + /// The store's end as the pass's highest proved total: with chunks of two, /// the first page proves five entries at H, and the full second page, from /// a node a block behind, proves only four and its own end. The public @@ -10276,6 +10365,148 @@ mod tests { let _ = alice; } + /// A restoration left unfinished, deferred for a busy store and its task + /// gone, is run again by the next contact request before it is refused as + /// not restored: once the store frees, a public request to the pair the + /// journal keeps is refused as pending, not as unrestored. + #[cfg(feature = "shielded")] + #[tokio::test] + async fn should_retry_an_unfinished_restoration_before_a_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 alice_signer = signer(ALICE_PHRASE, &alice); + let (_manager, wallet) = wallet_on( + RecordingPersistence::attesting( + PersistenceCapabilities::PRIVATE_CONTACT_SCAN_STATE_RESTART, + ), + ALICE_PHRASE, + &alice, + &[&alice, &bob], + vec![], + ) + .await; + let dir = tempfile::tempdir().expect("temporary shielded store"); + let path = dir.path().join("tree.sqlite"); + journal_alice_to_bob(&path, wallet.wallet_id()); + let coordinator = Arc::new(NetworkShieldedCoordinator::new( + Arc::clone(&wallet.identity().sdk), + Network::Testnet, + path.clone(), + FileBackedShieldedStore::open_path(&path, 100).expect("the store"), + )); + let scan = coordinator.store().write().await; + let deferred = wallet + .restore_private_payments_or_defer(&coordinator) + .await + .expect("deferred for the busy store"); + deferred.abort(); + let _ = deferred.await; + drop(scan); + assert!(wallet.private_payments_held().await, "its task is gone"); + let view = wallet.identity().dashpay(); + let readiness = view + .private_contact_readiness(&alice.id()) + .await + .expect("the readiness"); + assert!(!readiness.private_payments_restored); + assert_eq!( + readiness.private_payments_restore_failure, None, + "not failed, only not run" + ); + 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:?}" + ); + assert!( + !wallet.private_payments_held().await, + "restored by the send" + ); + } + + /// A restoration that fails holds the wallet and is reported with its + /// reason while it does; a contact request is refused as not restored. + #[cfg(feature = "shielded")] + #[tokio::test] + async fn should_report_why_a_restoration_failed_while_the_wallet_is_held() { + 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, wallet) = wallet_on( + RecordingPersistence::attesting( + PersistenceCapabilities::PRIVATE_CONTACT_SCAN_STATE_RESTART, + ), + ALICE_PHRASE, + &alice, + &[&alice, &bob], + vec![], + ) + .await; + let dir = tempfile::tempdir().expect("temporary shielded store"); + let path = dir.path().join("tree.sqlite"); + journal_alice_to_bob(&path, wallet.wallet_id()); + rusqlite::Connection::open(&path) + .expect("the store's file") + .execute( + "UPDATE shielded_private_payment_journal SET record = x'00'", + [], + ) + .expect("the record damaged"); + let coordinator = Arc::new(NetworkShieldedCoordinator::new( + Arc::clone(&wallet.identity().sdk), + Network::Testnet, + path.clone(), + FileBackedShieldedStore::open_path(&path, 100).expect("the store"), + )); + let failed = wallet.restore_private_contact_payments(&coordinator).await; + assert!(failed.is_err(), "{failed:?}"); + let view = wallet.identity().dashpay(); + let readiness = view + .private_contact_readiness(&alice.id()) + .await + .expect("the readiness"); + assert!(!readiness.private_payments_restored); + assert!( + readiness + .private_payments_restore_failure + .as_deref() + .is_some_and(|reason| reason.contains("does not read back")), + "{readiness:?}" + ); + 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::PrivateContactPaymentsNotRestored) + ), + "{public:?}" + ); + } + /// A private payment that never executes is proven expired once its /// anchor is pruned and its entry is absent from the store: the /// coordinator discharges its journal record, the pair is freed, a retry From ebb0464e1d91e249f0a005f7e995d6bab63b9295 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Tue, 10 Nov 2026 16:34:02 +0700 Subject: [PATCH 229/269] test(platform-wallet): cover the re-check after publication, the first configure's journal read, the load hold and journal record bytes A wallet still held once published under a store configured since is restored by the registration's own check. The first configure reads the journal of a wallet nothing holds, as a backend that stopped attesting the scan state leaves it. A loaded wallet over an attesting persister is held until the store is configured. A format 4 journal record keeps its entry hash and store position across its bytes, and a format 3 record reads back with neither. Co-Authored-By: Claude Opus 5.5 --- .../network/private_contact_requests.rs | 63 +++++++++++++++++++ .../src/wallet/shielded/store.rs | 63 +++++++++++++++++-- 2 files changed, 122 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 d8536029ea4..0a7e891f2ad 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 @@ -3925,6 +3925,16 @@ mod tests { // A host keeping the private scan state configures the shielded store // at activation: until then, a loaded wallet is held. #[cfg(feature = "shielded")] + assert!( + manager + .get_wallet(&before.wallet_id()) + .await + .expect("the relaunched wallet") + .private_payments_held() + .await, + "held until the store is configured" + ); + #[cfg(feature = "shielded")] let _store = { let dir = tempfile::tempdir().expect("temporary shielded store"); manager @@ -10324,6 +10334,59 @@ mod tests { } } + /// A wallet still held once it is published, under a store configured + /// since its registration found none, is restored by the check the + /// registration makes once the wallet is published: the configuration's + /// own walk ran before the wallet was published, so it did not see it. + #[cfg(feature = "shielded")] + #[tokio::test] + async fn should_restore_a_wallet_still_held_once_published_under_a_store_configured_since() { + let alice = identity(ALICE_PHRASE, ALICE); + let dir = tempfile::tempdir().expect("temporary shielded store"); + let manager = manager_on(RecordingPersistence::attesting( + PersistenceCapabilities::PRIVATE_CONTACT_SCAN_STATE_RESTART, + )) + .await; + manager + .configure_shielded(dir.path().join("tree.sqlite")) + .await + .expect("the shielded store is configured"); + let wallet = create_holding(&manager, ALICE_PHRASE, &alice).await; + // As the registration's check before the store was configured left it. + wallet.persister().hold_until_private_payments_restored(); + manager + .restore_private_payments_configured_since(&wallet) + .await; + assert!(!wallet.private_payments_held().await); + } + + /// The first configure reads the journal of every wallet whose id it may + /// keep payments of, held or not: a backend that no longer attests the + /// private scan state holds no wallet for want of a store, yet the + /// payments journaled while it did still hold their pairs. + #[cfg(feature = "shielded")] + #[tokio::test] + async fn should_read_the_journal_of_a_wallet_not_held_at_the_first_configure() { + let alice = identity(ALICE_PHRASE, ALICE); + let bob = identity(BOB_PHRASE, BOB); + let dir = tempfile::tempdir().expect("temporary shielded store"); + let path = dir.path().join("tree.sqlite"); + let manager = manager_on(RecordingPersistence::attesting( + PersistenceCapabilities::NONE, + )) + .await; + let wallet = create_holding(&manager, ALICE_PHRASE, &alice).await; + assert!(!wallet.private_payments_held().await); + journal_alice_to_bob(&path, wallet.wallet_id()); + manager + .configure_shielded(&path) + .await + .expect("the shielded store is configured"); + assert!(wallet + .persister() + .private_pair_journaled(&alice.id(), &bob.id())); + } + /// A repeat `configure_shielded`, as a host makes on every bind from its /// main thread, returns at once while a scan holds the store's writer: a /// wallet still held is restored once the writer is released. diff --git a/packages/rs-platform-wallet/src/wallet/shielded/store.rs b/packages/rs-platform-wallet/src/wallet/shielded/store.rs index bce8c76235f..78c87b307ad 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/store.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/store.rs @@ -620,10 +620,10 @@ pub trait ShieldedStore: Send + Sync + 'static { /// with it, and the private payment journal's record of each entry the /// spend appends (`entries`, each with the hash of the whole entry), /// which outlives it ([`PrivatePaymentRecord`]), with the sender's private - /// store read position when it paid (`store_position`). 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. + /// store read position when it paid (`store_position`). 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. /// `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( @@ -2274,6 +2274,61 @@ mod tests { } } + /// A format 4 journal record keeps its entry's hash and its store + /// position across its bytes, each present or not; a format 3 record, + /// which kept neither, reads back with neither. A presence byte other than + /// 0 or 1, or bytes past the last field, do not read. + #[test] + fn should_keep_a_journal_record_s_entry_hash_and_position_across_its_bytes() { + let record = |entry_hash, store_position| PrivatePaymentRecord { + account_index: 2, + activity_id: [0x61; 32], + anchor: [0x62; 32], + anchor_height: Some(41), + commitment: [0x63; 32], + payment: PrivateContactPayment { + sender: [0x64; 32], + recipient: [0x65; 32], + account_reference: 3, + }, + nullifiers: vec![[0x66; 32], [0x68; 32]], + entry_hash, + store_position, + }; + for (entry_hash, store_position) in [ + (None, None), + (Some([0x67; 32]), None), + (None, Some(9)), + (Some([0x67; 32]), Some(u64::MAX - 1)), + ] { + let bytes = record(entry_hash, store_position) + .to_bytes() + .expect("encodes"); + assert_eq!( + PrivatePaymentRecord::from_bytes(&bytes), + Some(record(entry_hash, store_position)), + "{entry_hash:?} {store_position:?}" + ); + let mut longer = bytes; + longer.push(0); + assert_eq!(PrivatePaymentRecord::from_bytes(&longer), None); + } + // Format 3: the fields up to the nullifiers. + let mut format_3 = record(None, None).to_bytes().expect("encodes"); + format_3.truncate(format_3.len() - 2); + format_3[0] = 3; + assert_eq!( + PrivatePaymentRecord::from_bytes(&format_3), + Some(record(None, None)) + ); + format_3.push(0); + assert_eq!(PrivatePaymentRecord::from_bytes(&format_3), None); + let mut unknown_presence = record(None, None).to_bytes().expect("encodes"); + let at = unknown_presence.len() - 2; + unknown_presence[at] = 2; + assert_eq!(PrivatePaymentRecord::from_bytes(&unknown_presence), None); + } + /// The fund-safe release: once the anchor is pruned (absent from the /// recorded set) the spend can never execute, so the reservation is /// released and the freed note becomes spendable again. From e53aa68de8403012e82cfb5647411c0a828b0401 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Tue, 10 Nov 2026 16:35:42 +0700 Subject: [PATCH 230/269] docs(platform-wallet): describe the wallet hold, the tail read and codes 74 and 75 The README and the FFI error code registry now say what this branch does: a wallet published with no shielded store is held while its persister attests the private scan state (bit 15), a repeat configure never waits on the store, a contact request retries a restoration once, and readiness gives a failed restoration's reason. Every contact request reads the private store from the cursor first and is refused with 74 while that read cannot finish; a pinned SDK has its own code, 75, and the next allocatable code is 76. The journal keeps each entry's hash and the sender's read position (format 4), and the recovery connection syncs with fullfsync. Registry rows 68 to 75 describe this tree only: the exports that send and accept contact requests and the Swift and Kotlin typing are named as added by the FFI and Kotlin PRs. Rows 16 and 18 say which note spend outcomes moved from 16 to 18. The doc of ShieldedBroadcastMaybeDelivered now says that no spend of this crate returns it and how a caller's payment that does is handled, and code 18's doc no longer lists identity creation, which reports 17. Co-Authored-By: Claude Opus 5.5 --- .../ERROR_CODE_REGISTRY.md | 22 +++++----- packages/rs-platform-wallet-ffi/src/error.rs | 41 ++++++++++--------- packages/rs-platform-wallet/README.md | 31 ++++++++++---- packages/rs-platform-wallet/src/error.rs | 14 ++++--- .../src/wallet/platform_wallet.rs | 2 +- 5 files changed, 66 insertions(+), 44 deletions(-) diff --git a/packages/rs-platform-wallet-ffi/ERROR_CODE_REGISTRY.md b/packages/rs-platform-wallet-ffi/ERROR_CODE_REGISTRY.md index c890d8010c3..877ce13fa36 100644 --- a/packages/rs-platform-wallet-ffi/ERROR_CODE_REGISTRY.md +++ b/packages/rs-platform-wallet-ffi/ERROR_CODE_REGISTRY.md @@ -87,9 +87,9 @@ These are shipped ABI. Do not renumber. | 13 | `ErrorArithmeticOverflow` | Produced in-tree by `shielded_send.rs` (the shielded-send amount/fee overflow guard). Its rustdoc was corrected by #4360 (`e0b8baa850`), which documents the `PlatformWalletError::InputSumOverflow` mapping — the stale no-producer comment this row used to track is gone | | 14 | `ErrorNoSelectableInputs` | | | 15 | `ErrorWalletAlreadyExists` | | -| 16 | `ErrorShieldedBroadcastFailed` | | +| 16 | `ErrorShieldedBroadcastFailed` | For a shielded spend that consumes notes, since #5810: only a transition refused before anything was sent (no address to send it to, or the SDK's own refusal), its notes released. An endpoint's verdict (CheckTx or execution, which no proof backs) and every transport failure after a dispatch, which note spends reported here before, now report 18, or 17 for an identity created from the pool. A shield into the pool still reports a verdict here | | 17 | `ErrorShieldedBroadcastUnconfirmed` | | -| 18 | `ErrorShieldedSpendUnconfirmed` | | +| 18 | `ErrorShieldedSpendUnconfirmed` | Also, since #5810, what note spends (shielded transfers, unshields, withdrawals, identity top-ups and private document creations from the pool) reported as 16 before: an endpoint's verdict and any transport failure after a dispatch, as the transition may have been delivered. Also `ShieldedBroadcastMaybeDelivered`. The notes and the redrive record are kept; a host must not pay again with other notes | | 19 | `ErrorShieldedNoRecordedAnchor` | | | 20 | `ErrorTransactionBroadcastUnconfirmed` | | | 21 | `ErrorAddressNonceMismatch` | | @@ -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: 74**: 27–73 are all claimed (27, 29, 31, 34–42 +**Next allocatable integer: 76**: 27–75 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 @@ -128,7 +128,7 @@ renumbered from 59 when #5322 took that number on `v5.1-dev`); 61 proposed by by open branches with no row here yet (62 both `ErrorShieldedClaimBindingMismatch` and `ErrorIdentityRevisionMismatch`, 63–66 the invitation-claim preflight, 67 `ErrorShieldedTreeInconsistent`); 68 -to 73 proposed for DashPay private contact requests; 28, 30, +to 75 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 @@ -183,12 +183,14 @@ Fork-era numbers remain in the collision history, which is immutable record. | 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 | | 61 | `ErrorAssetLockRecipientMismatch` | dashpay/platform#5603 (tracked-lock shielded recipient) | Proposed — a shielded fund-from-asset-lock resume (the generic outpoint resume or a repeated-UUID wallet drain) names a different Orchard recipient than the one the tracked lock recorded when it was built. Nothing was submitted or paid; resume with the recorded recipient, which the row and `TrackedAssetLockFFI` expose. Rows tracked before recipients were recorded carry none and accept the caller's. Typed in Rust, Swift and Kotlin | -| 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. A refusal from the network's version lifts when the network upgrades; one from a pin lifts only once the host raises or removes the pin, the network supporting the required version too, as a network upgrade alone does not lift it. 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 15) 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 | +| 68 | `ErrorPrivateContactRequestNeedsPrivateReply` | DashPay private contact requests | 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 request goes privately instead, and the in-kind accept picks the private reply itself. The shielded spend contract (`map_spend_result`) keeps the code, as the in-kind accept can raise it. Taken above 61–67, which open branches already hold. The exports that send and accept contact requests, and the Swift and Kotlin typing, are added by the FFI PR (#5812) and the Kotlin PR (#5813); in this tree it reaches a host through the blanket conversion | +| 69 | `ErrorShieldedBuildRequired` | DashPay private contact requests | Proposed. The operation needs the shielded pool and the library was built without the `shielded` feature: accepting in kind a request that came privately, whose 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. No export in this tree returns it yet: the in-kind accept export, and the Swift and Kotlin typing, are added by the FFI PR (#5812) and the Kotlin PR (#5813) | +| 70 | `ErrorProtocolVersionNotActive` | DashPay private contact requests | Proposed. The SDK refused a request without sending it (`dash_sdk::Error::ProtocolVersionNotActive`, not pinned) because the network is proven at a protocol version below the one that serves 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. An SDK pinned below the required version has its own code, 75. 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 and Kotlin typing is added by the FFI PR (#5812) and the Kotlin PR (#5813) | +| 71 | `ErrorPrivateContactRequestPending` | DashPay private contact requests | 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 a private payment the shielded store's journal keeps unresolved holds the pair. Nothing new was paid; a later private contact request sync settles it once the entry is read back, and the shielded sync once the payment provably never executes. Pending, not failed: the host must not submit it again. A private store read that did not finish before the request was decided is 74, not 71. The shielded spend contract (`map_spend_result`) keeps the code rather than flattening it to `ErrorWalletOperation`. Swift and Kotlin typing is added by the FFI PR (#5812) and the Kotlin PR (#5813) | +| 72 | `ErrorPrivateContactPaymentsNotRestored` | DashPay private contact requests | Proposed. Maps `PlatformWalletError::PrivateContactPaymentsNotRestored`: wallet readiness, not one contact's pending request (71). The wallet's unconfirmed private payments may hold pairs no reservation shows yet, which only a restoration from the shielded store's journal shows. In a build with the `shielded` feature: the wallet was published (created or loaded) while no shielded store was configured, its persister declaring the private scan state (bit 15), which holds a wallet removed and registered again from the same seed, in this process or a new one; it was loaded after binding a shielded account in an earlier process; or its last restoration failed, or waits for a busy store. In every build: the identity was loaded with its private scan state marking a private payment that may be unresolved. Contact requests and acceptances, public and private, are refused with nothing read, signed or broadcast until they are restored: configuring the shielded store restores them with no keys, as binding the wallet's shielded account does, and a contact request first retries a restoration once without waiting on the store; a private sync does not, and a build without the `shielded` feature never does. A host whose persister declares the private scan state configures the shielded store when it activates the manager, before loading or creating wallets: until it does, and while a failed configure is not retried, 72 holds. `platform_wallet_get_private_contact_readiness` reads whether the payments are restored and whether the last restoration failed (`private_payments_restore_failed`; the wallet's `PrivateContactReadiness::private_payments_restore_failure` holds the reason). Swift and Kotlin typing is added by the FFI PR (#5812) and the Kotlin PR (#5813); hosts treat it as held until the readiness reads restored | +| 73 | `ErrorPrivateContactRecoveryUnsupported` | DashPay private contact requests | Proposed. Maps `PlatformWalletError::PrivateContactRecoveryUnsupported`: the persister does not declare `PRIVATE_CONTACT_REQUEST_SCAN_STATE` (bit 15) 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. The FFI constant for bit 15 and the Swift and Kotlin typing are added by the FFI PR (#5812) and the Kotlin PR (#5813) | +| 74 | `ErrorPrivateContactStoreNotRead` | DashPay private contact requests | Proposed. Maps `PlatformWalletError::PrivateContactStoreNotRead`: DashPay's private contact request store was not read to its end for the identity, with the keys Platform proves it holds, before a contact request or acceptance, public or private, was decided (every one reads it from the identity's cursor first). Nothing was reserved, sealed, paid or broadcast; calling again continues the read from where it stopped. A node behind the store already seen, or keys that kept changing, stop it. It was reported as 71 before, which says not to submit again. Mapped by the blanket conversion and kept by the shielded spend contract (`map_spend_result`). Swift and Kotlin typing is added by the FFI PR (#5812) and the Kotlin PR (#5813) | +| 75 | `ErrorSdkPinnedBelowProtocolVersion` | DashPay private contact requests | Proposed. The SDK refused a request without sending it because the SDK itself is pinned below the protocol version the request needs (`dash_sdk::Error::ProtocolVersionNotActive` with `pinned`), whatever the network runs: for example every contact request's private store read with the SDK pinned below protocol version 15 on a network at 15. A network upgrade does not lift it; only raising or removing the pin does. Before, it shared 70 with a network proven below the version, which lifts by itself. Mapped by the blanket conversion. Swift and Kotlin typing is added by the FFI PR (#5812) and the Kotlin PR (#5813) | **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 d393f88b1a9..ea9f145388b 100644 --- a/packages/rs-platform-wallet-ffi/src/error.rs +++ b/packages/rs-platform-wallet-ffi/src/error.rs @@ -140,8 +140,9 @@ pub enum PlatformWalletFFIResultCode { /// 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 + /// [`Self::ErrorShieldedSpendUnconfirmed`] ([`Self::ErrorShieldedBroadcastUnconfirmed`] + /// for an identity create), 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 @@ -157,8 +158,9 @@ pub enum PlatformWalletFFIResultCode { /// the caller can hold the slot and surface the pending identity. ErrorShieldedBroadcastUnconfirmed = 17, /// Maps `PlatformWalletError::ShieldedSpendUnconfirmed` (unshield / - /// shielded transfer / shielded withdrawal / identity top-up and create / - /// private document create from the pool), and + /// shielded transfer / shielded withdrawal / identity top-up / private + /// document create from the pool; an identity created from the pool + /// reports [`Self::ErrorShieldedBroadcastUnconfirmed`] (17) instead), 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 @@ -642,20 +644,20 @@ pub enum PlatformWalletFFIResultCode { /// 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. - /// 62 to 67 are claimed by open branches, so this takes 68. + /// public accept refuse it, and nothing was sent: the request goes + /// privately instead, and the in-kind accept picks the private reply + /// itself. The shielded spend contract (`map_spend_result`) keeps the code, + /// as the in-kind accept can raise it. The exports that send and accept + /// contact requests are added by the FFI PR. 62 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. + /// without the `shielded` feature: accepting a contact request that came + /// privately in kind, whose private reply is paid from the pool (the + /// export is added by the FFI PR; no export in this crate returns it yet). + /// 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 @@ -675,10 +677,11 @@ pub enum PlatformWalletFFIResultCode { /// reserved it), or was paid for and is not yet read back from the store, /// or a private payment the shielded store's journal keeps unresolved /// holds the pair. Nothing new was paid. Pending, not failed: do not - /// submit it again. A later private contact request sync - /// (`platform_wallet_sync_private_contact_requests`) settles it once the - /// entry is read back, and the shielded sync once the payment provably - /// never executes. + /// submit it again. A later private contact request sync settles it once + /// the entry is read back, and the shielded sync once the payment provably + /// never executes. A read of the private store that did not finish before + /// the request was decided is not pending: it is + /// [`Self::ErrorPrivateContactStoreNotRead`] (74). ErrorPrivateContactRequestPending = 71, /// Maps `PlatformWalletError::PrivateContactPaymentsNotRestored`: the diff --git a/packages/rs-platform-wallet/README.md b/packages/rs-platform-wallet/README.md index 9355696c17e..f43beabc548 100644 --- a/packages/rs-platform-wallet/README.md +++ b/packages/rs-platform-wallet/README.md @@ -123,22 +123,26 @@ 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 record is also discharged once its payment is proven expired, judged against Platform's recorded anchors fetched before the shielded sync pass and proved at a height H: the spend's anchor, which the record keeps with the proved height of the set it was read from, was read at or below H and is absent from the set at H, the pass's note scan covers H, and its entry is absent from DashPay's private store read through its end with every page proved at or above H. Whether its inputs are spent does not matter: the transition appends its entry in the block that spends them. 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. +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, the SHA-256 of the whole entry, the position the sender's read of DashPay's private store rested at when it paid, 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` with `fullfsync` (on Apple platforms a plain sync does not flush the drive's own cache), 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, judged against Platform's recorded anchors fetched before the shielded sync pass and proved at a height H: the spend's anchor, which the record keeps with the proved height of the set it was read from, was read at or below H and is absent from the set at H, the pass's note scan covers H, and its entry is absent from DashPay's private store read from the lowest position the records being judged kept through its end, with every page proved at or above H. An entry counts as the record's only when its whole bytes hash to the hash the record keeps: one that only starts with the commitment, which anyone who saw the commitment can append, does not hold the record. A record journaled before the hash and the position were kept matches by its commitment and is read for from the store's start. Whether its inputs are spent does not matter: the transition appends its entry in the block that spends them. 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). A restoration that finds a record whose request the sender's tracked request already records or supersedes, as a private sync read it back in a process that ended before discharging it, discharges it. 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: +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 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. These 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 a build with the `shielded` feature, a wallet published, registered or loaded, while no shielded store is configured is held when its persister attests the private scan state: only such a persister lets a private payment be paid, so only its wallet can have journal records that no marker shows, as after the wallet was removed and registered again from the same seed, in this process or a new one. A wallet whose persister does not attest is never held for want of a store. With a store configured, the journal is read as the wallet is published. +- In a build with the `shielded` feature, `load_from_persistor` also 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 a build with the `shielded` feature, a wallet stays held while its last restoration failed, or waits for a busy store. - 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 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. The wallet holds it, beyond the sender identity, for as long as the journal keeps the payment's record: removing the identity and loading it again does not release it. +A private contact request is therefore paid for only over a backend attesting `PRIVATE_CONTACT_REQUEST_SCAN_STATE` (bit 15) together with `ATOMIC_CHANGESETS` (bit 0) (`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. The wallet holds it, beyond the sender identity, for as long as the journal keeps the payment's record: removing the identity and loading it again does not release it. -`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. +The first `PlatformWalletManager::configure_shielded` restores from the journal, with no keys, the payments of every held wallet and of every wallet the journal may keep payments of; a wallet registered or loaded after it is restored as it is published. A repeat call, as a host makes on every bind, never waits for a store that a scan holds: it restores only the wallets still held, each deferred to a task while the store is busy. Binding a wallet's shielded account restores its own, deferring the same way, the wallet held until it succeeds. A contact request refused as not restored first runs one more restoration, which does not wait on the store. A private sync does not restore. A host whose persister attests the private scan state therefore configures the shielded store when it activates the manager, before loading or creating wallets: until then every such wallet's contact requests are refused with 72, and a configure that fails keeps them refused until one succeeds. A host that opens no shielded store this session keeps those wallets' and identities' contact requests refused, and a build without the `shielded` feature keeps the marked identities' 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, why the last restoration failed if it did, whether the private store was read to its end, and whether a payment may be unresolved (the marker, or a journal record the wallet holds the pair for), so a host shows the wallet's state rather than inferring it from refusals. `DashPayView::private_pair_held` reads one pair's hold as a send decides it. -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. +Every contact request and acceptance, public or private, first reads DashPay's private contact request store for the identity from its cursor to the store's end, with the keys Platform proves the identity holds, so a private request that landed since the last read (from another device of the identity, or a contact's request to it) is seen before the request is decided; the claim of a public pair checks again, in its lock, that the read still reaches the end. A pass reaches the end only at the highest store size any of its pages proved, so a later page from a node behind does not end it early, and after a key change rewinds the read, it counts only when the page that reaches the end was proved at or above the height the earlier read had reached. While the read cannot finish, the request is refused with `PrivateContactStoreNotRead` (FFI code 74), nothing reserved, sealed, paid or broadcast; calling again continues the read. On a network proven below the protocol version that serves private documents, nothing is read. An SDK pinned below that version refuses the read (FFI code 75), whatever the network runs. + +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 clears with a store for the wallet's network. A journal record that does not read back is never removed, so it clears only once a known-good store file is restored; one whose wallet id does not read holds every wallet. The undecodable transition and the payment with no request 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. The readiness gives the failure's reason while the wallet is held. ### 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 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. +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. An identity creation reports `ShieldedBroadcastUnconfirmed` (FFI code 17) instead, carrying the identity's derived id. Earlier versions reported these verdicts and transport failures as `ShieldedBroadcastFailed` (FFI code 16). 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 @@ -154,6 +158,15 @@ removes a spend's saved record and its journal records in one durable write, and refuses, changing nothing, once a re-broadcast of the record was claimed (`PendingRedrive::attempts` above 0). +`arm_private_contact_payment` takes the entries a payment appends as +`&[PrivatePaymentEntry]` (each entry's commitment and the SHA-256 of its whole +bytes) and the sender's private store read position when it paid +(`store_position: Option`), and `PrivatePaymentRecord` struct literals +require `entry_hash` and `store_position`. A store keeps both with each journal +record. `FileBackedShieldedStore` writes journal records in format version 4 +and still reads version 3 records, which carry neither: such a record matches +an entry by its commitment and is read for from the store's start. + Note reservations are owned by the spend attempt that took them: stores must implement `mark_pending_by(id, nullifier, reservation)`, which reserves a note for the attempt `reservation`, and `clear_pending_reserved_by(id, nullifier, @@ -183,7 +196,9 @@ and such a record is released, or discharged, on a set lacking its anchor only when that count, proved at or below the set's height, reaches the bound and one past the position of every note spent on that anchor that the store holds, the scan covers the set, and, for a journal record, the store read -through its end lacks its entry at that set's height. Otherwise it stays held. +through its end lacks its entry at that set's height (one hashing to the hash +the record keeps, or, for a record that keeps none, one starting with its +commitment). Otherwise it stays held. 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 2d7000483f8..66eec7c61f1 100644 --- a/packages/rs-platform-wallet/src/error.rs +++ b/packages/rs-platform-wallet/src/error.rs @@ -1049,12 +1049,14 @@ pub enum PlatformWalletError { 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. + /// transition was never delivered, so it may still execute. None of this + /// crate's spends returns it: a note spend that may have been delivered + /// reports [`Self::ShieldedSpendUnconfirmed`], its notes and redrive record + /// kept. A payment a caller makes for a private contact request (the `pay` + /// a private send or accept takes) may return it, and is then handled as + /// unconfirmed, never as failed: the request stays pending and its pair + /// held, as for one paid for and not read back, and the FFI reports it as + /// `ErrorShieldedSpendUnconfirmed` (18), so a host does not pay again. #[error("Shielded broadcast failed, but may have been delivered: {0}")] ShieldedBroadcastMaybeDelivered(String), diff --git a/packages/rs-platform-wallet/src/wallet/platform_wallet.rs b/packages/rs-platform-wallet/src/wallet/platform_wallet.rs index 8d9530c343f..ab34c7cdfe7 100644 --- a/packages/rs-platform-wallet/src/wallet/platform_wallet.rs +++ b/packages/rs-platform-wallet/src/wallet/platform_wallet.rs @@ -1906,13 +1906,13 @@ impl PlatformWallet { /// The handles a restoration of this wallet's private contact request /// payments runs on ([`Self::restore_private_contact_payments`]). - #[cfg(feature = "shielded")] /// /// Installs on the persister one more restoration from `coordinator`, for /// the wallet's views to run, without waiting on the store, before a /// contact request is refused as not restored /// (`WalletPersister::retry_private_payment_restore`): a failed or /// deferred restoration then lifts at the next send. + #[cfg(feature = "shielded")] fn private_payment_restore( &self, coordinator: &Arc, From 4a0707bc088c8a8126822aa23b2c6dfe82293185 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Tue, 10 Nov 2026 16:37:34 +0700 Subject: [PATCH 231/269] test(platform-wallet): import the journal record type once in the private contact request tests Co-Authored-By: Claude Opus 5.5 --- .../wallet/identity/network/private_contact_requests.rs | 9 +-------- 1 file changed, 1 insertion(+), 8 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 0a7e891f2ad..443f7caa4bc 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 @@ -2621,7 +2621,7 @@ mod tests { #[cfg(feature = "shielded")] use crate::wallet::shielded::{ FileBackedShieldedStore, NetworkShieldedCoordinator, OrchardKeySet, PrivateContactPayment, - ShieldedNote, ShieldedStore, SubwalletId, + PrivatePaymentRecord, ShieldedNote, ShieldedStore, SubwalletId, }; use crate::PlatformWalletManager; use dash_sdk::error::ContextProviderError; @@ -3570,8 +3570,6 @@ mod tests { #[cfg(feature = "shielded")] #[tokio::test] async fn should_read_the_store_for_an_expiry_from_the_position_the_record_kept() { - use crate::wallet::shielded::PrivatePaymentRecord; - let alice = identity(ALICE_PHRASE, ALICE); let bob = identity(BOB_PHRASE, BOB); let fixture = ExpiryFixture::new().await; @@ -9563,7 +9561,6 @@ mod tests { #[tokio::test] async fn should_discharge_a_reopened_heightless_journal_record_only_once_the_pool_reached_its_tree( ) { - use crate::wallet::shielded::PrivatePaymentRecord; const TREE: u64 = 4; let alice = identity(ALICE_PHRASE, ALICE); @@ -9973,8 +9970,6 @@ mod tests { #[cfg(feature = "shielded")] #[tokio::test] async fn should_hold_the_journaled_pairs_of_a_wallet_re_created_under_its_id() { - use crate::wallet::shielded::PrivatePaymentRecord; - let alice = identity(ALICE_PHRASE, ALICE); let bob = identity(BOB_PHRASE, BOB); let alice_crypto = SeedCryptoProvider::from_seed(seed(ALICE_PHRASE), Network::Testnet); @@ -10095,8 +10090,6 @@ mod tests { /// Bob made by wallet `wallet_id` that may still execute. #[cfg(feature = "shielded")] fn journal_alice_to_bob(path: &std::path::Path, wallet_id: WalletId) { - use crate::wallet::shielded::PrivatePaymentRecord; - let (alice, bob) = (identity(ALICE_PHRASE, ALICE), identity(BOB_PHRASE, BOB)); FileBackedShieldedStore::open_path(path, 100) .expect("the store") From ddcb8bd7d385ee42a3d3fbc56e74c336477bdc12 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Tue, 10 Nov 2026 16:43:07 +0700 Subject: [PATCH 232/269] fix(platform-wallet): discharge no settled journal record after a flush was lost A restoration discharges a journal record whose request the sender's tracked request already records. After a flush of the wallet's records failed for good in this process, that tracked request may be in memory only: discharging the record would leave a restart with neither. The persister now notes a non-transient flush failure, and a restoration then leaves settled records for the next process, whose tracked requests come from disk. Co-Authored-By: Claude Opus 5.5 --- packages/rs-platform-wallet/README.md | 2 +- .../network/private_contact_requests.rs | 12 ++++++++- .../src/wallet/persister.rs | 27 ++++++++++++++++++- .../src/wallet/platform_wallet.rs | 7 +++-- 4 files changed, 43 insertions(+), 5 deletions(-) diff --git a/packages/rs-platform-wallet/README.md b/packages/rs-platform-wallet/README.md index f43beabc548..b6f7a181268 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, the SHA-256 of the whole entry, the position the sender's read of DashPay's private store rested at when it paid, 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` with `fullfsync` (on Apple platforms a plain sync does not flush the drive's own cache), 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, judged against Platform's recorded anchors fetched before the shielded sync pass and proved at a height H: the spend's anchor, which the record keeps with the proved height of the set it was read from, was read at or below H and is absent from the set at H, the pass's note scan covers H, and its entry is absent from DashPay's private store read from the lowest position the records being judged kept through its end, with every page proved at or above H. An entry counts as the record's only when its whole bytes hash to the hash the record keeps: one that only starts with the commitment, which anyone who saw the commitment can append, does not hold the record. A record journaled before the hash and the position were kept matches by its commitment and is read for from the store's start. Whether its inputs are spent does not matter: the transition appends its entry in the block that spends them. 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). A restoration that finds a record whose request the sender's tracked request already records or supersedes, as a private sync read it back in a process that ended before discharging it, discharges it. Nothing else, a relay's refusal or a missing redrive record included, discharges a record. +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, the SHA-256 of the whole entry, the position the sender's read of DashPay's private store rested at when it paid, 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` with `fullfsync` (on Apple platforms a plain sync does not flush the drive's own cache), 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, judged against Platform's recorded anchors fetched before the shielded sync pass and proved at a height H: the spend's anchor, which the record keeps with the proved height of the set it was read from, was read at or below H and is absent from the set at H, the pass's note scan covers H, and its entry is absent from DashPay's private store read from the lowest position the records being judged kept through its end, with every page proved at or above H. An entry counts as the record's only when its whole bytes hash to the hash the record keeps: one that only starts with the commitment, which anyone who saw the commitment can append, does not hold the record. A record journaled before the hash and the position were kept matches by its commitment and is read for from the store's start. Whether its inputs are spent does not matter: the transition appends its entry in the block that spends them. 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). A restoration that finds a record whose request the sender's tracked request already records or supersedes, as a private sync read it back in a process that ended before discharging it, discharges it, unless a flush of the wallet's records failed for good in this process (the tracked request may then be in memory only). 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 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. These 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 443f7caa4bc..584f8986ac8 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 @@ -3498,7 +3498,9 @@ mod tests { /// A read-back whose records' flush fails for good, the backend dropping /// what it buffered, drops its notice: the journal record stays, the pair - /// held, and a later flush that succeeds notes nothing. + /// held, and a later flush that succeeds notes nothing; a restoration in + /// the same process leaves the record too, the request it finds tracked + /// being in memory only. #[cfg(feature = "shielded")] #[tokio::test] async fn should_drop_a_read_back_notice_whose_records_the_backend_dropped() { @@ -3538,6 +3540,14 @@ mod tests { .await; assert_eq!(fixture.journaled().await, 1, "the record stays"); assert!(persister.private_pair_journaled(&alice.id(), &bob.id())); + // Nor does a restoration in this process discharge it, though the + // request is tracked: in memory only, as its flush was lost. + fixture + .wallet + .restore_private_contact_payments(&fixture.coordinator) + .await + .expect("restored"); + assert_eq!(fixture.journaled().await, 1, "kept for the next process"); } /// An entry that only starts with a journal record's commitment, as anyone diff --git a/packages/rs-platform-wallet/src/wallet/persister.rs b/packages/rs-platform-wallet/src/wallet/persister.rs index 474c65af6a6..7a8fb4895b8 100644 --- a/packages/rs-platform-wallet/src/wallet/persister.rs +++ b/packages/rs-platform-wallet/src/wallet/persister.rs @@ -98,6 +98,14 @@ pub struct WalletPersister { /// last store or its flush failed. Shared by the clones the wallet and /// the coordinator hold. In memory only. stored_private_markers: Arc>>, + /// Whether a flush of this wallet's records failed for good in this + /// process, the backend dropping what it buffered: what the wallet tracks + /// in memory since may then not be on disk, so nothing is discharged on + /// the strength of it alone ([`Self::buffered_writes_lost`]). Shared by + /// the clones the wallet and the coordinator hold. In memory only: the + /// next process tracks what its load read from disk. + #[cfg(feature = "shielded")] + buffered_writes_lost: Arc, /// Why the last restoration of this wallet's private payments failed, /// while no later one succeeded (`PlatformWalletError::PrivateContactPaymentsNotRestored` /// then holds for it, not only for one not run yet). Shared by the @@ -160,6 +168,8 @@ impl WalletPersister { discharged_private_entries: Arc::default(), private_payments_unrestored: Arc::default(), stored_private_markers: Arc::default(), + #[cfg(feature = "shielded")] + buffered_writes_lost: Arc::default(), private_payment_restore_failure: Arc::default(), #[cfg(feature = "shielded")] private_payment_restore_retry: Arc::default(), @@ -519,7 +529,22 @@ impl WalletPersister { } pub(crate) fn flush(&self) -> Result<(), PersistenceError> { - self.inner.flush(self.wallet_id) + let flushed = self.inner.flush(self.wallet_id); + #[cfg(feature = "shielded")] + if flushed.as_ref().is_err_and(|error| !error.is_transient()) { + self.buffered_writes_lost.store(true, Ordering::SeqCst); + } + flushed + } + + /// Whether a flush of this wallet's records failed for good in this + /// process (anything but a transient failure, which keeps the buffer): + /// what the wallet tracked before it may not be on disk, though it is in + /// memory. A restoration then leaves the journal records it finds settled + /// by tracked requests for the next process to discharge. + #[cfg(feature = "shielded")] + pub(crate) fn buffered_writes_lost(&self) -> bool { + self.buffered_writes_lost.load(Ordering::SeqCst) } pub(crate) fn store_commits_inline(&self) -> bool { diff --git a/packages/rs-platform-wallet/src/wallet/platform_wallet.rs b/packages/rs-platform-wallet/src/wallet/platform_wallet.rs index ab34c7cdfe7..48ada321060 100644 --- a/packages/rs-platform-wallet/src/wallet/platform_wallet.rs +++ b/packages/rs-platform-wallet/src/wallet/platform_wallet.rs @@ -3167,8 +3167,11 @@ impl PrivatePaymentRestore { // A record whose request the tracked one records or supersedes was // read back by an earlier process, which ended before it discharged // it: discharged now, so its store is not read for it on every launch. - // A busy store leaves it for the next restoration. - if !settled.is_empty() { + // A busy store leaves it for the next restoration. Not once a flush of + // the wallet's records failed for good in this process: the tracked + // request may be in memory only, and a restart would then find neither + // it nor the record. + if !settled.is_empty() && !self.persister.buffered_writes_lost() { let store = if wait { Some(self.coordinator.store().write().await) } else { From 6033d6565f9f346085a5e2a3620d8e41073b3951 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Tue, 10 Nov 2026 16:50:38 +0700 Subject: [PATCH 233/269] docs(platform-wallet): name the two scan state capability bits plainly Co-Authored-By: Claude Opus 5.5 --- packages/rs-platform-wallet/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/rs-platform-wallet/README.md b/packages/rs-platform-wallet/README.md index b6f7a181268..beef86e0fbf 100644 --- a/packages/rs-platform-wallet/README.md +++ b/packages/rs-platform-wallet/README.md @@ -132,7 +132,7 @@ Until a payment's reservation is restored from the journal, nothing shows that t - In a build with the `shielded` feature, a wallet stays held while its last restoration failed, or waits for a busy store. - 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` (bit 15) together with `ATOMIC_CHANGESETS` (bit 0) (`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. The wallet holds it, beyond the sender identity, for as long as the journal keeps the payment's record: removing the identity and loading it again does not release it. +A private contact request is therefore paid for only over a backend attesting `PRIVATE_CONTACT_REQUEST_SCAN_STATE` (bit 15) together with `ATOMIC_CHANGESETS` (bit 0), the two `PersistenceCapabilities::PRIVATE_CONTACT_SCAN_STATE_RESTART` names, 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. The wallet holds it, beyond the sender identity, for as long as the journal keeps the payment's record: removing the identity and loading it again does not release it. The first `PlatformWalletManager::configure_shielded` restores from the journal, with no keys, the payments of every held wallet and of every wallet the journal may keep payments of; a wallet registered or loaded after it is restored as it is published. A repeat call, as a host makes on every bind, never waits for a store that a scan holds: it restores only the wallets still held, each deferred to a task while the store is busy. Binding a wallet's shielded account restores its own, deferring the same way, the wallet held until it succeeds. A contact request refused as not restored first runs one more restoration, which does not wait on the store. A private sync does not restore. A host whose persister attests the private scan state therefore configures the shielded store when it activates the manager, before loading or creating wallets: until then every such wallet's contact requests are refused with 72, and a configure that fails keeps them refused until one succeeds. A host that opens no shielded store this session keeps those wallets' and identities' contact requests refused, and a build without the `shielded` feature keeps the marked identities' 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, why the last restoration failed if it did, whether the private store was read to its end, and whether a payment may be unresolved (the marker, or a journal record the wallet holds the pair for), so a host shows the wallet's state rather than inferring it from refusals. `DashPayView::private_pair_held` reads one pair's hold as a send decides it. From b3fb9336bb47665f324fc782b525d8af7fd1cdde Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Tue, 10 Nov 2026 17:51:20 +0700 Subject: [PATCH 234/269] feat(platform-wallet-ffi): read a pair's hold as a send decides it, and why a restoration failed The wallet no longer frees a refused private payment on a refresh: its pair stays held until the entry is read back or the payment is proven expired, and the journal can hold a pair that no identity's reservation shows. The managed-identity pending read saw only the reservation, so a host could offer a send that the wallet then refuses with 71. - platform_wallet_get_private_pair_held(wallet, sender, recipient) replaces managed_identity_get_private_reply_pending: it calls DashPayView::private_pair_held, which applies discharges first and counts the journal's holds. - platform_wallet_get_private_payments_restore_failure(wallet, identity) gives the reason behind the readiness's private_payments_restore_failed flag, null when none failed. - Both read the wallet outside the registry guard. - Docs: the readiness getter applies discharged journal records under a brief write lock (the rejection notices are gone), the sync's cursors persist with the scan state, codes 68 and 69 name the exports on this branch, and the registry rows 68 to 75 name the exports and Swift cases here. Co-Authored-By: Claude Opus 5.5 --- .../ERROR_CODE_REGISTRY.md | 16 +- .../src/contact_request.rs | 17 +- packages/rs-platform-wallet-ffi/src/error.rs | 13 +- .../src/private_contact_request.rs | 267 ++++++++++++++---- 4 files changed, 231 insertions(+), 82 deletions(-) diff --git a/packages/rs-platform-wallet-ffi/ERROR_CODE_REGISTRY.md b/packages/rs-platform-wallet-ffi/ERROR_CODE_REGISTRY.md index 877ce13fa36..877eb36db9a 100644 --- a/packages/rs-platform-wallet-ffi/ERROR_CODE_REGISTRY.md +++ b/packages/rs-platform-wallet-ffi/ERROR_CODE_REGISTRY.md @@ -183,14 +183,14 @@ Fork-era numbers remain in the collision history, which is immutable record. | 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 | | 61 | `ErrorAssetLockRecipientMismatch` | dashpay/platform#5603 (tracked-lock shielded recipient) | Proposed — a shielded fund-from-asset-lock resume (the generic outpoint resume or a repeated-UUID wallet drain) names a different Orchard recipient than the one the tracked lock recorded when it was built. Nothing was submitted or paid; resume with the recorded recipient, which the row and `TrackedAssetLockFFI` expose. Rows tracked before recipients were recorded carry none and accept the caller's. Typed in Rust, Swift and Kotlin | -| 68 | `ErrorPrivateContactRequestNeedsPrivateReply` | DashPay private contact requests | 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 request goes privately instead, and the in-kind accept picks the private reply itself. The shielded spend contract (`map_spend_result`) keeps the code, as the in-kind accept can raise it. Taken above 61–67, which open branches already hold. The exports that send and accept contact requests, and the Swift and Kotlin typing, are added by the FFI PR (#5812) and the Kotlin PR (#5813); in this tree it reaches a host through the blanket conversion | -| 69 | `ErrorShieldedBuildRequired` | DashPay private contact requests | Proposed. The operation needs the shielded pool and the library was built without the `shielded` feature: accepting in kind a request that came privately, whose 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. No export in this tree returns it yet: the in-kind accept export, and the Swift and Kotlin typing, are added by the FFI PR (#5812) and the Kotlin PR (#5813) | -| 70 | `ErrorProtocolVersionNotActive` | DashPay private contact requests | Proposed. The SDK refused a request without sending it (`dash_sdk::Error::ProtocolVersionNotActive`, not pinned) because the network is proven at a protocol version below the one that serves 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. An SDK pinned below the required version has its own code, 75. 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 and Kotlin typing is added by the FFI PR (#5812) and the Kotlin PR (#5813) | -| 71 | `ErrorPrivateContactRequestPending` | DashPay private contact requests | 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 a private payment the shielded store's journal keeps unresolved holds the pair. Nothing new was paid; a later private contact request sync settles it once the entry is read back, and the shielded sync once the payment provably never executes. Pending, not failed: the host must not submit it again. A private store read that did not finish before the request was decided is 74, not 71. The shielded spend contract (`map_spend_result`) keeps the code rather than flattening it to `ErrorWalletOperation`. Swift and Kotlin typing is added by the FFI PR (#5812) and the Kotlin PR (#5813) | -| 72 | `ErrorPrivateContactPaymentsNotRestored` | DashPay private contact requests | Proposed. Maps `PlatformWalletError::PrivateContactPaymentsNotRestored`: wallet readiness, not one contact's pending request (71). The wallet's unconfirmed private payments may hold pairs no reservation shows yet, which only a restoration from the shielded store's journal shows. In a build with the `shielded` feature: the wallet was published (created or loaded) while no shielded store was configured, its persister declaring the private scan state (bit 15), which holds a wallet removed and registered again from the same seed, in this process or a new one; it was loaded after binding a shielded account in an earlier process; or its last restoration failed, or waits for a busy store. In every build: the identity was loaded with its private scan state marking a private payment that may be unresolved. Contact requests and acceptances, public and private, are refused with nothing read, signed or broadcast until they are restored: configuring the shielded store restores them with no keys, as binding the wallet's shielded account does, and a contact request first retries a restoration once without waiting on the store; a private sync does not, and a build without the `shielded` feature never does. A host whose persister declares the private scan state configures the shielded store when it activates the manager, before loading or creating wallets: until it does, and while a failed configure is not retried, 72 holds. `platform_wallet_get_private_contact_readiness` reads whether the payments are restored and whether the last restoration failed (`private_payments_restore_failed`; the wallet's `PrivateContactReadiness::private_payments_restore_failure` holds the reason). Swift and Kotlin typing is added by the FFI PR (#5812) and the Kotlin PR (#5813); hosts treat it as held until the readiness reads restored | -| 73 | `ErrorPrivateContactRecoveryUnsupported` | DashPay private contact requests | Proposed. Maps `PlatformWalletError::PrivateContactRecoveryUnsupported`: the persister does not declare `PRIVATE_CONTACT_REQUEST_SCAN_STATE` (bit 15) 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. The FFI constant for bit 15 and the Swift and Kotlin typing are added by the FFI PR (#5812) and the Kotlin PR (#5813) | -| 74 | `ErrorPrivateContactStoreNotRead` | DashPay private contact requests | Proposed. Maps `PlatformWalletError::PrivateContactStoreNotRead`: DashPay's private contact request store was not read to its end for the identity, with the keys Platform proves it holds, before a contact request or acceptance, public or private, was decided (every one reads it from the identity's cursor first). Nothing was reserved, sealed, paid or broadcast; calling again continues the read from where it stopped. A node behind the store already seen, or keys that kept changing, stop it. It was reported as 71 before, which says not to submit again. Mapped by the blanket conversion and kept by the shielded spend contract (`map_spend_result`). Swift and Kotlin typing is added by the FFI PR (#5812) and the Kotlin PR (#5813) | -| 75 | `ErrorSdkPinnedBelowProtocolVersion` | DashPay private contact requests | Proposed. The SDK refused a request without sending it because the SDK itself is pinned below the protocol version the request needs (`dash_sdk::Error::ProtocolVersionNotActive` with `pinned`), whatever the network runs: for example every contact request's private store read with the SDK pinned below protocol version 15 on a network at 15. A network upgrade does not lift it; only raising or removing the pin does. Before, it shared 70 with a network proven below the version, which lifts by itself. Mapped by the blanket conversion. Swift and Kotlin typing is added by the FFI PR (#5812) and the Kotlin PR (#5813) | +| 68 | `ErrorPrivateContactRequestNeedsPrivateReply` | DashPay private contact requests | 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 request goes privately instead, and the in-kind accept picks the private reply itself. The shielded spend contract (`map_spend_result`) keeps the code, as the in-kind accept can raise it. Taken above 61–67, which open branches already hold. Returned by the public send and accept; the host sends with `platform_wallet_manager_send_private_contact_request`, or 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`. Swift types it (`PlatformWalletError.privateContactRequestNeedsPrivateReply`, `ErrorHandlingTests` pin); Kotlin typing is added by the Kotlin PR (#5813) | +| 69 | `ErrorShieldedBuildRequired` | DashPay private contact requests | Proposed. The operation needs the shielded pool and the library was built without the `shielded` feature: accepting in kind a request that came privately, whose 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. Returned by `platform_wallet_manager_accept_contact_request_replying_in_kind`, exported in every build. Swift types it (`PlatformWalletError.shieldedBuildRequired`, `ErrorHandlingTests` pin); Kotlin typing is added by the Kotlin PR (#5813) | +| 70 | `ErrorProtocolVersionNotActive` | DashPay private contact requests | Proposed. The SDK refused a request without sending it (`dash_sdk::Error::ProtocolVersionNotActive`, not pinned) because the network is proven at a protocol version below the one that serves 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. An SDK pinned below the required version has its own code, 75. 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 types it (`PlatformWalletError.protocolVersionNotActive`, `ErrorHandlingTests` pin) and SwiftExampleApp keeps a refresh quiet on it; Kotlin typing is added by the Kotlin PR (#5813) | +| 71 | `ErrorPrivateContactRequestPending` | DashPay private contact requests | 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 a private payment the shielded store's journal keeps unresolved holds the pair. Nothing new was paid; a later private contact request sync settles it once the entry is read back, and the shielded sync once the payment provably never executes. Pending, not failed: the host must not submit it again. A private store read that did not finish before the request was decided is 74, not 71. The shielded spend contract (`map_spend_result`) keeps the code rather than flattening it to `ErrorWalletOperation`. Returned by the private send, the private accept and the in-kind accept, and by a public send or accept the pair holds; `platform_wallet_get_private_pair_held` reads one pair's hold as a send decides it. Swift types it (`PlatformWalletError.privateContactRequestPending`, `ErrorHandlingTests` pin; `ManagedPlatformWallet.privatePairHeld`), and SwiftExampleApp keeps the pair pending until that read says free; Kotlin typing is added by the Kotlin PR (#5813) | +| 72 | `ErrorPrivateContactPaymentsNotRestored` | DashPay private contact requests | Proposed. Maps `PlatformWalletError::PrivateContactPaymentsNotRestored`: wallet readiness, not one contact's pending request (71). The wallet's unconfirmed private payments may hold pairs no reservation shows yet, which only a restoration from the shielded store's journal shows. In a build with the `shielded` feature: the wallet was published (created or loaded) while no shielded store was configured, its persister declaring the private scan state (bit 15), which holds a wallet removed and registered again from the same seed, in this process or a new one; it was loaded after binding a shielded account in an earlier process; or its last restoration failed, or waits for a busy store. In every build: the identity was loaded with its private scan state marking a private payment that may be unresolved. Contact requests and acceptances, public and private, are refused with nothing read, signed or broadcast until they are restored: configuring the shielded store restores them with no keys, as binding the wallet's shielded account does, and a contact request first retries a restoration once without waiting on the store; a private sync does not, and a build without the `shielded` feature never does. A host whose persister declares the private scan state configures the shielded store when it activates the manager, before loading or creating wallets: until it does, and while a failed configure is not retried, 72 holds. `platform_wallet_get_private_contact_readiness` reads whether the payments are restored and whether the last restoration failed (`private_payments_restore_failed`; the wallet's `PrivateContactReadiness::private_payments_restore_failure` holds the reason). `platform_wallet_get_private_payments_restore_failure` gives that reason. Swift types it (`PlatformWalletError.privateContactPaymentsNotRestored`, `ErrorHandlingTests` pin) and binds both reads (`ManagedPlatformWallet.privateContactReadiness`, `privatePaymentsRestoreFailure`); SwiftExampleApp configures the shielded store when it activates a manager, before loading wallets, retries a failed configure, and shows "setting up" or the failure's reason until the readiness reads restored. Kotlin typing is added by the Kotlin PR (#5813) | +| 73 | `ErrorPrivateContactRecoveryUnsupported` | DashPay private contact requests | Proposed. Maps `PlatformWalletError::PrivateContactRecoveryUnsupported`: the persister does not declare `PRIVATE_CONTACT_REQUEST_SCAN_STATE` (bit 15) 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. The FFI constant is `PLATFORM_WALLET_PERSISTENCE_CAPABILITY_PRIVATE_CONTACT_REQUEST_SCAN_STATE` (`1 << 15`). Swift types it (`PlatformWalletError.privateContactRecoveryUnsupported`, `ErrorHandlingTests` pin), SwiftExampleApp shows it as a storage configuration error, and the SwiftData handler declares bit 0 and bit 15 with the begin and end callbacks wired; Kotlin typing is added by the Kotlin PR (#5813) | +| 74 | `ErrorPrivateContactStoreNotRead` | DashPay private contact requests | Proposed. Maps `PlatformWalletError::PrivateContactStoreNotRead`: DashPay's private contact request store was not read to its end for the identity, with the keys Platform proves it holds, before a contact request or acceptance, public or private, was decided (every one reads it from the identity's cursor first). Nothing was reserved, sealed, paid or broadcast; calling again continues the read from where it stopped. A node behind the store already seen, or keys that kept changing, stop it. It was reported as 71 before, which says not to submit again. Mapped by the blanket conversion and kept by the shielded spend contract (`map_spend_result`). Swift types it (`PlatformWalletError.privateContactStoreNotRead`, `ErrorHandlingTests` pin) and SwiftExampleApp offers a retry with no pending row; Kotlin typing is added by the Kotlin PR (#5813) | +| 75 | `ErrorSdkPinnedBelowProtocolVersion` | DashPay private contact requests | Proposed. The SDK refused a request without sending it because the SDK itself is pinned below the protocol version the request needs (`dash_sdk::Error::ProtocolVersionNotActive` with `pinned`), whatever the network runs: for example every contact request's private store read with the SDK pinned below protocol version 15 on a network at 15. A network upgrade does not lift it; only raising or removing the pin does. Before, it shared 70 with a network proven below the version, which lifts by itself. Mapped by the blanket conversion. Swift types it (`PlatformWalletError.sdkPinnedBelowProtocolVersion`, `ErrorHandlingTests` pin) and SwiftExampleApp shows a developer-facing message; Kotlin typing is added by the Kotlin PR (#5813) | **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 a8fc769a3d7..203660d7d3f 100644 --- a/packages/rs-platform-wallet-ffi/src/contact_request.rs +++ b/packages/rs-platform-wallet-ffi/src/contact_request.rs @@ -254,8 +254,8 @@ pub struct PrivateContactReadinessFFI { pub unresolved_private_payment: bool, /// Whether the last restoration of the wallet's private payments failed, /// while `private_payments_restored` is false for it: then 72 holds until - /// a restoration succeeds, not only until one runs. The reason is in the - /// wallet's log; an export for it is left to the FFI PR. + /// a restoration succeeds, not only until one runs. The reason is + /// `platform_wallet_get_private_payments_restore_failure`. pub private_payments_restore_failed: bool, } @@ -274,13 +274,12 @@ impl From for PrivateContactReadinessFFI { /// 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. Not a pure read: it first releases the -/// private requests whose payment was settled as never executing, briefly -/// taking the wallet's write lock to consume the wallet's rejection notices, -/// so a pair rejected for good reads as free with no restart and no other -/// request; then it reads live wallet state. Read it before -/// `managed_identity_get_private_reply_pending`, whose snapshot does not -/// consume the notices. +/// request by the wallet's own state. Not a pure read: a journal record +/// discharged since (a payment proven never to execute) is applied first, +/// briefly taking the wallet's write lock, as a send would see it; then it +/// reads live wallet state. Why a failed restoration failed is +/// `platform_wallet_get_private_payments_restore_failure`; one pair's hold is +/// `platform_wallet_get_private_pair_held`. /// /// `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 diff --git a/packages/rs-platform-wallet-ffi/src/error.rs b/packages/rs-platform-wallet-ffi/src/error.rs index ea9f145388b..0e712c99b93 100644 --- a/packages/rs-platform-wallet-ffi/src/error.rs +++ b/packages/rs-platform-wallet-ffi/src/error.rs @@ -647,15 +647,18 @@ pub enum PlatformWalletFFIResultCode { /// public accept refuse it, and nothing was sent: the request goes /// privately instead, and the in-kind accept picks the private reply /// itself. The shielded spend contract (`map_spend_result`) keeps the code, - /// as the in-kind accept can raise it. The exports that send and accept - /// contact requests are added by the FFI PR. 62 to 67 are claimed by open - /// branches, so this takes 68. + /// as the in-kind accept can raise it. Send it with + /// `platform_wallet_manager_send_private_contact_request`, or accept it + /// with `platform_wallet_manager_accept_contact_request_replying_in_kind` + /// or `platform_wallet_manager_accept_private_contact_request`. 62 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: accepting a contact request that came - /// privately in kind, whose private reply is paid from the pool (the - /// export is added by the FFI PR; no export in this crate returns it yet). + /// privately with + /// `platform_wallet_manager_accept_contact_request_replying_in_kind` + /// (exported in every build), 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, diff --git a/packages/rs-platform-wallet-ffi/src/private_contact_request.rs b/packages/rs-platform-wallet-ffi/src/private_contact_request.rs index 3ca258d32cd..02b7ddcfe59 100644 --- a/packages/rs-platform-wallet-ffi/src/private_contact_request.rs +++ b/packages/rs-platform-wallet-ffi/src/private_contact_request.rs @@ -25,13 +25,18 @@ //! - [`managed_identity_get_private_contact`]: what a contact's private //! requests carry beyond the `ContactRequest`: whether they came or went //! privately, and the contact's shielded address. -//! - [`managed_identity_get_private_reply_pending`]: whether a private request -//! to a contact is in flight or not yet read back, so a host offers no -//! second send or reply. +//! - [`platform_wallet_get_private_pair_held`]: whether a contact request +//! from one of the wallet's identities to a contact is held for a private +//! one, as a send decides it, so a host offers no second send or reply. +//! - [`platform_wallet_get_private_payments_restore_failure`]: why the last +//! restoration of the wallet's private payments failed, which +//! `platform_wallet_get_private_contact_readiness` reports only as a flag. //! //! The explicitly public send and accept refuse a contact whose requests came //! or went privately with `ErrorPrivateContactRequestNeedsPrivateReply`. +use std::ffi::CString; +use std::os::raw::c_char; use std::sync::{Arc, Mutex}; use platform_wallet::{PlatformWalletError, PrivateContact}; @@ -52,8 +57,6 @@ use dash_sdk::dapi_grpc::platform::v0::ResponseMetadata; #[cfg(feature = "shielded")] use std::ffi::CStr; #[cfg(feature = "shielded")] -use std::os::raw::c_char; -#[cfg(feature = "shielded")] use std::sync::atomic::{AtomicBool, Ordering}; #[cfg(feature = "shielded")] @@ -143,8 +146,11 @@ fn saturating_count(count: usize) -> u32 { /// /// `core_signer_handle` is the wallet-HD resolver signer: the store is opened /// with keys derived inside it. Each request's signature is checked against -/// its sender's keys, which needs no secret. The cursors live in memory only, -/// so the first pass of a process reads the store from its start. +/// its sender's keys, which needs no secret. Each identity's cursor is stored +/// with the requests read before it and restored at load when the persister +/// attests `PLATFORM_WALLET_PERSISTENCE_CAPABILITY_PRIVATE_CONTACT_REQUEST_SCAN_STATE` +/// with atomic changesets; otherwise the first pass of a process reads the +/// store from its start. /// /// Writes the requests the pass ingested, received and sent (including ones /// already held), to `out_requests` (release with @@ -299,36 +305,100 @@ pub unsafe extern "C" fn managed_identity_get_private_contact( PlatformWalletFFIResult::ok() } -/// Whether a private contact request from this identity to `contact_id` is -/// being paid for, or was paid for and is not yet read back from the store -/// (`DashPayState::private_reply_pending`), into `out_pending`. While it is, -/// the wallet refuses another private send or reply to the contact with -/// `ErrorPrivateContactRequestPending`, so a host shows the contact as pending -/// and offers neither; a later `platform_wallet_sync_private_contact_requests` -/// settles it. A read of the managed-identity snapshot, which does not consume -/// the wallet's rejection notices: read -/// `platform_wallet_get_private_contact_readiness` (or refresh) first, so a -/// pair whose payment was rejected for good is already released. After a -/// restart a reply paid for in an earlier process is pending again once the -/// wallet restores its private payments from the shielded store. +/// Whether a contact request from `sender_id`, one of the wallet's +/// identities, to `recipient_id`, public or private, is held for a private +/// one, as a send decides it (`DashPayView::private_pair_held`), into +/// `out_held`: a private request to the recipient is being paid for, or was +/// paid for and is not yet read back from the store, or a private payment the +/// shielded store's journal keeps unresolved holds the pair, also after the +/// sender identity was removed and added again. While it is, a private send +/// or reply to the recipient is refused with `ErrorPrivateContactRequestPending` +/// (71) and a public one is held, so a host shows the pair as pending and +/// offers neither. The entry read back +/// (`platform_wallet_sync_private_contact_requests`) frees it, as does the +/// payment proven never to execute (its anchor pruned, which the shielded +/// sync judges); an endpoint's refusal of the payment does not. Not a pure +/// read: a journal record discharged since is applied first, briefly taking +/// the wallet's write lock, so a discharge reads as freed with no send or +/// sync in between. +/// +/// `out_held` is reset to false first. An unknown `wallet_handle` is +/// `NotFound`; a sender the wallet does not hold is refused as its lookup is. /// /// # Safety -/// - `contact_id` must point to 32 readable bytes. -/// - `out_pending` must be a valid, writable pointer. +/// - `sender_id` and `recipient_id` must each point to 32 readable bytes. +/// - `out_held` must be a valid, writable pointer. #[no_mangle] -pub unsafe extern "C" fn managed_identity_get_private_reply_pending( - identity_handle: Handle, - contact_id: *const u8, - out_pending: *mut bool, +pub unsafe extern "C" fn platform_wallet_get_private_pair_held( + wallet_handle: Handle, + sender_id: *const u8, + recipient_id: *const u8, + out_held: *mut bool, ) -> PlatformWalletFFIResult { - check_ptr!(out_pending); - unsafe { *out_pending = false }; - let contact_id = unwrap_result_or_return!(unsafe { read_identifier(contact_id) }); - let option = MANAGED_IDENTITY_STORAGE.with_item(identity_handle, |identity| { - identity.dashpay().private_reply_pending(&contact_id) + check_ptr!(out_held); + unsafe { *out_held = false }; + let sender_id = unwrap_result_or_return!(unsafe { read_identifier(sender_id) }); + let recipient_id = unwrap_result_or_return!(unsafe { read_identifier(recipient_id) }); + // Look the wallet up under the registry guard, but read outside it: the + // read may wait on the wallet's write lock, and a registry guard held + // across that wait would stall `platform_wallet_destroy`. + let option = + PLATFORM_WALLET_STORAGE.with_item(wallet_handle, |wallet| wallet.identity().clone()); + let identity = unwrap_option_or_return!(option); + let result = block_on_worker(async move { + identity + .dashpay() + .private_pair_held(&sender_id, &recipient_id) + .await }); - let pending = unwrap_option_or_return!(option); - unsafe { *out_pending = pending }; + let held = unwrap_result_or_return!(result); + unsafe { *out_held = held }; + PlatformWalletFFIResult::ok() +} + +/// Why the last restoration of the wallet's private payments failed, while +/// they are not restored for `identity_id` and none succeeded since +/// (`PrivateContactReadiness::private_payments_restore_failure`, which +/// `platform_wallet_get_private_contact_readiness` reports as +/// `private_payments_restore_failed`), into `out_reason`: a NUL-terminated +/// UTF-8 string the caller releases with `platform_wallet_string_free`, or +/// null when none failed (the payments are restored, or only not restored +/// yet). It reads the readiness, so a journal record discharged since is +/// applied first, as there. +/// +/// `out_reason` is reset to null 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_reason` must be a valid, writable pointer. +#[no_mangle] +pub unsafe extern "C" fn platform_wallet_get_private_payments_restore_failure( + wallet_handle: Handle, + identity_id: *const u8, + out_reason: *mut *mut c_char, +) -> PlatformWalletFFIResult { + check_ptr!(out_reason); + unsafe { *out_reason = std::ptr::null_mut() }; + let identity_id = unwrap_result_or_return!(unsafe { read_identifier(identity_id) }); + // Look the wallet up under the registry guard, but read outside it: the + // read may wait on the wallet's write lock, and a registry guard held + // across that wait would stall `platform_wallet_destroy`. + let option = + PLATFORM_WALLET_STORAGE.with_item(wallet_handle, |wallet| wallet.identity().clone()); + let identity = unwrap_option_or_return!(option); + let result = block_on_worker(async move { + identity + .dashpay() + .private_contact_readiness(&identity_id) + .await + }); + let readiness = unwrap_result_or_return!(result); + if let Some(reason) = readiness.private_payments_restore_failure { + let reason = unwrap_result_or_return!(CString::new(reason)); + unsafe { *out_reason = reason.into_raw() }; + } PlatformWalletFFIResult::ok() } @@ -1671,52 +1741,129 @@ mod tests { assert!(*replied_reads > 0, "the DashPay crypto reads the mnemonic"); } - /// A managed identity with no private request in flight reports none; an - /// unknown handle is not found and leaves the flag false; a null output is - /// refused. Reserving a reply is the wallet's own (its tests pin the - /// reservation), so this pins the read. + /// A wallet whose identity is paying for nothing to a contact reads the + /// pair as free and has no restoration failure to give; an unknown wallet + /// is not found and an unknown sender refused, each leaving the output + /// reset; a null output is refused. Holding a pair is the wallet's own + /// (its tests pin the reservation and the journal mirror), so this pins + /// the reads. #[test] - fn should_read_whether_a_private_reply_is_pending() { + fn should_read_a_pair_s_hold_and_the_restore_failure_from_the_wallet() { + use crate::runtime::runtime; + use crate::test_fixtures::mock_manager; use dpp::identity::{Identity, IdentityV0}; - use platform_wallet::ManagedIdentity; + use key_wallet::wallet::initialization::WalletAccountCreationOptions; + let sender_id = [0xB0u8; 32]; + let recipient_id = [0xA0u8; 32]; + let manager = mock_manager(); + let wallet = runtime() + .block_on(manager.create_wallet_from_seed_bytes( + key_wallet::Network::Testnet, + &[11; 64], + WalletAccountCreationOptions::Default, + Some(0), + )) + .expect("a wallet"); let identity = Identity::V0(IdentityV0 { - id: [0xB0; 32].into(), + id: sender_id.into(), public_keys: Default::default(), balance: 0, revision: 0, }); - let handle = MANAGED_IDENTITY_STORAGE.insert(ManagedIdentity::new(identity, 0)); - let contact_id = [0xA0u8; 32]; + 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 mut pending = true; - let result = unsafe { - managed_identity_get_private_reply_pending(handle, contact_id.as_ptr(), &mut pending) + let mut held = true; + let free = unsafe { + platform_wallet_get_private_pair_held( + handle, + sender_id.as_ptr(), + recipient_id.as_ptr(), + &mut held, + ) }; - assert_eq!(result.code, PlatformWalletFFIResultCode::Success); - assert!(!pending, "nothing is in flight to the contact"); + assert_eq!(free.code, PlatformWalletFFIResultCode::Success); + assert!(!held, "nothing holds the pair"); - let mut pending = true; - let result = unsafe { - managed_identity_get_private_reply_pending( - 0xDEAD_BEEF, - contact_id.as_ptr(), - &mut pending, + let mut reason: *mut c_char = std::ptr::dangling_mut(); + let none = unsafe { + platform_wallet_get_private_payments_restore_failure( + handle, + sender_id.as_ptr(), + &mut reason, ) }; - assert_eq!(result.code, PlatformWalletFFIResultCode::NotFound); - assert!(!pending); + assert_eq!(none.code, PlatformWalletFFIResultCode::Success); + assert!(reason.is_null(), "no restoration failed"); + + let mut held = true; + let unknown_wallet = unsafe { + platform_wallet_get_private_pair_held( + Handle::MAX, + sender_id.as_ptr(), + recipient_id.as_ptr(), + &mut held, + ) + }; + assert_eq!(unknown_wallet.code, PlatformWalletFFIResultCode::NotFound); + assert!(!held, "reset"); + let mut reason: *mut c_char = std::ptr::dangling_mut(); + let unknown_wallet = unsafe { + platform_wallet_get_private_payments_restore_failure( + Handle::MAX, + sender_id.as_ptr(), + &mut reason, + ) + }; + assert_eq!(unknown_wallet.code, PlatformWalletFFIResultCode::NotFound); + assert!(reason.is_null(), "reset"); - let result = unsafe { - managed_identity_get_private_reply_pending( + let stranger = [0xC0u8; 32]; + let mut held = true; + let unknown_sender = unsafe { + platform_wallet_get_private_pair_held( handle, - contact_id.as_ptr(), - std::ptr::null_mut(), + stranger.as_ptr(), + recipient_id.as_ptr(), + &mut held, ) }; - assert_eq!(result.code, PlatformWalletFFIResultCode::ErrorNullPointer); + assert_ne!(unknown_sender.code, PlatformWalletFFIResultCode::Success); + assert!(!held); + + assert_eq!( + unsafe { + platform_wallet_get_private_pair_held( + handle, + sender_id.as_ptr(), + recipient_id.as_ptr(), + std::ptr::null_mut(), + ) + } + .code, + PlatformWalletFFIResultCode::ErrorNullPointer + ); + assert_eq!( + unsafe { + platform_wallet_get_private_payments_restore_failure( + handle, + sender_id.as_ptr(), + std::ptr::null_mut(), + ) + } + .code, + PlatformWalletFFIResultCode::ErrorNullPointer + ); - let _ = MANAGED_IDENTITY_STORAGE.remove(handle); + let _ = PLATFORM_WALLET_STORAGE.remove(handle); } #[test] From 78104858c7aec07fcc335c7bd9144f77ec651677 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Tue, 10 Nov 2026 17:51:20 +0700 Subject: [PATCH 235/269] feat(swift-sdk): type codes 74 and 75, read a pair's hold and the restore failure - PlatformWalletError.privateContactStoreNotRead (74): the private store read every contact request runs first did not finish; nothing was reserved or sent, and calling again continues it. - PlatformWalletError.sdkPinnedBelowProtocolVersion (75): the SDK is pinned below the protocol version a request needs. - PrivateContactReadiness gains privatePaymentsRestoreFailed, the C struct's new field. - ManagedPlatformWallet.privatePairHeld(senderId:recipientId:) replaces ManagedIdentity.privateReplyPending(contactId:). - ManagedPlatformWallet.privatePaymentsRestoreFailure(identityId:) reads why the last restoration failed. - Docs follow the wallet: 16 for a note spend only when nothing was sent, 18 for anything that may have been delivered, 70 for a network below the version, 71 only for a held pair, and 72's new causes. Removing an identity can be refused with 71 or 72. The readiness read applies discharges instead of consuming rejection notices, and nothing reads it before the pending read any more. Co-Authored-By: Claude Opus 5.5 --- .../PlatformWallet/IdentityManager.swift | 5 +- .../PlatformWallet/PlatformWalletResult.swift | 133 +++++++++++----- .../PrivateContactRequests.swift | 145 +++++++++++------- .../ErrorHandlingTests.swift | 46 +++++- 4 files changed, 232 insertions(+), 97 deletions(-) diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/IdentityManager.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/IdentityManager.swift index a057c1a9003..91a65df6b4f 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/IdentityManager.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/IdentityManager.swift @@ -30,7 +30,10 @@ public class IdentityManager { try identity_manager_add_identity(handle, identity.handle).check() } - /// Remove an identity from the manager + /// Remove an identity from the manager. Refused while its contact request + /// channel is held: `PlatformWalletError.privateContactRequestPending` + /// for a request in flight or a private payment that may still execute, + /// `.privateContactPaymentsNotRestored` for payments not restored yet. public func removeIdentity(_ identityId: Identifier) throws { try identityId.withFFIBytes { idPtr in try identity_manager_remove_identity(handle, idPtr).check() diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift index d5e76c131ae..2d72024dd45 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift @@ -21,23 +21,29 @@ public enum PlatformWalletResultCode: Int32, Sendable { case errorArithmeticOverflow = 13 case errorNoSelectableInputs = 14 case errorWalletAlreadyExists = 15 - /// Definitive shielded-broadcast failure: the shielded transition - /// (identity-create, unshield, transfer, or withdrawal) was not executed - /// (relay/CheckTx rejection or a platform-reported execution error), the - /// spent notes were released, and the caller may retry. + /// Definitive shielded-broadcast failure: the shielded transition was + /// not executed, the spent notes were released, and the caller may retry. + /// For a spend that consumes notes it never left the wallet (refused + /// before anything was sent); an endpoint's verdict, which no proof + /// backs, or a transport failure after a dispatch is + /// `errorShieldedSpendUnconfirmed` (or `errorShieldedBroadcastUnconfirmed` + /// for an identity created from the pool). A shield into the pool still + /// reports a verdict here. case errorShieldedBroadcastFailed = 16 /// Shielded broadcast accepted but its execution result could not be /// confirmed; the identity may already exist on chain. The FFI also fills /// the derived id into `outIdentityId` on this code, so the caller can /// hold the slot rather than treat the registration as failed. case errorShieldedBroadcastUnconfirmed = 17 - /// A shielded operation (shield / unshield / transfer / withdrawal) was - /// accepted by the relay but its execution result could not be - /// confirmed. It may have executed on chain; for the spend-based - /// operations the wallet keeps the notes reserved (a shield reserves - /// nothing) until the next sync (or app restart) reconciles the - /// outcome. Do NOT auto-retry — a retry would rebuild the bundle and - /// could double-execute if the original landed. + /// A shielded operation was accepted by the relay, or may have been + /// delivered (an endpoint's verdict or a transport failure after a + /// dispatch, for every spend that consumes notes), but its execution + /// result could not be confirmed. It may have executed on chain; for the + /// spend-based operations the wallet keeps the notes reserved and the + /// recovery record (a shield reserves nothing) until a sync shows it + /// landed or it provably never can. Do NOT auto-retry: a retry would + /// rebuild the bundle with other notes and could double-execute if the + /// original landed. case errorShieldedSpendUnconfirmed = 18 /// A shielded spend could not be built against a Platform-recorded anchor: /// the wallet's commitment tree isn't synced to a checkpoint Platform has @@ -279,17 +285,25 @@ public enum PlatformWalletResultCode: Int32, Sendable { /// The network is proven at a protocol version below the one that /// serves the request (for example the private contact request store /// before protocol version 15), so the SDK did not send it. Expected on - /// such a network; it lifts when the network upgrades. + /// such a network; it lifts when the network upgrades. An SDK pinned + /// below the version is `errorSdkPinnedBelowProtocolVersion` (75). case errorProtocolVersionNotActive = 70 - /// A private contact request to that identity is in flight, or paid for - /// and not yet read back from the store. Nothing new was paid; a later - /// private contact request sync settles it. Do not submit it again. + /// A private contact request to that identity is pending: in flight, or + /// paid for and not yet read back from the store, or a private payment + /// the shielded store's journal keeps unresolved holds the pair. Nothing + /// new was paid. Do not submit it again: the entry read back by a private + /// sync, or the payment proven never to execute, frees it. An unfinished + /// private store read is `errorPrivateContactStoreNotRead` (74), not this. case errorPrivateContactRequestPending = 71 - /// The wallet, not one contact, is held: its private payments from an - /// earlier process are not restored from the shielded store yet, so no - /// public contact request is sent or accepted. Configuring the shielded - /// store or binding the wallet's shielded account restores them; a - /// private sync does not. + /// The wallet, not one contact, is held: its private payments are not + /// restored from the shielded store's journal, so no contact request, + /// public or private, is sent or accepted. The wallet was published + /// with no shielded store configured (configure it when the manager is + /// activated, before loading or creating wallets), it bound a shielded + /// account in an earlier process, its last restoration failed or waits + /// for a busy store, 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; a private sync does not. case errorPrivateContactPaymentsNotRestored = 72 /// The persister cannot keep a private payment's recovery state across /// a restart (it does not declare the private contact request scan @@ -297,6 +311,15 @@ public enum PlatformWalletResultCode: Int32, Sendable { /// before anything is sealed or paid. A configuration error, not /// retryable as it is. case errorPrivateContactRecoveryUnsupported = 73 + /// DashPay's private contact request store could not be read to its end + /// for the identity before a contact request or acceptance, public or + /// private, was decided. Nothing was reserved, sent or paid; calling + /// again continues the read. Not a pending request. + case errorPrivateContactStoreNotRead = 74 + /// The SDK is pinned below the protocol version the request needs, so it + /// did not send it, whatever the network runs. A network upgrade does not + /// lift it; only raising or removing the pin does. + case errorSdkPinnedBelowProtocolVersion = 75 /// The named thing does not exist. Besides the handle/lookup failures this /// has always covered, BOTH deferred-send paths report the /// wallet-was-REMOVED case here. @@ -442,6 +465,10 @@ public enum PlatformWalletResultCode: Int32, Sendable { self = .errorPrivateContactPaymentsNotRestored case PLATFORM_WALLET_FFI_RESULT_CODE_ERROR_PRIVATE_CONTACT_RECOVERY_UNSUPPORTED: self = .errorPrivateContactRecoveryUnsupported + case PLATFORM_WALLET_FFI_RESULT_CODE_ERROR_PRIVATE_CONTACT_STORE_NOT_READ: + self = .errorPrivateContactStoreNotRead + case PLATFORM_WALLET_FFI_RESULT_CODE_ERROR_SDK_PINNED_BELOW_PROTOCOL_VERSION: + self = .errorSdkPinnedBelowProtocolVersion case PLATFORM_WALLET_FFI_RESULT_CODE_NOT_FOUND: self = .notFound case PLATFORM_WALLET_FFI_RESULT_CODE_ERROR_UNKNOWN: @@ -658,23 +685,30 @@ public enum PlatformWalletError: LocalizedError { /// The network does not serve the request yet: it is proven at a protocol /// version below the one that does, so the SDK did not send it. Expected /// on such a network (for example the private contact request store before - /// protocol version 15); a host may stay quiet about it. + /// protocol version 15); a host may stay quiet about it. An SDK pinned + /// below the version is `sdkPinnedBelowProtocolVersion` instead. case protocolVersionNotActive(String) - /// A private contact request to that identity is in flight (another - /// private 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 - /// launch. Nothing new was paid; a later - /// `ManagedPlatformWallet.syncPrivateContactRequests`, or a retry, settles - /// it. Pending, not failed: do not submit it again. + /// A private contact request to that identity is pending (code 71): in + /// flight (another private send or reply reserved it), or paid for and + /// not yet read back from the store, or a private payment the shielded + /// store's journal keeps unresolved holds the pair. Nothing new was paid. + /// Pending, not failed: do not submit it again. A + /// `ManagedPlatformWallet.syncPrivateContactRequests` that reads the + /// entry back frees it, as does the payment proven never to execute; an + /// endpoint's refusal of the payment does not. + /// `ManagedPlatformWallet.privatePairHeld(senderId:recipientId:)` reads + /// whether the pair is still held. case privateContactRequestPending(String) - /// The wallet's private payments from an earlier process are not - /// restored from the shielded store yet, so its public contact requests, - /// sent or accepted, are held (code 72). Nothing was signed or sent. - /// `PlatformWalletManager.configureShielded` or binding the wallet's - /// shielded account restores them; a private sync does not. + /// The wallet's private payments are not restored from the shielded + /// store's journal, so its contact requests, public or private, sent or + /// accepted, are held (code 72). Nothing was signed or sent. + /// `PlatformWalletManager.configureShielded` (called when the manager is + /// activated, before loading or creating wallets, and again after a + /// failure) or binding the wallet's shielded account restores them; a + /// private sync does not. /// `ManagedPlatformWallet.privateContactReadiness(identityId:)` reads - /// whether they are restored. + /// whether they are restored and whether the last restoration failed, + /// and `privatePaymentsRestoreFailure(identityId:)` why. case privateContactPaymentsNotRestored(String) /// The wallet's storage cannot keep a private payment's recovery state /// across a restart (code 73): it does not declare the private contact @@ -682,10 +716,23 @@ public enum PlatformWalletError: LocalizedError { /// or reply is refused before anything is sealed or paid. A /// configuration error of the store, not something a retry fixes. case privateContactRecoveryUnsupported(String) + /// DashPay's private contact request store could not be read to its end + /// for the identity before a contact request or acceptance, public or + /// private, was decided (code 74): a node behind the store already seen, + /// or keys that kept changing, stop the read. Nothing was reserved, sent + /// or paid; calling again continues the read. Not a pending request. + case privateContactStoreNotRead(String) + /// The SDK is pinned below the protocol version the request needs, so it + /// did not send it, whatever the network runs (code 75). Only raising or + /// removing the SDK's protocol version pin lifts it: a developer's + /// setting, not something the user or a network upgrade changes. + case sdkPinnedBelowProtocolVersion(String) case walletAlreadyExists(String) /// Definitive shielded-broadcast failure: the shielded transition /// (identity-create or a spend — unshield / transfer / withdrawal) was - /// not executed and the spent notes were released; safe to retry. + /// not executed and the spent notes were released; safe to retry. For a + /// spend that consumes notes, only one refused before anything was sent: + /// one that may have been delivered is `shieldedSpendUnconfirmed`. case shieldedBroadcastFailed(String) /// Shielded broadcast accepted but its execution result could not be /// confirmed; the identity may already exist on chain. Callers that need @@ -694,11 +741,12 @@ public enum PlatformWalletError: LocalizedError { /// `outIdentityId`) before falling back to this error — see /// `ShieldedIdentityCreateUnconfirmedError`. case shieldedBroadcastUnconfirmed(String) - /// A shielded operation (shield / unshield / transfer / withdrawal) - /// was accepted by the relay but its execution result could not be - /// confirmed. It may have executed; spend-based operations keep their - /// notes reserved wallet-side (a shield reserves nothing) until the - /// next sync reconciles the outcome. Do NOT auto-retry. + /// A shielded operation was accepted by the relay, or may have been + /// delivered (for a spend that consumes notes, any endpoint verdict or + /// transport failure after a dispatch), but its execution result could + /// not be confirmed. It may have executed; spend-based operations keep + /// their notes reserved wallet-side (a shield reserves nothing) until a + /// sync shows it landed or it provably never can. Do NOT auto-retry. case shieldedSpendUnconfirmed(String) /// A shielded spend could not be built against a Platform-recorded anchor — /// the wallet's commitment tree isn't synced to a checkpoint Platform has @@ -911,6 +959,7 @@ public enum PlatformWalletError: LocalizedError { .shieldedBuildRequired(let m), .protocolVersionNotActive(let m), .privateContactRequestPending(let m), .privateContactPaymentsNotRestored(let m), .privateContactRecoveryUnsupported(let m), + .privateContactStoreNotRead(let m), .sdkPinnedBelowProtocolVersion(let m), .walletAlreadyExists(let m), .shieldedBroadcastFailed(let m), .shieldedBroadcastUnconfirmed(let m), .shieldedSpendUnconfirmed(let m), .shieldedNoRecordedAnchor(let m), .shieldedInsufficientBalance(let m), @@ -1042,6 +1091,10 @@ public enum PlatformWalletError: LocalizedError { self = .privateContactPaymentsNotRestored(detail) case .errorPrivateContactRecoveryUnsupported: self = .privateContactRecoveryUnsupported(detail) + case .errorPrivateContactStoreNotRead: + self = .privateContactStoreNotRead(detail) + case .errorSdkPinnedBelowProtocolVersion: + self = .sdkPinnedBelowProtocolVersion(detail) case .errorWalletAlreadyExists: self = .walletAlreadyExists(detail) case .errorShieldedBroadcastFailed: self = .shieldedBroadcastFailed(detail) case .errorShieldedBroadcastUnconfirmed: self = .shieldedBroadcastUnconfirmed(detail) diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PrivateContactRequests.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PrivateContactRequests.swift index f87db4a91ff..a3a88a607c7 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PrivateContactRequests.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PrivateContactRequests.swift @@ -50,35 +50,49 @@ public struct PrivateContactInfo: Sendable, Equatable { /// private store" and from a contact's pending request rather than inferring /// them from the refusals it got. public struct PrivateContactReadiness: Sendable, Equatable { - /// 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 - /// `PlatformWalletError.privateContactPaymentsNotRestored` (72) until the - /// shielded store is configured or the wallet's shielded account bound; - /// no private sync lifts it. + /// Whether the wallet's private payments are restored for the identity. + /// While false, its contact requests and acceptances, public or private, + /// are refused with `PlatformWalletError.privateContactPaymentsNotRestored` + /// (72) until the shielded store is configured or the wallet's shielded + /// account bound, or a contact request's own retry of a failed + /// restoration succeeds; no private sync lifts it. public let privatePaymentsRestored: 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` (71) while that - /// read cannot finish. + /// its end for the identity with its current keys. Every contact request + /// and acceptance reads it again from the cursor before it is decided, and + /// is refused with `PlatformWalletError.privateContactStoreNotRead` (74) + /// while that read cannot finish. public let privateStoreRead: Bool /// Whether a private contact request the identity paid for may still be - /// unresolved. A contact's own pending request is - /// `ManagedIdentity.privateReplyPending(contactId:)`. + /// unresolved: the marker its private scan state stores, or a payment the + /// shielded store's journal keeps unresolved. One pair's hold is + /// `ManagedPlatformWallet.privatePairHeld(senderId:recipientId:)`. public let unresolvedPrivatePayment: Bool + /// Whether the last restoration of the wallet's private payments failed + /// while `privatePaymentsRestored` is false: 72 then holds until one + /// succeeds, not only until one runs. + /// `ManagedPlatformWallet.privatePaymentsRestoreFailure(identityId:)` reads + /// why. + public let privatePaymentsRestoreFailed: Bool - public init(privatePaymentsRestored: Bool, privateStoreRead: Bool, unresolvedPrivatePayment: Bool) { + public init( + privatePaymentsRestored: Bool, + privateStoreRead: Bool, + unresolvedPrivatePayment: Bool, + privatePaymentsRestoreFailed: Bool = false + ) { self.privatePaymentsRestored = privatePaymentsRestored self.privateStoreRead = privateStoreRead self.unresolvedPrivatePayment = unresolvedPrivatePayment + self.privatePaymentsRestoreFailed = privatePaymentsRestoreFailed } init(ffi: PrivateContactReadinessFFI) { self.init( privatePaymentsRestored: ffi.private_payments_restored, privateStoreRead: ffi.private_store_read, - unresolvedPrivatePayment: ffi.unresolved_private_payment) + unresolvedPrivatePayment: ffi.unresolved_private_payment, + privatePaymentsRestoreFailed: ffi.private_payments_restore_failed) } } @@ -117,42 +131,16 @@ extension ManagedIdentity { try result.throwIfError() return PrivateContactInfo(ffi: ffi) } - - /// Whether a private contact request from this identity to `contactId` - /// is being paid for, or was paid for and is not yet read back from the - /// store. While it is, Rust refuses another private send or reply to the - /// contact (`PlatformWalletError.privateContactRequestPending`), so show - /// the contact as pending and offer neither; a private sync settles it. - /// Read from this snapshot: take a fresh `ManagedIdentity` to see a - /// change. The snapshot does not consume the wallet's rejection notices: - /// read `ManagedPlatformWallet.privateContactReadiness(identityId:)` (or - /// refresh) first, so a pair whose payment was rejected for good is - /// already released. After a relaunch, a reply paid for in an earlier process is - /// pending again once Rust restores it from the shielded store, which it - /// does when the store is configured after the load - /// (`PlatformWalletManager.configureShielded`) or the wallet binds its - /// shielded account. - public func privateReplyPending(contactId: Identifier) throws -> Bool { - var pending = false - let result = contactId.withFFIBytes { idPtr in - PlatformWalletResult(managed_identity_get_private_reply_pending(handle, idPtr, &pending)) - } - try result.throwIfError() - return pending - } } extension ManagedPlatformWallet { /// What a contact request from `identityId`, one of this wallet's - /// identities, waits for, as the wallet decides it. Not a pure read: it - /// first releases the private requests whose payment was settled as never - /// executing, briefly taking the wallet's write lock to consume the - /// wallet's rejection notices, so a pair rejected for good reads as free - /// with no restart and no other request; then it reads live wallet state. - /// Read it before `ManagedIdentity.privateReplyPending(contactId:)`, whose - /// snapshot does not consume the notices, and again after the shielded - /// store is configured or the wallet binds its shielded account to see - /// the restore. + /// identities, waits for, as the wallet decides it. Not a pure read: a + /// journal record discharged since (a payment proven never to execute) is + /// applied first, briefly taking the wallet's write lock, as a send would + /// see it; then it reads live wallet state. Read it again after the + /// shielded store is configured or the wallet binds its shielded account + /// to see the restore. public func privateContactReadiness(identityId: Identifier) throws -> PrivateContactReadiness { var ffi = PrivateContactReadinessFFI() let handle = self.handle @@ -163,6 +151,48 @@ extension ManagedPlatformWallet { return PrivateContactReadiness(ffi: ffi) } + /// Why the last restoration of this wallet's private payments failed, + /// while they are not restored for `identityId` and none succeeded since, + /// or `nil` when none failed (restored, or only not restored yet). What + /// `PrivateContactReadiness.privatePaymentsRestoreFailed` flags. + public func privatePaymentsRestoreFailure(identityId: Identifier) throws -> String? { + var reason: UnsafeMutablePointer? + let handle = self.handle + let result = identityId.withFFIBytes { idPtr in + platform_wallet_get_private_payments_restore_failure(handle, idPtr, &reason) + } + defer { platform_wallet_string_free(reason) } + try result.check() + return reason.map { String(cString: $0) } + } + + /// Whether a contact request from `senderId`, one of this wallet's + /// identities, to `recipientId`, public or private, is held for a + /// private one, as a send decides it: a private request to the recipient + /// is being paid for, or was paid for and is not yet read back from the + /// store, or a private payment the shielded store's journal keeps + /// unresolved holds the pair, also after the sender identity was removed + /// and added again. While it is, Rust refuses another private send or + /// reply to the recipient (`PlatformWalletError.privateContactRequestPending`) + /// and holds a public one, so show the pair as pending and offer + /// neither. A `syncPrivateContactRequests` that reads the entry back + /// frees it, as does the payment proven never to execute (its anchor + /// pruned, which the shielded sync judges); an endpoint's refusal of the + /// payment does not. Not a pure read: a journal record discharged since + /// is applied first, so a discharge reads as freed with no send or sync + /// in between. + public func privatePairHeld(senderId: Identifier, recipientId: Identifier) throws -> Bool { + var held = false + let handle = self.handle + let result = senderId.withFFIBytes { senderPtr in + recipientId.withFFIBytes { recipientPtr in + platform_wallet_get_private_pair_held(handle, senderPtr, recipientPtr, &held) + } + } + try result.check() + return held + } + /// Read DashPay's private contact request store for every identity of /// this wallet, at most `maxPages` pages (the default reads to the end), /// and ingest what it holds for them: requests to them, each sender @@ -227,11 +257,15 @@ extension PlatformWalletManager { /// `resolver` supplies both the Orchard spend authority and the DashPay /// keys. `signer` is the identity signer `sendContactRequest` takes: it /// signs the request with one of the sender's authentication keys, as it - /// signs a public request's transition. Throws - /// `PlatformWalletError.shieldedSpendUnconfirmed` when the payment may - /// already be on chain, and `.privateContactRequestPending` when a - /// private request to the recipient is in flight or paid and not yet read - /// back: either way do not send again; a later private sync settles it. + /// signs a public request's transition. Before anything is paid the + /// wallet reads DashPay's private store for the sender to its end. + /// Throws `PlatformWalletError.shieldedSpendUnconfirmed` when the payment + /// may already be on chain, and `.privateContactRequestPending` when the + /// pair is held for a private request (in flight, paid and not yet read + /// back, or kept by the journal): either way do not send again. + /// `.privateContactStoreNotRead` sent nothing; calling again continues + /// the read. `.privateContactPaymentsNotRestored` and + /// `.privateContactRecoveryUnsupported` refuse before the read. public func sendPrivateContactRequest( walletId: Data, resolver: MnemonicResolver, @@ -297,8 +331,8 @@ extension PlatformWalletManager { /// privately and paying for the reply from `account`'s shielded notes. /// `shieldedAddress` is the 43-byte Orchard address the contact may pay /// us at privately. `signer` is the identity signer - /// `acceptContactRequest` takes; it signs the reply. An unconfirmed or - /// pending reply throws as `sendPrivateContactRequest` does. + /// `acceptContactRequest` takes; it signs the reply. It throws as + /// `sendPrivateContactRequest` does. public func acceptPrivateContactRequest( walletId: Data, resolver: MnemonicResolver, @@ -353,8 +387,9 @@ extension PlatformWalletManager { /// needs neither shielded support nor a mnemonic read for the payment. /// Throws `PlatformWalletError.shieldedSpendUnconfirmed` when a private /// reply may already be on chain, or `.privateContactRequestPending` when - /// one is in flight or paid and not yet read back: do not accept again; - /// and + /// the pair is held for one: do not accept again; `.privateContactStoreNotRead` + /// when the private store read every accept runs first did not finish + /// (nothing sent; calling again continues it); and /// `PlatformWalletError.shieldedBuildRequired` when the request needs a /// private reply and the native library has no shielded support. public func acceptContactRequestReplyingInKind( diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/ErrorHandlingTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/ErrorHandlingTests.swift index ccdc9cc3e60..cf636ae98e3 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/ErrorHandlingTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/ErrorHandlingTests.swift @@ -553,15 +553,59 @@ final class ErrorHandlingTests: XCTestCase { XCTAssertEqual(message, detail) } + /// A private store read that did not finish before a contact request was + /// decided has its own code and typed case, apart from a pending request + /// (71): nothing was reserved, and calling again continues the read. + func testShouldReportAnUnfinishedPrivateStoreReadByItsOwnCode() { + let code = PlatformWalletResultCode( + ffi: PLATFORM_WALLET_FFI_RESULT_CODE_ERROR_PRIVATE_CONTACT_STORE_NOT_READ + ) + XCTAssertEqual(code, .errorPrivateContactStoreNotRead) + XCTAssertEqual(code.rawValue, 74) + let detail = "the private contact request store was not read to its end for 9fRYTJZW" + guard case .privateContactStoreNotRead(let message) = + PlatformWalletError(code: code, message: detail) else { + return XCTFail("lost typed store-not-read error") + } + XCTAssertEqual(message, detail) + } + + /// An SDK pinned below the protocol version a request needs has its own + /// code and typed case, apart from a network below it (70), which lifts by + /// itself. + func testShouldReportAnSdkPinnedBelowTheProtocolVersionByItsOwnCode() { + let code = PlatformWalletResultCode( + ffi: PLATFORM_WALLET_FFI_RESULT_CODE_ERROR_SDK_PINNED_BELOW_PROTOCOL_VERSION + ) + XCTAssertEqual(code, .errorSdkPinnedBelowProtocolVersion) + XCTAssertEqual(code.rawValue, 75) + let detail = "get_private_document_entries needs protocol version 15; " + + "the SDK is pinned at protocol version 14" + guard case .sdkPinnedBelowProtocolVersion(let message) = + PlatformWalletError(code: code, message: detail) else { + return XCTFail("lost typed pinned-SDK error") + } + XCTAssertEqual(message, detail) + } + func testShouldReadEveryFieldOfThePrivateContactReadiness() { var ffi = PrivateContactReadinessFFI() ffi.private_payments_restored = false ffi.private_store_read = true ffi.unresolved_private_payment = true + ffi.private_payments_restore_failed = true XCTAssertEqual( PrivateContactReadiness(ffi: ffi), PrivateContactReadiness( - privatePaymentsRestored: false, privateStoreRead: true, unresolvedPrivatePayment: true)) + privatePaymentsRestored: false, + privateStoreRead: true, + unresolvedPrivatePayment: true, + privatePaymentsRestoreFailed: true)) + // Each flag reaches its own field: clearing only the failure flag + // reads as a payment not restored yet. + ffi.private_payments_restore_failed = false + XCTAssertFalse(PrivateContactReadiness(ffi: ffi).privatePaymentsRestoreFailed) + XCTAssertFalse(PrivateContactReadiness(ffi: ffi).privatePaymentsRestored) } func testShouldReadEveryFieldOfAPrivateContact() { From 9a5892b167e25c84b156c300f4cb6eff52a2fffd Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Tue, 10 Nov 2026 17:51:20 +0700 Subject: [PATCH 236/269] feat(swift-example-app): open the shielded store at activation and show each private contact code - The manager's shielded store now opens before any wallet is loaded or created. This app's persister attests the scan state, so the wallet holds every wallet published with no store (72). A failed open is recorded, retried on the next activation and on each DashPay reload, and shown. The binds' own calls stay, and are no-ops on the same path. - Pending reads use the wallet's per-pair hold. A pair refused with 71 re-enables only when that read says it is free; the readiness no longer frees it. - 72 shows "setting up", or why the last restoration (or the store) failed. 74 is a retry message with no pending row. 75 is a developer-facing message, on a send and on a refresh. 18 stays "may have gone through" and 73 stays a storage error. - ContactRequestOutcomesTests pins each of these. Co-Authored-By: Claude Opus 5.5 --- .../Views/DashPay/AddContactView.swift | 45 +++-- .../DashPay/ContactRequestOutcomes.swift | 171 ++++++++++++------ .../Views/DashPay/ContactRequestsView.swift | 43 +++-- .../Views/DashPay/ContactsView.swift | 39 ++-- .../SwiftExampleApp/WalletManagerStore.swift | 69 +++++-- .../ContactRequestOutcomesTests.swift | 130 +++++++++++-- 6 files changed, 360 insertions(+), 137 deletions(-) diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/AddContactView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/AddContactView.swift index be26d80415c..8b49f9246a2 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/AddContactView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/AddContactView.swift @@ -21,6 +21,9 @@ struct AddContactView: View { /// Observed for its bind: binding the shielded account restores the /// wallet's private payments, so the readiness is read again then. @EnvironmentObject var shieldedService: ShieldedService + /// Read for why the shielded store could not be opened, which keeps the + /// wallet's private payments waiting. + @EnvironmentObject var walletManagerStore: WalletManagerStore @Environment(\.dismiss) private var dismiss @Environment(\.modelContext) private var modelContext @@ -65,13 +68,19 @@ struct AddContactView: View { /// submit again, as `SendTransactionView` does for an unconfirmed spend. @State private var unconfirmedNotice: String? - /// Whether the wallet has a private request to the resolved recipient in - /// flight, or paid and not yet read back (`privateRepliesPending`). While - /// it does, Send is off and the footer offers a private sync, which - /// settles it. Read from the wallet, so reopening this sheet keeps it. A - /// failed read keeps Send off until a read succeeds. + /// Whether the wallet holds the pair with the resolved recipient for a + /// private request, as a send decides it (`privateRepliesPending`): + /// in flight, paid and not yet read back, or kept by the shielded + /// store's journal until the payment is proven never to land. While it + /// does, Send is off and the footer offers a private sync, which reads a + /// landed request back. Read from the wallet, so reopening this sheet + /// keeps it. A failed read keeps Send off until a read succeeds. @State private var recipientPending = PrivateRepliesPendingSnapshot() + /// Why the shielded store could not be opened, as last read with the + /// pending state; nil when it is open. + @State private var shieldedStoreFailure: String? + /// A private sync the footer started is running. @State private var isSyncingPrivate = false @@ -435,6 +444,8 @@ struct AddContactView: View { Text(ContactRequestSubmissionOutcome.rowMessage( pending: recipientPending.isPending(recipient), restoring: recipientPending.isRestoringPrivatePayments, + restoreFailure: recipientPending.restoreFailure, + shieldedStoreFailure: shieldedStoreFailure, unavailable: recipientPending.isUnavailable, error: nil ) ?? "") @@ -459,8 +470,9 @@ struct AddContactView: View { } } - /// Re-read from the wallet whether a private request to the resolved - /// recipient is pending. + /// Re-read from the wallet whether it holds the pair with the resolved + /// recipient for a private request, and its readiness, and try a + /// shielded store that did not open again. private func reloadRecipientPending() { let contactIds = resolvedRecipient.map { [$0] } ?? [] let wallet = try? requireWallet() @@ -472,8 +484,9 @@ struct AddContactView: View { ), for: contactIds ) - recipientPending.recordReadiness( - privateContactReadiness(wallet: wallet, identityId: identity.identityId)) + let read = privateContactReadiness(wallet: wallet, identityId: identity.identityId) + recipientPending.recordReadiness(read.readiness, restoreFailure: read.restoreFailure) + shieldedStoreFailure = walletManagerStore.shieldedStoreFailure(retrying: walletManager) } /// Read the private store, which settles a pending private request, then @@ -597,14 +610,18 @@ struct AddContactView: View { kickDashPaySync(walletManager) unconfirmedNotice = ContactRequestSubmissionOutcome.mayHaveGoneThroughMessage case .privateRequestPending: - // Nothing was sent, and the wallet may hold nothing for - // it: the footer shows it as pending and offers the - // private sync that settles it. + // Nothing was sent: the wallet holds the pair. The footer + // shows it as pending, with the private sync, until the + // per-contact read says it is free. recipientPending.recordRefusal(of: recipient) case .privatePaymentsNotRestored: - // Nothing was sent: the footer shows the wallet restoring + // Nothing was sent: the footer shows the wallet setting up // its private payments until its readiness says restored. recipientPending.recordRestoringRefusal() + case .privateStoreNotRead: + // Nothing was sent and nothing is pending: Send stays on, + // and sending again continues the read. + errorMessage = ContactRequestSubmissionOutcome.privateStoreNotReadMessage case .failed(let message): errorMessage = message } @@ -668,6 +685,8 @@ struct AddContactView: View { recipientPending.recordRefusal(of: recipient) case .privatePaymentsNotRestored: recipientPending.recordRestoringRefusal() + case .privateStoreNotRead: + errorMessage = ContactRequestSubmissionOutcome.privateStoreNotReadMessage case .failed(let message): errorMessage = "Accept failed: \(message)" } diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ContactRequestOutcomes.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ContactRequestOutcomes.swift index eb37dd7c1fa..81ab26f58af 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ContactRequestOutcomes.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ContactRequestOutcomes.swift @@ -3,26 +3,30 @@ import SwiftDashSDK /// How a contact request send or accept that threw ended, for the DashPay /// views. Follows `SendViewModel`: a shielded spend whose result could not be -/// confirmed may already be on chain (Rust keeps its notes reserved), so the -/// view keeps it pending and never offers a fresh submission; the next sync -/// settles it. A private request Rust reports as pending (one to the same -/// contact in flight, or paid and not yet read back), which a public send or -/// accept can also meet, sent nothing new: the view shows it as pending and -/// offers a private sync, which settles it, after which the user may try -/// again. Unrestored private payments (code 72) hold the wallet, not one -/// contact: the view shows "restoring private payments" until the wallet's -/// readiness reads restored; no private sync lifts it. Every other error is -/// an ordinary failure the user may retry. +/// confirmed (code 18) may already be on chain (Rust keeps its notes +/// reserved), so the view keeps it pending and never offers a fresh +/// submission; the next sync settles it. A pair Rust reports as held for a +/// private request (code 71), which a public send or accept can also meet, +/// sent nothing new: the view shows it as pending until the wallet's +/// per-contact read says the pair is free. Unrestored private payments +/// (code 72) hold the wallet, not one contact: the view shows "setting up" +/// until the wallet's readiness reads restored, or why the last restoration +/// failed; no private sync lifts it. A private store read that did not +/// finish (code 74) sent nothing and is not pending: the user may try again. +/// Every other error is an ordinary failure the user may retry; a storage +/// backend that cannot protect private payments (73) and an SDK pinned below +/// the protocol version (75) are shown as configuration problems. enum ContactRequestSubmissionOutcome: Equatable { /// The private send or reply may have gone through: do not submit again. case mayHaveGoneThrough - /// A private request to the contact is pending in the wallet; nothing - /// was sent. A private sync settles it. + /// The wallet holds the pair for a private request; nothing was sent. case privateRequestPending - /// The wallet's private payments from an earlier process are not - /// restored yet; nothing was sent. Restoring them, not a private sync, - /// lifts it. + /// The wallet's private payments are not restored yet; nothing was sent. + /// Restoring them, not a private sync, lifts it. case privatePaymentsNotRestored + /// The private store read every contact request runs first did not + /// finish; nothing was sent, and trying again continues it. + case privateStoreNotRead /// It failed; the message is the error's. case failed(String) @@ -30,26 +34,66 @@ enum ContactRequestSubmissionOutcome: Equatable { + "after the next sync confirms it; do not submit it again." static let privateRequestPendingMessage = "A private request to this contact is " - + "pending. Sync private requests to settle it, then try again if it did not " - + "go through." + + "pending. It clears once the request is read back from the private store, " + + "or once its payment can no longer land, which can take a while. Sync " + + "private requests to check." - static let restoringPrivatePaymentsMessage = "Restoring this wallet's private " - + "payments. Contact requests wait until they are restored." + static let settingUpPrivatePaymentsMessage = "Setting up this wallet's private " + + "payments. Contact requests wait until it finishes." + + /// The wallet's last restoration of its private payments failed. + static func restoreFailedMessage(_ reason: String) -> String { + "Restoring this wallet's private payments failed: \(reason). Contact requests " + + "wait until a restore succeeds." + } + + /// The app could not open the shielded store, which restores the + /// wallet's private payments. + static func shieldedStoreFailedMessage(_ reason: String) -> String { + "The shielded store could not be opened: \(reason). Contact requests wait " + + "until it opens." + } + + static let privateStoreNotReadMessage = "Still reading private contact requests. " + + "Nothing was sent; try again in a moment." /// Code 73 is a configuration error of the app's storage, which both /// declares what a private payment needs; shown as such. static let recoveryUnsupportedMessage = "This storage backend can't protect " + "private payments, so private contact requests are off." + /// Code 75 is the developer's setting: only raising or removing the SDK's + /// protocol version pin lifts it. + static let sdkPinnedMessage = "This build's SDK is pinned below the protocol " + + "version private contact requests need. Raise or remove the SDK's protocol " + + "version pin." + + /// The notice shown while the wallet's private payments are not + /// restored: why opening the shielded store failed, why the last + /// restoration failed, or that they are being set up. + static func restoringMessage(restoreFailure: String?, shieldedStoreFailure: String?) -> String { + if let shieldedStoreFailure { return shieldedStoreFailedMessage(shieldedStoreFailure) } + if let restoreFailure { return restoreFailedMessage(restoreFailure) } + return settingUpPrivatePaymentsMessage + } + /// The message a request row shows: the restoring notice while the /// wallet's private payments are not restored, the pending notice while - /// the wallet has a private request to the contact pending, the - /// unavailable notice while the wallet could not be read, and whatever - /// error an earlier attempt left otherwise. + /// the wallet holds the pair, the unavailable notice while the wallet + /// could not be read, and whatever error an earlier attempt left + /// otherwise. static func rowMessage( - pending: Bool, restoring: Bool = false, unavailable: Bool = false, error: String? + pending: Bool, + restoring: Bool = false, + restoreFailure: String? = nil, + shieldedStoreFailure: String? = nil, + unavailable: Bool = false, + error: String? ) -> String? { - if restoring { return restoringPrivatePaymentsMessage } + if restoring { + return restoringMessage( + restoreFailure: restoreFailure, shieldedStoreFailure: shieldedStoreFailure) + } if pending { return privateRequestPendingMessage } if unavailable { return PrivateRepliesPendingSnapshot.unavailableMessage } return error @@ -64,8 +108,12 @@ enum ContactRequestSubmissionOutcome: Equatable { return .privateRequestPending case .privateContactPaymentsNotRestored: return .privatePaymentsNotRestored + case .privateContactStoreNotRead: + return .privateStoreNotRead case .privateContactRecoveryUnsupported: return .failed(recoveryUnsupportedMessage) + case .sdkPinnedBelowProtocolVersion: + return .failed(sdkPinnedMessage) default: break } @@ -74,48 +122,53 @@ enum ContactRequestSubmissionOutcome: Equatable { } } -/// Which contacts have a private request pending in the wallet, as the views -/// last read it (`privateRepliesPending`). While one is, Rust refuses another -/// send or reply to that contact, so the views turn off what would submit one -/// (Accept, Ignore, Send). A failed read is not "nothing pending": the last +/// Which contacts the wallet holds a pair with for a private request, as the +/// views last read it (`privateRepliesPending`, one +/// `ManagedPlatformWallet.privatePairHeld` read per contact, which answers as +/// a send decides it). While one is held, Rust refuses another send or reply +/// to that contact, so the views turn off what would submit one (Accept, +/// Ignore, Send). A failed read is not "nothing pending": the last /// successful snapshot stays, and those controls stay off for every contact /// until a read covering it succeeds. /// -/// A send or accept Rust refused as pending (code 71) is kept apart from the -/// read: code 71 also means the private store could not be read to its end -/// before the first send or accept since load, with nothing reserved, so the -/// read may find nothing pending. That contact stays pending, with the -/// private sync offered, until the wallet's readiness says the store was -/// read to its end. +/// A send or accept Rust refused as pending (code 71) marks the contact +/// pending at once; the next successful read covering it decides, as it +/// reads what the refusal read. Controls for a pair re-enable only when that +/// read says it is free: a payment that will never land frees it only once +/// the wallet proves so, which the read shows. /// /// Whether the wallet's private payments are restored is the wallet's /// readiness (`ManagedPlatformWallet.privateContactReadiness`), not /// anything the views conclude: a code 72 refusal, or a readiness read that -/// says not restored, shows "restoring private payments" for every contact, -/// and only a readiness read that says restored lifts it. A private sync -/// changes neither on its own. +/// says not restored, shows "setting up" (or why the last restoration +/// failed) for every contact, and only a readiness read that says restored +/// lifts it. A private sync changes neither on its own. struct PrivateRepliesPendingSnapshot: Equatable { static let unavailableMessage = "Could not read whether a private request to " + "this contact is pending. Sync private requests to check again." - /// The pending contacts the last successful read found. + /// The held contacts the last successful read found. private(set) var pending: Set = [] /// The contacts the last successful read covered. private(set) var covered: Set = [] /// False after a failed read, until a read succeeds. private(set) var available = true - /// Contacts a send or accept was refused for as pending (code 71), until - /// the wallet's readiness says the private store was read to its end. + /// Contacts a send or accept was refused for as pending (code 71) since + /// the last successful read covering them. private(set) var refused: Set = [] /// The wallet's readiness for the identity, as last read; nil until a /// read succeeds. private(set) var readiness: PrivateContactReadiness? + /// Why the last restoration failed, as last read with the readiness; + /// nil when none failed or the reason could not be read. + private(set) var restoreFailure: String? /// Set by a code 72 refusal; cleared only by a readiness read that says /// the private payments are restored. private(set) var restoringRefused = false - /// Record a read of `contactIds`: the pending ones, or nil when the read - /// failed. + /// Record a read of `contactIds`: the held ones, or nil when the read + /// failed. A successful read decides each contact it covered, a code 71 + /// refusal included. mutating func record(_ read: Set?, for contactIds: [Data]) { guard let read else { available = false @@ -123,6 +176,7 @@ struct PrivateRepliesPendingSnapshot: Equatable { } pending = read covered = Set(contactIds) + refused.subtract(contactIds) available = true } @@ -138,19 +192,17 @@ struct PrivateRepliesPendingSnapshot: Equatable { restoringRefused = true } - /// Record a read of the wallet's readiness, or nil when it failed, which - /// keeps what the last read said. A readiness that says restored lifts a - /// code 72 refusal; one that says the private store was read to its end - /// leaves a contact's pending state to the pending read. - mutating func recordReadiness(_ read: PrivateContactReadiness?) { + /// Record a read of the wallet's readiness and, when it flags a failed + /// restoration, the reason read with it; a nil readiness (the read + /// failed) keeps what the last read said. A readiness that says restored + /// lifts a code 72 refusal and its failure. + mutating func recordReadiness(_ read: PrivateContactReadiness?, restoreFailure: String? = nil) { guard let read else { return } readiness = read + self.restoreFailure = read.privatePaymentsRestoreFailed ? restoreFailure : nil if read.privatePaymentsRestored { restoringRefused = false } - if read.privateStoreRead { - refused.removeAll() - } } /// Whether the wallet's private payments are being restored: a send or @@ -160,9 +212,8 @@ struct PrivateRepliesPendingSnapshot: Equatable { restoringRefused || readiness?.privatePaymentsRestored == false } - /// Whether `contactId` is pending: the last successful read found a - /// private request to it, or a send or accept to it was refused as - /// pending and the store has not been read to its end since. + /// Whether `contactId` is pending: the last successful read found the + /// pair held, or a send or accept to it was refused as pending since. func isPending(_ contactId: Data) -> Bool { pending.contains(contactId) || refused.contains(contactId) } @@ -171,7 +222,7 @@ struct PrivateRepliesPendingSnapshot: Equatable { var isUnavailable: Bool { !available } /// Whether a send or reply to `contactId` may be offered: a successful - /// read covered it and found nothing pending. + /// read covered it and found the pair free. func allowsSubmission(to contactId: Data) -> Bool { available && covered.contains(contactId) && !isPending(contactId) && !isRestoringPrivatePayments @@ -237,9 +288,15 @@ enum PrivateContactRefreshNotice { /// The notice a pass that failed leaves, or nil when the network does not /// serve the private store yet. static func notice(for error: Error) -> String? { - if let walletError = error as? PlatformWalletError, - case .protocolVersionNotActive = walletError { - return nil + if let walletError = error as? PlatformWalletError { + switch walletError { + case .protocolVersionNotActive: + return nil + case .sdkPinnedBelowProtocolVersion: + return ContactRequestSubmissionOutcome.sdkPinnedMessage + default: + break + } } return "Private requests could not be read: \(error.localizedDescription)" } diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ContactRequestsView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ContactRequestsView.swift index 070dc4a1fcf..7656d7de241 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ContactRequestsView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ContactRequestsView.swift @@ -23,6 +23,9 @@ struct ContactRequestsView: View { /// Observed for its bind: binding the shielded account restores the /// wallet's private payments, so the readiness is read again then. @EnvironmentObject var shieldedService: ShieldedService + /// Read for why the shielded store could not be opened, which keeps the + /// wallet's private payments waiting. + @EnvironmentObject var walletManagerStore: WalletManagerStore @EnvironmentObject var contactMeta: DashPayContactMetaStore @Environment(\.modelContext) private var modelContext @@ -44,15 +47,19 @@ struct ContactRequestsView: View { /// with an inline error on the row). @State private var rowErrors: [Data: String] = [:] - /// Contact ids with a private reply in flight, or paid and not yet read - /// back, as the wallet records it (`privateRepliesPending`): the row stays - /// pending, without its buttons, until a private sync settles it. Read - /// from the wallet on appear, after an accept and after each sync pass, - /// so leaving and reopening the screen keeps it. A failed read keeps the - /// last snapshot and leaves every row without its buttons until a read - /// succeeds. + /// Contact ids whose pair the wallet holds for a private request, as a + /// send decides it (`privateRepliesPending`): the row stays pending, + /// without its buttons, until the wallet's read says the pair is free. + /// Read from the wallet on appear, after an accept and after each sync + /// pass, so leaving and reopening the screen keeps it. A failed read + /// keeps the last snapshot and leaves every row without its buttons until + /// a read succeeds. @State private var walletPending = PrivateRepliesPendingSnapshot() + /// Why the shielded store could not be opened, as last read with the + /// pending state; nil when it is open. + @State private var shieldedStoreFailure: String? + /// What the last private-store refresh has to tell the user, if anything. @State private var privateRefreshNotice: String? @@ -148,6 +155,8 @@ struct ContactRequestsView: View { errorMessage: ContactRequestSubmissionOutcome.rowMessage( pending: walletPending.isPending(row.contactIdentityId), restoring: walletPending.isRestoringPrivatePayments, + restoreFailure: walletPending.restoreFailure, + shieldedStoreFailure: shieldedStoreFailure, unavailable: walletPending.isUnavailable, error: rowErrors[row.contactIdentityId] ), @@ -225,8 +234,9 @@ struct ContactRequestsView: View { } } - /// Re-read which incoming rows have a private reply pending, from the - /// wallet. + /// Re-read from the wallet which incoming rows' pairs it holds for a + /// private request and its readiness, and try a shielded store that did + /// not open again. private func reloadPendingReplies() { let contactIds = incomingPending.map(\.contactIdentityId) let wallet = try? requireWallet() @@ -238,8 +248,9 @@ struct ContactRequestsView: View { ), for: contactIds ) - walletPending.recordReadiness( - privateContactReadiness(wallet: wallet, identityId: identity.identityId)) + let read = privateContactReadiness(wallet: wallet, identityId: identity.identityId) + walletPending.recordReadiness(read.readiness, restoreFailure: read.restoreFailure) + shieldedStoreFailure = walletManagerStore.shieldedStoreFailure(retrying: walletManager) } // MARK: - Overlay maintenance @@ -306,16 +317,18 @@ struct ContactRequestsView: View { // and the row reads that; the next sync settles it. kickDashPaySync(walletManager) case .privateRequestPending: - // Nothing was sent, and the wallet may hold nothing for - // it (the store was not read to its end): the row stays - // pending until the wallet's readiness says the store was - // read (pull to refresh reads it). + // Nothing was sent: the wallet holds the pair. The row + // stays pending until its per-contact read says free. walletPending.recordRefusal(of: contactId) kickDashPaySync(walletManager) case .privatePaymentsNotRestored: // The wallet is held until its private payments are // restored; every row waits for the readiness to say so. walletPending.recordRestoringRefusal() + case .privateStoreNotRead: + // Nothing was sent and nothing is pending: the user may + // accept again, which continues the read. + rowErrors[contactId] = ContactRequestSubmissionOutcome.privateStoreNotReadMessage case .failed(let message): rowErrors[contactId] = "Accept failed: \(message)" } diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ContactsView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ContactsView.swift index 2a241112dcc..f35dcb9b7b3 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ContactsView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ContactsView.swift @@ -226,14 +226,16 @@ func kickDashPaySync(_ walletManager: PlatformWalletManager) { Task { _ = try? await walletManager.dashPaySyncNow() } } -/// The contacts among `contactIds` that `identityId` has a private request -/// to in flight, or paid and not yet read back, as the wallet records it -/// (`ManagedIdentity.privateReplyPending`, one snapshot). While one is, Rust -/// refuses another send or reply to it, so the views show it as pending and -/// offer neither; a private sync settles it. Empty when the wallet does not -/// manage the identity, as it then holds nothing for it. Nil when the wallet -/// could not be read (no loaded wallet, or a read failed), which the views -/// keep apart from "nothing pending" (`PrivateRepliesPendingSnapshot`). +/// The contacts among `contactIds` whose pair with `identityId` the wallet +/// holds for a private request, as a send decides it +/// (`ManagedPlatformWallet.privatePairHeld`, one read per contact): a private +/// request in flight, paid and not yet read back, or kept by the shielded +/// store's journal. While a pair is held, Rust refuses another send or reply +/// to it, so the views show it as pending and offer neither. Empty when the +/// wallet does not manage the identity, as it then holds nothing for it. Nil +/// when the wallet could not be read (no loaded wallet, or a read failed), +/// which the views keep apart from "nothing pending" +/// (`PrivateRepliesPendingSnapshot`). @MainActor func privateRepliesPending( wallet: ManagedPlatformWallet?, @@ -243,15 +245,14 @@ func privateRepliesPending( guard !contactIds.isEmpty else { return [] } guard let wallet else { return nil } do { - let managed: ManagedIdentity do { - managed = try wallet.managedIdentity(identityId: identityId) + _ = try wallet.managedIdentity(identityId: identityId) } catch PlatformWalletError.notFound(_) { return [] } var pending = Set() for contactId in contactIds { - if try managed.privateReplyPending(contactId: contactId) { + if try wallet.privatePairHeld(senderId: identityId, recipientId: contactId) { pending.insert(contactId) } } @@ -261,15 +262,21 @@ func privateRepliesPending( } } -/// The wallet's readiness for `identityId`'s contact requests, or nil when -/// it could not be read (`PrivateRepliesPendingSnapshot.recordReadiness` -/// keeps the last one then). +/// The wallet's readiness for `identityId`'s contact requests and, when it +/// flags a failed restoration, why it failed; a nil readiness when it could +/// not be read (`PrivateRepliesPendingSnapshot.recordReadiness` keeps the +/// last one then). @MainActor func privateContactReadiness( wallet: ManagedPlatformWallet?, identityId: Data -) -> PrivateContactReadiness? { - try? wallet?.privateContactReadiness(identityId: identityId) +) -> (readiness: PrivateContactReadiness?, restoreFailure: String?) { + guard let readiness = try? wallet?.privateContactReadiness(identityId: identityId) else { + return (nil, nil) + } + guard readiness.privatePaymentsRestoreFailed else { return (readiness, nil) } + let reason = (try? wallet?.privatePaymentsRestoreFailure(identityId: identityId)) ?? nil + return (readiness, reason) } /// Read DashPay's private contact request store (protocol version 15) for diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/WalletManagerStore.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/WalletManagerStore.swift index 8dea8c41a2c..fb84475b44f 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/WalletManagerStore.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/WalletManagerStore.swift @@ -168,6 +168,12 @@ final class WalletManagerStore: ObservableObject { /// cross-network bleed at restore time. private let modelContainer: ModelContainer + /// Why opening each network's shielded store failed, until a later + /// attempt succeeds. The DashPay views show it while the wallet's + /// private payments wait for that store, and retry it + /// (`shieldedStoreFailure(retrying:)`). + @Published private(set) var shieldedStoreFailures: [Network: String] = [:] + init(modelContainer: ModelContainer) { self.modelContainer = modelContainer // Placeholder. Held until the first `activate(...)` call @@ -202,6 +208,11 @@ final class WalletManagerStore: ObservableObject { if let existing = managers[network] { let cachedHandle = managerSdkHandles[network] if cachedHandle == sdk.handle { + // A shielded store that did not open last time is tried + // again: until it opens, contact requests stay held (72). + if shieldedStoreFailures[network] != nil { + configureShieldedStore(existing, network: network) + } if makeActive && existing !== activeManager { // Swapping to a different warm manager supersedes any // in-flight SPV start against the outgoing one. @@ -232,6 +243,15 @@ final class WalletManagerStore: ObservableObject { } let manager = PlatformWalletManager() try manager.configure(sdk: sdk, modelContainer: modelContainer) + // Open the shielded store before any wallet is loaded or created: + // this app's persister keeps the private contact request scan state, + // so Rust holds every wallet published while no store is configured + // (code 72) until its private payment journal is read, and a later + // bind may never come (no mnemonic, shielded off). With the store + // open first, each wallet's journal is read as it is published, and + // the binds' own calls on the same path are no-ops. A failure is + // recorded and retried; the wallets stay held meanwhile. + configureShieldedStore(manager, network: network) // Best-effort: a fresh manager comes up with no wallets in // memory; restore from the network-scoped persistence // handler so reopening the app on this network resurfaces @@ -249,23 +269,6 @@ final class WalletManagerStore: ObservableObject { error: error ) } - // Open the shielded store right after the load: Rust then restores, - // with no keys, the unconfirmed private contact request payments of - // every wallet that bound a shielded account in an earlier process. - // Until it does, those wallets' public contact requests are refused - // (code 71), and a later bind may never come (no mnemonic, shielded - // off). The same path the bind opens, so the bind's call is a no-op. - do { - try manager.configureShielded(dbPath: ShieldedService.dbPath(for: network)) - } catch { - SDKLogger.event( - "manager_shielded_configure_failed", - category: .lifecycle, - severity: .error, - fields: ["network": .publicText(network.displayName)], - error: error - ) - } managers[network] = manager managerSdkHandles[network] = sdk.handle if makeActive { @@ -287,6 +290,38 @@ final class WalletManagerStore: ObservableObject { ) } + /// Open `network`'s shielded store on `manager`, recording why it failed + /// for the DashPay views, or clearing that once it opens. A repeat on the + /// same path does not wait on the store, so it is safe on the main + /// thread. + private func configureShieldedStore(_ manager: PlatformWalletManager, network: Network) { + do { + try manager.configureShielded(dbPath: ShieldedService.dbPath(for: network)) + shieldedStoreFailures[network] = nil + } catch { + shieldedStoreFailures[network] = error.localizedDescription + SDKLogger.event( + "manager_shielded_configure_failed", + category: .lifecycle, + severity: .error, + fields: ["network": .publicText(network.displayName)], + error: error + ) + } + } + + /// Why the shielded store of `manager`'s network could not be opened, + /// after trying it again, or nil when it is open (or `manager` is not + /// one of this store's). + func shieldedStoreFailure(retrying manager: PlatformWalletManager) -> String? { + guard let network = managers.first(where: { $0.value === manager })?.key, + shieldedStoreFailures[network] != nil else { + return nil + } + configureShieldedStore(manager, network: network) + return shieldedStoreFailures[network] + } + /// Manager for `network` if one has been activated this session; /// otherwise `nil`. Used for diagnostics surfaces (e.g. Wallet /// Memory Explorer) that want to inspect a specific network's diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/ContactRequestOutcomesTests.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/ContactRequestOutcomesTests.swift index a60c6c914ca..0612f3adbc4 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/ContactRequestOutcomesTests.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/ContactRequestOutcomesTests.swift @@ -3,11 +3,17 @@ import SwiftDashSDK @testable import SwiftExampleApp /// Pins how the DashPay views read a failed contact request submission and a -/// private-store refresh: an unconfirmed private send or reply stays pending -/// (no fresh submission is offered), and a network that does not serve the -/// private store yet is quiet while real failures are shown. +/// private-store refresh, code by code: an unconfirmed private send or reply +/// (18) stays pending with no fresh submission offered, a held pair (71) is +/// pending until the per-contact read says free, unrestored payments (72) +/// show "setting up" or why the restoration failed, an unsupported store (73) +/// and a pinned SDK (75) are configuration messages, an unfinished private +/// store read (74) is a retry with no pending row, and a network that does +/// not serve the private store yet is quiet while real failures are shown. final class ContactRequestOutcomesTests: XCTestCase { + /// Code 18: a note spend whose broadcast may have reached a node, a + /// verdict included, may still land; nothing is offered again. func testShouldKeepAnUnconfirmedPrivateSubmissionPending() { let outcome = ContactRequestSubmissionOutcome.of( PlatformWalletError.shieldedSpendUnconfirmed("result could not be confirmed") @@ -16,9 +22,8 @@ final class ContactRequestOutcomesTests: XCTestCase { } /// A pending private request (code 71), which a public send or accept - /// can meet too, sent nothing: it is shown as pending, with a private - /// sync to settle it, rather than as a submission that may have gone - /// through. + /// can meet too, sent nothing: it is shown as pending rather than as a + /// submission that may have gone through. func testShouldKeepAPendingPrivateRequestPending() { let outcome = ContactRequestSubmissionOutcome.of( PlatformWalletError.privateContactRequestPending("not yet read back from the store") @@ -66,16 +71,15 @@ final class ContactRequestOutcomesTests: XCTestCase { XCTAssertTrue(snapshot.allowsSubmission(to: pendingContact)) } - /// A send refused as pending (code 71) when the wallet reserved - /// nothing (its store was not read to its end): the following read finds - /// nothing pending, yet the contact stays pending, with the private sync - /// offered, until the wallet's readiness says the store was read. - func testShouldKeepARefusedContactPendingUntilTheReadinessSaysTheStoreWasRead() { + /// A send refused as pending (code 71) marks the contact pending at once; + /// the next read of the pair, which reads what the refusal read, decides: + /// held keeps it pending, free re-enables it. The readiness alone never + /// re-enables it. + func testShouldReEnableARefusedPairOnlyWhenThePerContactReadSaysFree() { let contact = Data(repeating: 1, count: 32) var snapshot = PrivateRepliesPendingSnapshot() - snapshot.recordRefusal(of: contact) snapshot.record([], for: [contact]) - XCTAssertFalse(snapshot.isUnavailable) + snapshot.recordRefusal(of: contact) XCTAssertTrue(snapshot.isPending(contact)) XCTAssertFalse(snapshot.allowsSubmission(to: contact)) XCTAssertEqual( @@ -83,16 +87,65 @@ final class ContactRequestOutcomesTests: XCTestCase { pending: snapshot.isPending(contact), error: nil), ContactRequestSubmissionOutcome.privateRequestPendingMessage) - // A pending read that finds nothing does not lift it; the wallet's - // readiness saying the store was read to its end does. - snapshot.record([], for: [contact]) - XCTAssertTrue(snapshot.isPending(contact)) + // A readiness that says the store was read and nothing is held for + // the wallet does not free one pair. snapshot.recordReadiness(PrivateContactReadiness( privatePaymentsRestored: true, privateStoreRead: true, unresolvedPrivatePayment: false)) + XCTAssertFalse(snapshot.allowsSubmission(to: contact)) + + // The pair's read says held: still pending. + snapshot.record([contact], for: [contact]) + XCTAssertTrue(snapshot.isPending(contact)) + XCTAssertFalse(snapshot.allowsSubmission(to: contact)) + + // A payment that will never land frees the pair once the wallet + // proves so; the next read says free and the controls come back. + snapshot.record([], for: [contact]) XCTAssertFalse(snapshot.isPending(contact)) XCTAssertTrue(snapshot.allowsSubmission(to: contact)) } + /// A read that does not cover a refused contact leaves its refusal. + func testShouldKeepARefusalAReadDidNotCover() { + let refused = Data(repeating: 1, count: 32) + let other = Data(repeating: 2, count: 32) + var snapshot = PrivateRepliesPendingSnapshot() + snapshot.recordRefusal(of: refused) + snapshot.record([], for: [other]) + XCTAssertTrue(snapshot.isPending(refused)) + XCTAssertTrue(snapshot.allowsSubmission(to: other)) + } + + /// Code 74: the private store read every contact request runs first did + /// not finish. Nothing was sent and nothing is pending: its own outcome, + /// with a retry message, and no pending row. + func testShouldOfferARetryForAnUnfinishedPrivateStoreRead() { + let contact = Data(repeating: 1, count: 32) + XCTAssertEqual( + ContactRequestSubmissionOutcome.of( + PlatformWalletError.privateContactStoreNotRead("not read to its end")), + .privateStoreNotRead) + var snapshot = PrivateRepliesPendingSnapshot() + snapshot.record([], for: [contact]) + XCTAssertFalse(snapshot.isPending(contact), "the view records no refusal for 74") + XCTAssertTrue(snapshot.allowsSubmission(to: contact)) + XCTAssertNotEqual( + ContactRequestSubmissionOutcome.privateStoreNotReadMessage, + ContactRequestSubmissionOutcome.privateRequestPendingMessage) + } + + /// Code 75 is the developer's setting, shown as such, on a send and on a + /// refresh alike. + func testShouldShowAPinnedSdkAsADeveloperMessage() { + let error = PlatformWalletError.sdkPinnedBelowProtocolVersion("pinned at 14") + XCTAssertEqual( + ContactRequestSubmissionOutcome.of(error), + .failed(ContactRequestSubmissionOutcome.sdkPinnedMessage)) + XCTAssertEqual( + PrivateContactRefreshNotice.notice(for: error), + ContactRequestSubmissionOutcome.sdkPinnedMessage) + } + /// Unrestored private payments (code 72) are their own outcome, apart /// from a pending request (71), with their own message. func testShouldTellUnrestoredPrivatePaymentsFromAPendingRequest() { @@ -102,12 +155,51 @@ final class ContactRequestOutcomesTests: XCTestCase { .privatePaymentsNotRestored) XCTAssertEqual( ContactRequestSubmissionOutcome.rowMessage(pending: true, restoring: true, error: nil), - ContactRequestSubmissionOutcome.restoringPrivatePaymentsMessage) + ContactRequestSubmissionOutcome.settingUpPrivatePaymentsMessage) XCTAssertNotEqual( - ContactRequestSubmissionOutcome.restoringPrivatePaymentsMessage, + ContactRequestSubmissionOutcome.settingUpPrivatePaymentsMessage, ContactRequestSubmissionOutcome.privateRequestPendingMessage) } + /// Code 72 after a failed restoration shows why it failed, read with the + /// readiness, until a readiness says restored; a shielded store that did + /// not open is shown over both. + func testShouldShowWhyARestorationFailedUntilTheReadinessSaysRestored() { + let contact = Data(repeating: 1, count: 32) + var snapshot = PrivateRepliesPendingSnapshot() + snapshot.record([], for: [contact]) + snapshot.recordReadiness( + PrivateContactReadiness( + privatePaymentsRestored: false, privateStoreRead: false, + unresolvedPrivatePayment: false, privatePaymentsRestoreFailed: true), + restoreFailure: "the journal does not read") + XCTAssertTrue(snapshot.isRestoringPrivatePayments) + XCTAssertEqual(snapshot.restoreFailure, "the journal does not read") + XCTAssertEqual( + ContactRequestSubmissionOutcome.rowMessage( + pending: false, restoring: snapshot.isRestoringPrivatePayments, + restoreFailure: snapshot.restoreFailure, error: nil), + ContactRequestSubmissionOutcome.restoreFailedMessage("the journal does not read")) + XCTAssertEqual( + ContactRequestSubmissionOutcome.rowMessage( + pending: false, restoring: true, restoreFailure: snapshot.restoreFailure, + shieldedStoreFailure: "disk full", error: nil), + ContactRequestSubmissionOutcome.shieldedStoreFailedMessage("disk full")) + + // A readiness that no longer flags a failure drops the reason. + snapshot.recordReadiness( + PrivateContactReadiness( + privatePaymentsRestored: false, privateStoreRead: false, + unresolvedPrivatePayment: false), + restoreFailure: "stale") + XCTAssertNil(snapshot.restoreFailure) + + snapshot.recordReadiness(PrivateContactReadiness( + privatePaymentsRestored: true, privateStoreRead: false, unresolvedPrivatePayment: false)) + XCTAssertFalse(snapshot.isRestoringPrivatePayments) + XCTAssertTrue(snapshot.allowsSubmission(to: contact)) + } + /// A store that cannot protect private payments (code 73) is a /// configuration error, shown as such. func testShouldShowUnsupportedPrivateRecoveryAsAStorageError() { From 50414f2742af339a43a2f6ce21b0f26174018ab9 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Tue, 10 Nov 2026 17:57:10 +0700 Subject: [PATCH 237/269] feat(sdk): bind the pair-hold read and restore failure in Kotlin, type codes 74 and 75, scan state at bit 15 - JNI: DashpayNative.privatePairHeld(wallet, sender, recipient) replaces managedIdentityPrivateReplyPending; privatePaymentsRestoreFailure(wallet, identity) returns the reason or null; the readiness is four flags. - Dashpay.privateRepliesPending reads one pair hold per contact, as a send decides it, the shielded store's journal included. PrivateContactReadiness gains privatePaymentsRestoreFailed, and Dashpay.privatePaymentsRestoreFailure reads why. - DashSdkError.PlatformWallet.PrivateContactStoreNotRead (74, retryable) and SdkPinnedBelowProtocolVersion (75). - The 16, 18, 70, 71 and 72 KDocs follow the wallet, and the private send and in-kind accept name their refusals. - CAPABILITY_PRIVATE_CONTACT_REQUEST_SCAN_STATE is 1L shl 15. The handler's expected mask is 0x8bbf, and it does not claim bit 14 (ASSET_LOCK_SHIELDED_RECIPIENTS). - The registry rows 68 to 75 name the Kotlin types. Co-Authored-By: Claude Opus 5.5 --- .../dashfoundation/dashsdk/FfiSmokeTest.kt | 8 +- .../dashsdk/errors/DashSdkError.kt | 88 +++++++++++---- .../dashsdk/ffi/DashpayNative.kt | 34 ++++-- .../dashsdk/ffi/NativePersistenceBridge.kt | 9 +- .../dashfoundation/dashsdk/tokens/Dashpay.kt | 88 ++++++++++----- .../dashsdk/wallet/PlatformWalletManager.kt | 20 +++- .../dashsdk/errors/DashSdkErrorTest.kt | 30 ++++++ .../PlatformWalletPersistenceHandlerTest.kt | 5 +- .../ERROR_CODE_REGISTRY.md | 16 +-- packages/rs-unified-sdk-jni/src/dashpay.rs | 102 ++++++++++++++---- 10 files changed, 304 insertions(+), 96 deletions(-) diff --git a/packages/kotlin-sdk/sdk/src/androidTest/kotlin/org/dashfoundation/dashsdk/FfiSmokeTest.kt b/packages/kotlin-sdk/sdk/src/androidTest/kotlin/org/dashfoundation/dashsdk/FfiSmokeTest.kt index 70190a07dd7..0f27dead571 100644 --- a/packages/kotlin-sdk/sdk/src/androidTest/kotlin/org/dashfoundation/dashsdk/FfiSmokeTest.kt +++ b/packages/kotlin-sdk/sdk/src/androidTest/kotlin/org/dashfoundation/dashsdk/FfiSmokeTest.kt @@ -131,10 +131,14 @@ class FfiSmokeTest { ) } // The private contact read is benign on a miss (null, as Swift's - // nil); the pending read reports an unknown handle. + // nil); the pair-hold and restore-failure reads report an unknown + // wallet handle. assertNull(DashpayNative.managedIdentityPrivateContact(0L, ByteArray(32))) assertThrows(DashSDKException::class.java) { - DashpayNative.managedIdentityPrivateReplyPending(0L, ByteArray(32)) + DashpayNative.privatePairHeld(0L, ByteArray(32), ByteArray(32)) + } + assertThrows(DashSDKException::class.java) { + DashpayNative.privatePaymentsRestoreFailure(0L, ByteArray(32)) } } diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/errors/DashSdkError.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/errors/DashSdkError.kt index ab3f3ea74ef..c25575850ea 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/errors/DashSdkError.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/errors/DashSdkError.kt @@ -268,11 +268,15 @@ sealed class DashSdkError( /** * `ErrorShieldedSpendUnconfirmed` (native code 18). A shielded spend * (transfer / unshield / withdraw / shield) was broadcast and - * accepted, but its execution result couldn't be confirmed — it may - * already be on chain. Rust intentionally KEEPS the spent notes' - * reservations, so the caller must NOT retry (a retry would rebuild - * the bundle against other notes and could double-spend); the next - * shielded sync reconciles the outcome. The Android analog of + * accepted, or may have been delivered (for every spend that consumes + * notes, an endpoint's verdict, which no proof backs, or a transport + * failure after a dispatch), but its execution result couldn't be + * confirmed — it may already be on chain. Rust intentionally KEEPS + * the spent notes' reservations and the recovery record, so the + * caller must NOT retry (a retry would rebuild the bundle against + * other notes and could double-spend); a sync shows it landed, or the + * redrive and anchor-prune passes release it once it provably never + * can. The Android analog of * Swift's `PlatformWalletError.shieldedSpendUnconfirmed`, which the * iOS send flow surfaces as a non-retryable "may have gone through" * outcome rather than an error. @@ -304,10 +308,12 @@ sealed class DashSdkError( /** * `ErrorShieldedBroadcastFailed` (native code 16). A DEFINITIVE - * non-execution outcome — relay/CheckTx rejected the transaction - * before it entered the chain, and any note reservations were - * released. Safe to retry (the opposite of the ambiguous - * [ShieldedSpendUnconfirmed]). + * non-execution outcome, and any note reservations were released. + * For a spend that consumes notes, the transition never left the + * wallet (refused before anything was sent); one that may have been + * delivered is [ShieldedSpendUnconfirmed]. A shield into the pool + * still reports a relay/CheckTx verdict here. Safe to retry (the + * opposite of the ambiguous [ShieldedSpendUnconfirmed]). */ class ShieldedBroadcastFailed(message: String, cause: Throwable? = null) : PlatformWallet(message, cause) { @@ -691,7 +697,8 @@ sealed class DashSdkError( * request store before protocol version 15. Expected on such a * network, so a host may stay quiet about it; it lifts when the * network upgrades. A refusal that may lift sooner (the version could - * not be confirmed) arrives as [Generic] instead. + * not be confirmed) arrives as [Generic] instead, and an SDK pinned + * below the version as [SdkPinnedBelowProtocolVersion]. * ← Swift `PlatformWalletError.protocolVersionNotActive`. */ class ProtocolVersionNotActive(message: String, cause: Throwable? = null) : @@ -699,11 +706,15 @@ sealed class DashSdkError( /** * `ErrorPrivateContactRequestPending` (native code 71). A private - * contact request to that identity is in flight (another private send - * or reply reserved it), or was paid for and is not yet read back from - * the store. Nothing new was paid; a later - * `Dashpay.syncPrivateContactRequests` settles it. Pending, not - * failed: do not submit it again. + * contact request to that identity is pending: in flight (another + * private send or reply reserved it), paid for and not yet read back + * from the store, or a private payment the shielded store's journal + * keeps unresolved holds the pair. Nothing new was paid. Pending, not + * failed: do not submit it again. A `Dashpay.syncPrivateContactRequests` + * that reads the entry back frees it, as does the payment proven never + * to execute; an endpoint's refusal of the payment does not. + * `Dashpay.privateRepliesPending` reads whether the pair is still held. + * An unfinished private store read is [PrivateContactStoreNotRead]. * ← Swift `PlatformWalletError.privateContactRequestPending`. */ class PrivateContactRequestPending(message: String, cause: Throwable? = null) : @@ -711,12 +722,20 @@ sealed class DashSdkError( /** * `ErrorPrivateContactPaymentsNotRestored` (native code 72). The - * wallet, not one contact, is held: its private payments from an - * earlier process are not restored from the shielded store yet, so - * no public contact request is sent or accepted, nothing signed or - * broadcast. `PlatformWalletManager.configureShielded` or binding - * the wallet's shielded account restores them; a private sync does - * not. `Dashpay.privateContactReadiness` reads whether they are. + * wallet, not one contact, is held: its private payments are not + * restored from the shielded store's journal, so no contact request, + * public or private, is sent or accepted, nothing signed or + * broadcast. The wallet was published with no shielded store + * configured (configure it when the manager is activated, before + * loading or creating wallets, and again after a failure), it bound a + * shielded account in an earlier process, its last restoration failed + * or waits for a busy store, or the identity was loaded marking a + * private payment that may be unresolved. + * `PlatformWalletManager.configureShielded` or binding the wallet's + * shielded account restores them; a private sync does not. + * `Dashpay.privateContactReadiness` reads whether they are and + * whether the last restoration failed, and + * `Dashpay.privatePaymentsRestoreFailure` why. * ← Swift `PlatformWalletError.privateContactPaymentsNotRestored`. */ class PrivateContactPaymentsNotRestored(message: String, cause: Throwable? = null) : @@ -734,6 +753,31 @@ sealed class DashSdkError( class PrivateContactRecoveryUnsupported(message: String, cause: Throwable? = null) : PlatformWallet(message, cause) + /** + * `ErrorPrivateContactStoreNotRead` (native code 74). DashPay's + * private contact request store could not be read to its end for the + * identity before a contact request or acceptance, public or private, + * was decided: a node behind the store already seen, or keys that + * kept changing, stop the read. Nothing was reserved, sent or paid; + * calling again continues the read. Not a pending request. + * ← Swift `PlatformWalletError.privateContactStoreNotRead`. + */ + class PrivateContactStoreNotRead(message: String, cause: Throwable? = null) : + PlatformWallet(message, cause) { + override val isRetryable: Boolean get() = true + } + + /** + * `ErrorSdkPinnedBelowProtocolVersion` (native code 75). The SDK is + * pinned below the protocol version the request needs, so it did not + * send it, whatever the network runs. A network upgrade does not lift + * it; only raising or removing the SDK's protocol version pin does: a + * developer's setting. + * ← Swift `PlatformWalletError.sdkPinnedBelowProtocolVersion`. + */ + class SdkPinnedBelowProtocolVersion(message: String, cause: Throwable? = null) : + PlatformWallet(message, cause) + /** * Any other `PlatformWalletFFIResultCode` without a dedicated type. * Carries the platform-wallet [nativeCode] (already de-offset) and @@ -939,6 +983,8 @@ sealed class DashSdkError( 71 -> PlatformWallet.PrivateContactRequestPending(message, cause) // ErrorPrivateContactRequestPending 72 -> PlatformWallet.PrivateContactPaymentsNotRestored(message, cause) // ErrorPrivateContactPaymentsNotRestored 73 -> PlatformWallet.PrivateContactRecoveryUnsupported(message, cause) // ErrorPrivateContactRecoveryUnsupported + 74 -> PlatformWallet.PrivateContactStoreNotRead(message, cause) // ErrorPrivateContactStoreNotRead + 75 -> PlatformWallet.SdkPinnedBelowProtocolVersion(message, cause) // ErrorSdkPinnedBelowProtocolVersion else -> // @Deprecated fallback — see the code-6 arm; code 31 is the // real discriminator. diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/DashpayNative.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/DashpayNative.kt index 229eb35db2f..1e902276585 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/DashpayNative.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/DashpayNative.kt @@ -338,24 +338,38 @@ internal object DashpayNative { external fun managedIdentityPrivateContact(identityHandle: Long, contactId: ByteArray): String? /** - * Whether a private contact request from the managed identity to - * [contactId] is being paid for, or was paid for and is not yet read - * back from the store. A pure read; in memory only on the Rust side. - * ← Swift `ManagedIdentity.privateReplyPending(contactId:)`. + * Whether a contact request from [senderId], one of the wallet's + * identities, to [recipientId], public or private, is held for a private + * one, as a send decides it (`platform_wallet_get_private_pair_held`): a + * private request in flight, paid and not yet read back, or kept by the + * shielded store's journal. A journal record discharged since is applied + * first. An unknown wallet handle throws (NotFound), as does a sender the + * wallet does not hold. + * ← Swift `ManagedPlatformWallet.privatePairHeld(senderId:recipientId:)`. */ - external fun managedIdentityPrivateReplyPending(identityHandle: Long, contactId: ByteArray): Boolean + external fun privatePairHeld(walletHandle: Long, senderId: ByteArray, recipientId: ByteArray): Boolean /** * What a contact request from [identityId], one of the wallet's - * identities, waits for, as the wallet decides it: three flags, - * `[privatePaymentsRestored, privateStoreRead, unresolvedPrivatePayment]` - * (`platform_wallet_get_private_contact_readiness`). An unknown wallet - * handle throws (NotFound), as does an identity the wallet does not - * hold. + * identities, waits for, as the wallet decides it: four flags, + * `[privatePaymentsRestored, privateStoreRead, unresolvedPrivatePayment, + * privatePaymentsRestoreFailed]` + * (`platform_wallet_get_private_contact_readiness`). A journal record + * discharged since is applied first. An unknown wallet handle throws + * (NotFound), as does an identity the wallet does not hold. * ← Swift `ManagedPlatformWallet.privateContactReadiness(identityId:)`. */ external fun privateContactReadiness(walletHandle: Long, identityId: ByteArray): BooleanArray? + /** + * Why the last restoration of the wallet's private payments failed, + * while they are not restored for [identityId] + * (`platform_wallet_get_private_payments_restore_failure`), or null when + * none failed. Throws as [privateContactReadiness] does. + * ← Swift `ManagedPlatformWallet.privatePaymentsRestoreFailure(identityId:)`. + */ + external fun privatePaymentsRestoreFailure(walletHandle: Long, identityId: ByteArray): String? + /** * Accept the incoming contact request [requestHandle] replying the way * it came: privately, paid from [account]'s shielded notes, when it came diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/NativePersistenceBridge.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/NativePersistenceBridge.kt index 8f81f004a4e..4d822a9f960 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/NativePersistenceBridge.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/NativePersistenceBridge.kt @@ -102,11 +102,12 @@ abstract class NativePersistenceBridge { /** * `PersistenceCapabilities::PRIVATE_CONTACT_REQUEST_SCAN_STATE` - * (bit 14, `0x4000`): the private contact request sync's scan state - * is stored on its round and restored at load. The one Kotlin home - * of this bit, aliased by the public diagnostic mirror. + * (bit 15, `0x8000`; bit 14 is `ASSET_LOCK_SHIELDED_RECIPIENTS`): the + * private contact request sync's scan state is stored on its round + * and restored at load. The one Kotlin home of this bit, aliased by + * the public diagnostic mirror. */ - const val CAPABILITY_PRIVATE_CONTACT_REQUEST_SCAN_STATE: Long = 1L shl 14 + const val CAPABILITY_PRIVATE_CONTACT_REQUEST_SCAN_STATE: Long = 1L shl 15 /** * Packed row widths of [onPersistPrivateContactScanState] and diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/tokens/Dashpay.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/tokens/Dashpay.kt index 3af96e3fd4b..1e272e2e030 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/tokens/Dashpay.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/tokens/Dashpay.kt @@ -335,27 +335,37 @@ class Dashpay internal constructor(private val walletHandle: Long, */ data class PrivateContactReadiness( /** - * Whether the wallet's private payments from an earlier process are - * restored for the identity. While false, its public sends and - * accepts are refused with + * Whether the wallet's private payments are restored for the + * identity. While false, its contact requests and acceptances, public + * or private, are refused with * [org.dashfoundation.dashsdk.errors.DashSdkError.PlatformWallet.PrivateContactPaymentsNotRestored] * until the shielded store is configured or the wallet's shielded - * account bound; no private sync lifts it. + * account bound, or a contact request's own retry of a failed + * restoration succeeds; no private sync lifts it. */ val privatePaymentsRestored: Boolean, /** * 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 - * [org.dashfoundation.dashsdk.errors.DashSdkError.PlatformWallet.PrivateContactRequestPending] + * to its end for the identity with its current keys. Every contact + * request and acceptance reads it again from the cursor before it is + * decided, and is refused with + * [org.dashfoundation.dashsdk.errors.DashSdkError.PlatformWallet.PrivateContactStoreNotRead] * while that read cannot finish. */ val privateStoreRead: Boolean, /** * Whether a private contact request the identity paid for may still - * be unresolved. A contact's own pending request is [privateRepliesPending]. + * be unresolved: the marker its private scan state stores, or a + * payment the shielded store's journal keeps unresolved. One pair's + * hold is [privateRepliesPending]. */ val unresolvedPrivatePayment: Boolean, + /** + * Whether the last restoration of the wallet's private payments + * failed while [privatePaymentsRestored] is false: the hold then + * lasts until one succeeds. [privatePaymentsRestoreFailure] reads why. + */ + val privatePaymentsRestoreFailed: Boolean = false, ) /** What one pass over DashPay's private contact request store did. */ @@ -433,36 +443,56 @@ class Dashpay internal constructor(private val walletHandle: Long, /** * What a contact request from [ourIdentityId] waits for, as the wallet - * decides it ([PrivateContactReadiness]). A pure read of live wallet - * state; read it again after the shielded store is configured or the - * wallet binds its shielded account to see the restore. Throws for an - * identity the wallet does not hold. + * decides it ([PrivateContactReadiness]). Not a pure read: a journal + * record discharged since (a payment proven never to execute) is applied + * first, briefly taking the wallet's write lock, as a send would see it; + * then it reads live wallet state. Read it again after the shielded store + * is configured or the wallet binds its shielded account to see the + * restore. Throws for an identity the wallet does not hold. * ← Swift `ManagedPlatformWallet.privateContactReadiness(identityId:)`. */ suspend fun privateContactReadiness(ourIdentityId: ByteArray): PrivateContactReadiness = withContext(Dispatchers.IO) { val flags = mapNativeErrors { DashpayNative.privateContactReadiness(walletHandle, ourIdentityId) } checkNotNull(flags) { "native privateContactReadiness returned no flags" } - check(flags.size == 3) { "expected 3 readiness flags, got ${flags.size}" } + check(flags.size == 4) { "expected 4 readiness flags, got ${flags.size}" } PrivateContactReadiness( privatePaymentsRestored = flags[0], privateStoreRead = flags[1], unresolvedPrivatePayment = flags[2], + privatePaymentsRestoreFailed = flags[3], ) } /** - * The contacts among [contactIds] that [ourIdentityId] has a private - * contact request to in flight, or paid for and not yet read back from - * the store, as the wallet records it, read from one fresh - * managed-identity snapshot. While one is, Rust refuses another private - * send or reply to it ([DashSdkError.PlatformWallet.PrivateContactRequestPending]), - * so a host shows it as pending and offers neither; a private sync - * settles it. Empty when the identity isn't managed. After a relaunch, a - * reply paid for in an earlier process is pending again once Rust - * restores it from the shielded store, which it does when the store is - * configured after the load or the wallet binds its shielded account. - * ← Swift `ManagedIdentity.privateReplyPending(contactId:)` + * Why the last restoration of the wallet's private payments failed, + * while they are not restored for [ourIdentityId] and none succeeded + * since, or null when none failed (restored, or only not restored yet). + * What [PrivateContactReadiness.privatePaymentsRestoreFailed] flags. + * Throws for an identity the wallet does not hold. + * ← Swift `ManagedPlatformWallet.privatePaymentsRestoreFailure(identityId:)`. + */ + suspend fun privatePaymentsRestoreFailure(ourIdentityId: ByteArray): String? = + withContext(Dispatchers.IO) { + mapNativeErrors { DashpayNative.privatePaymentsRestoreFailure(walletHandle, ourIdentityId) } + } + + /** + * The contacts among [contactIds] whose pair with [ourIdentityId] the + * wallet holds for a private contact request, as a send decides it + * (`DashPayView::private_pair_held`, one read per contact): a private + * request in flight, paid and not yet read back from the store, or kept + * by the shielded store's journal, also after the identity was removed + * and added again. While a pair is held, Rust refuses another private + * send or reply to it + * ([DashSdkError.PlatformWallet.PrivateContactRequestPending]) and holds + * a public one, so a host shows it as pending and offers neither. A + * private sync that reads the entry back frees it, as does the payment + * proven never to execute (its anchor pruned, which the shielded sync + * judges); an endpoint's refusal of the payment does not. A journal + * record discharged since is applied first. Empty when the identity + * isn't managed. + * ← Swift `ManagedPlatformWallet.privatePairHeld(senderId:recipientId:)` * (packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PrivateContactRequests.swift). */ suspend fun privateRepliesPending( @@ -471,9 +501,13 @@ class Dashpay internal constructor(private val walletHandle: Long, ): List = withContext(Dispatchers.IO) { if (contactIds.isEmpty()) return@withContext emptyList() mapNativeErrors { - withManagedIdentity(ourIdentityId) { handle -> - contactIds.filter { DashpayNative.managedIdentityPrivateReplyPending(handle, it) } - } ?: emptyList() + // An identity the wallet does not manage holds nothing. + val managed = withManagedIdentity(ourIdentityId) { true } ?: false + if (!managed) { + emptyList() + } else { + contactIds.filter { DashpayNative.privatePairHeld(walletHandle, ourIdentityId, it) } + } } } diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/PlatformWalletManager.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/PlatformWalletManager.kt index c60751e8d7c..0761fba0dc9 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/PlatformWalletManager.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/PlatformWalletManager.kt @@ -2155,8 +2155,17 @@ class PlatformWalletManager( * [shieldedAddress] is the 43-byte raw Orchard address the contact may * pay us at privately. Blocks for the Halo 2 proof. * - * Throws [DashSdkError.PlatformWallet.ShieldedSpendUnconfirmed] when - * the payment may already be on chain: do not send again. + * Before anything is paid the wallet reads DashPay's private store for + * the sender to its end. Throws + * [DashSdkError.PlatformWallet.ShieldedSpendUnconfirmed] when the payment + * may already be on chain, and + * [DashSdkError.PlatformWallet.PrivateContactRequestPending] when the + * pair is held for a private request: either way do not send again. + * [DashSdkError.PlatformWallet.PrivateContactStoreNotRead] sent nothing; + * calling again continues the read. + * [DashSdkError.PlatformWallet.PrivateContactPaymentsNotRestored] and + * [DashSdkError.PlatformWallet.PrivateContactRecoveryUnsupported] refuse + * before the read. * * @return the created request; close it (or `use {}`) when done. */ @@ -2243,7 +2252,12 @@ class PlatformWalletManager( * A public or adopted reply needs neither shielded support nor a * mnemonic read for the payment. Throws * [DashSdkError.PlatformWallet.ShieldedSpendUnconfirmed] when a private - * reply may already be on chain: do not accept again; and + * reply may already be on chain, or + * [DashSdkError.PlatformWallet.PrivateContactRequestPending] when the pair + * is held for one: do not accept again; + * [DashSdkError.PlatformWallet.PrivateContactStoreNotRead] when the + * private store read every accept runs first did not finish (nothing + * sent; calling again continues it); and * [DashSdkError.PlatformWallet.ShieldedBuildRequired] when the request * needs a private reply and the native library has no shielded support. * diff --git a/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/errors/DashSdkErrorTest.kt b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/errors/DashSdkErrorTest.kt index d3e6711a795..81557a14d65 100644 --- a/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/errors/DashSdkErrorTest.kt +++ b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/errors/DashSdkErrorTest.kt @@ -338,6 +338,36 @@ class DashSdkErrorTest { assertFalse("a configuration error, not a retry", mapped.isRetryable) } + /** + * An unfinished private store read has its own code, apart from a + * pending request (71): nothing was reserved, and calling again + * continues the read. + */ + @Test + fun shouldReportAnUnfinishedPrivateStoreReadByItsOwnCode() { + val detail = "the private contact request store was not read to its end for 9fRYTJZW" + val native = DashSDKException(DashSdkError.PLATFORM_WALLET_CODE_OFFSET + 74, detail) + val mapped = DashSdkError.fromNative(native) + assertTrue(mapped is DashSdkError.PlatformWallet.PrivateContactStoreNotRead) + assertEquals(detail, mapped.message) + assertTrue("calling again continues the read", mapped.isRetryable) + } + + /** + * An SDK pinned below the protocol version has its own code, apart from a + * network below it (70), which lifts by itself. + */ + @Test + fun shouldReportAnSdkPinnedBelowTheProtocolVersionByItsOwnCode() { + val detail = "get_private_document_entries needs protocol version 15; " + + "the SDK is pinned at protocol version 14" + val native = DashSDKException(DashSdkError.PLATFORM_WALLET_CODE_OFFSET + 75, detail) + val mapped = DashSdkError.fromNative(native) + assertTrue(mapped is DashSdkError.PlatformWallet.SdkPinnedBelowProtocolVersion) + assertEquals(detail, mapped.message) + assertFalse("only raising the pin lifts it", mapped.isRetryable) + } + @Test fun platformShieldCapacityCode41MapsTyped() { val message = diff --git a/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandlerTest.kt b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandlerTest.kt index 48f32164cbb..6a40c939b72 100644 --- a/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandlerTest.kt +++ b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandlerTest.kt @@ -220,10 +220,13 @@ class PlatformWalletPersistenceHandlerTest { assertEquals(0L, noOpBridge.persistenceCapabilitiesBits()) assertEquals(1, handler.persistenceCapabilitiesVersion()) - assertEquals(0x4bbfL, handler.persistenceCapabilitiesBits()) + // The private contact request scan state is bit 15 (0x8000). + assertEquals(0x8bbfL, handler.persistenceCapabilitiesBits()) // Android has no pending-contact-crypto callback, so it must not // attest that semantic contract. assertEquals(0L, handler.persistenceCapabilitiesBits() and 0x40L) + // Nor does it keep the shielded recipient of an asset lock (bit 14). + assertEquals(0L, handler.persistenceCapabilitiesBits() and 0x4000L) val diagnostic = PlatformWalletPersistenceCapabilities( handler.persistenceCapabilitiesVersion(), diff --git a/packages/rs-platform-wallet-ffi/ERROR_CODE_REGISTRY.md b/packages/rs-platform-wallet-ffi/ERROR_CODE_REGISTRY.md index 877eb36db9a..4f887a97941 100644 --- a/packages/rs-platform-wallet-ffi/ERROR_CODE_REGISTRY.md +++ b/packages/rs-platform-wallet-ffi/ERROR_CODE_REGISTRY.md @@ -183,14 +183,14 @@ Fork-era numbers remain in the collision history, which is immutable record. | 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 | | 61 | `ErrorAssetLockRecipientMismatch` | dashpay/platform#5603 (tracked-lock shielded recipient) | Proposed — a shielded fund-from-asset-lock resume (the generic outpoint resume or a repeated-UUID wallet drain) names a different Orchard recipient than the one the tracked lock recorded when it was built. Nothing was submitted or paid; resume with the recorded recipient, which the row and `TrackedAssetLockFFI` expose. Rows tracked before recipients were recorded carry none and accept the caller's. Typed in Rust, Swift and Kotlin | -| 68 | `ErrorPrivateContactRequestNeedsPrivateReply` | DashPay private contact requests | 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 request goes privately instead, and the in-kind accept picks the private reply itself. The shielded spend contract (`map_spend_result`) keeps the code, as the in-kind accept can raise it. Taken above 61–67, which open branches already hold. Returned by the public send and accept; the host sends with `platform_wallet_manager_send_private_contact_request`, or 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`. Swift types it (`PlatformWalletError.privateContactRequestNeedsPrivateReply`, `ErrorHandlingTests` pin); Kotlin typing is added by the Kotlin PR (#5813) | -| 69 | `ErrorShieldedBuildRequired` | DashPay private contact requests | Proposed. The operation needs the shielded pool and the library was built without the `shielded` feature: accepting in kind a request that came privately, whose 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. Returned by `platform_wallet_manager_accept_contact_request_replying_in_kind`, exported in every build. Swift types it (`PlatformWalletError.shieldedBuildRequired`, `ErrorHandlingTests` pin); Kotlin typing is added by the Kotlin PR (#5813) | -| 70 | `ErrorProtocolVersionNotActive` | DashPay private contact requests | Proposed. The SDK refused a request without sending it (`dash_sdk::Error::ProtocolVersionNotActive`, not pinned) because the network is proven at a protocol version below the one that serves 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. An SDK pinned below the required version has its own code, 75. 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 types it (`PlatformWalletError.protocolVersionNotActive`, `ErrorHandlingTests` pin) and SwiftExampleApp keeps a refresh quiet on it; Kotlin typing is added by the Kotlin PR (#5813) | -| 71 | `ErrorPrivateContactRequestPending` | DashPay private contact requests | 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 a private payment the shielded store's journal keeps unresolved holds the pair. Nothing new was paid; a later private contact request sync settles it once the entry is read back, and the shielded sync once the payment provably never executes. Pending, not failed: the host must not submit it again. A private store read that did not finish before the request was decided is 74, not 71. The shielded spend contract (`map_spend_result`) keeps the code rather than flattening it to `ErrorWalletOperation`. Returned by the private send, the private accept and the in-kind accept, and by a public send or accept the pair holds; `platform_wallet_get_private_pair_held` reads one pair's hold as a send decides it. Swift types it (`PlatformWalletError.privateContactRequestPending`, `ErrorHandlingTests` pin; `ManagedPlatformWallet.privatePairHeld`), and SwiftExampleApp keeps the pair pending until that read says free; Kotlin typing is added by the Kotlin PR (#5813) | -| 72 | `ErrorPrivateContactPaymentsNotRestored` | DashPay private contact requests | Proposed. Maps `PlatformWalletError::PrivateContactPaymentsNotRestored`: wallet readiness, not one contact's pending request (71). The wallet's unconfirmed private payments may hold pairs no reservation shows yet, which only a restoration from the shielded store's journal shows. In a build with the `shielded` feature: the wallet was published (created or loaded) while no shielded store was configured, its persister declaring the private scan state (bit 15), which holds a wallet removed and registered again from the same seed, in this process or a new one; it was loaded after binding a shielded account in an earlier process; or its last restoration failed, or waits for a busy store. In every build: the identity was loaded with its private scan state marking a private payment that may be unresolved. Contact requests and acceptances, public and private, are refused with nothing read, signed or broadcast until they are restored: configuring the shielded store restores them with no keys, as binding the wallet's shielded account does, and a contact request first retries a restoration once without waiting on the store; a private sync does not, and a build without the `shielded` feature never does. A host whose persister declares the private scan state configures the shielded store when it activates the manager, before loading or creating wallets: until it does, and while a failed configure is not retried, 72 holds. `platform_wallet_get_private_contact_readiness` reads whether the payments are restored and whether the last restoration failed (`private_payments_restore_failed`; the wallet's `PrivateContactReadiness::private_payments_restore_failure` holds the reason). `platform_wallet_get_private_payments_restore_failure` gives that reason. Swift types it (`PlatformWalletError.privateContactPaymentsNotRestored`, `ErrorHandlingTests` pin) and binds both reads (`ManagedPlatformWallet.privateContactReadiness`, `privatePaymentsRestoreFailure`); SwiftExampleApp configures the shielded store when it activates a manager, before loading wallets, retries a failed configure, and shows "setting up" or the failure's reason until the readiness reads restored. Kotlin typing is added by the Kotlin PR (#5813) | -| 73 | `ErrorPrivateContactRecoveryUnsupported` | DashPay private contact requests | Proposed. Maps `PlatformWalletError::PrivateContactRecoveryUnsupported`: the persister does not declare `PRIVATE_CONTACT_REQUEST_SCAN_STATE` (bit 15) 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. The FFI constant is `PLATFORM_WALLET_PERSISTENCE_CAPABILITY_PRIVATE_CONTACT_REQUEST_SCAN_STATE` (`1 << 15`). Swift types it (`PlatformWalletError.privateContactRecoveryUnsupported`, `ErrorHandlingTests` pin), SwiftExampleApp shows it as a storage configuration error, and the SwiftData handler declares bit 0 and bit 15 with the begin and end callbacks wired; Kotlin typing is added by the Kotlin PR (#5813) | -| 74 | `ErrorPrivateContactStoreNotRead` | DashPay private contact requests | Proposed. Maps `PlatformWalletError::PrivateContactStoreNotRead`: DashPay's private contact request store was not read to its end for the identity, with the keys Platform proves it holds, before a contact request or acceptance, public or private, was decided (every one reads it from the identity's cursor first). Nothing was reserved, sealed, paid or broadcast; calling again continues the read from where it stopped. A node behind the store already seen, or keys that kept changing, stop it. It was reported as 71 before, which says not to submit again. Mapped by the blanket conversion and kept by the shielded spend contract (`map_spend_result`). Swift types it (`PlatformWalletError.privateContactStoreNotRead`, `ErrorHandlingTests` pin) and SwiftExampleApp offers a retry with no pending row; Kotlin typing is added by the Kotlin PR (#5813) | -| 75 | `ErrorSdkPinnedBelowProtocolVersion` | DashPay private contact requests | Proposed. The SDK refused a request without sending it because the SDK itself is pinned below the protocol version the request needs (`dash_sdk::Error::ProtocolVersionNotActive` with `pinned`), whatever the network runs: for example every contact request's private store read with the SDK pinned below protocol version 15 on a network at 15. A network upgrade does not lift it; only raising or removing the pin does. Before, it shared 70 with a network proven below the version, which lifts by itself. Mapped by the blanket conversion. Swift types it (`PlatformWalletError.sdkPinnedBelowProtocolVersion`, `ErrorHandlingTests` pin) and SwiftExampleApp shows a developer-facing message; Kotlin typing is added by the Kotlin PR (#5813) | +| 68 | `ErrorPrivateContactRequestNeedsPrivateReply` | DashPay private contact requests | 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 request goes privately instead, and the in-kind accept picks the private reply itself. The shielded spend contract (`map_spend_result`) keeps the code, as the in-kind accept can raise it. Taken above 61–67, which open branches already hold. Returned by the public send and accept; the host sends with `platform_wallet_manager_send_private_contact_request`, or 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`. Swift types it (`PlatformWalletError.privateContactRequestNeedsPrivateReply`, `ErrorHandlingTests` pin); Kotlin types it (`DashSdkError.PlatformWallet.PrivateContactRequestNeedsPrivateReply`, `DashSdkErrorTest` pin) | +| 69 | `ErrorShieldedBuildRequired` | DashPay private contact requests | Proposed. The operation needs the shielded pool and the library was built without the `shielded` feature: accepting in kind a request that came privately, whose 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. Returned by `platform_wallet_manager_accept_contact_request_replying_in_kind`, exported in every build. Swift types it (`PlatformWalletError.shieldedBuildRequired`, `ErrorHandlingTests` pin); Kotlin types it (`DashSdkError.PlatformWallet.ShieldedBuildRequired`, `DashSdkErrorTest` pin) | +| 70 | `ErrorProtocolVersionNotActive` | DashPay private contact requests | Proposed. The SDK refused a request without sending it (`dash_sdk::Error::ProtocolVersionNotActive`, not pinned) because the network is proven at a protocol version below the one that serves 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. An SDK pinned below the required version has its own code, 75. 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 types it (`PlatformWalletError.protocolVersionNotActive`, `ErrorHandlingTests` pin) and SwiftExampleApp keeps a refresh quiet on it; Kotlin types it (`DashSdkError.PlatformWallet.ProtocolVersionNotActive`, `DashSdkErrorTest` pin) and KotlinExampleApp keeps a refresh quiet on it | +| 71 | `ErrorPrivateContactRequestPending` | DashPay private contact requests | 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 a private payment the shielded store's journal keeps unresolved holds the pair. Nothing new was paid; a later private contact request sync settles it once the entry is read back, and the shielded sync once the payment provably never executes. Pending, not failed: the host must not submit it again. A private store read that did not finish before the request was decided is 74, not 71. The shielded spend contract (`map_spend_result`) keeps the code rather than flattening it to `ErrorWalletOperation`. Returned by the private send, the private accept and the in-kind accept, and by a public send or accept the pair holds; `platform_wallet_get_private_pair_held` reads one pair's hold as a send decides it. Swift types it (`PlatformWalletError.privateContactRequestPending`, `ErrorHandlingTests` pin; `ManagedPlatformWallet.privatePairHeld`), and SwiftExampleApp keeps the pair pending until that read says free; Kotlin types it (`DashSdkError.PlatformWallet.PrivateContactRequestPending`, `DashSdkErrorTest` pin; `Dashpay.privateRepliesPending` reads the pair's hold through JNI `DashpayNative.privatePairHeld`), and KotlinExampleApp keeps the pair pending until that read says free | +| 72 | `ErrorPrivateContactPaymentsNotRestored` | DashPay private contact requests | Proposed. Maps `PlatformWalletError::PrivateContactPaymentsNotRestored`: wallet readiness, not one contact's pending request (71). The wallet's unconfirmed private payments may hold pairs no reservation shows yet, which only a restoration from the shielded store's journal shows. In a build with the `shielded` feature: the wallet was published (created or loaded) while no shielded store was configured, its persister declaring the private scan state (bit 15), which holds a wallet removed and registered again from the same seed, in this process or a new one; it was loaded after binding a shielded account in an earlier process; or its last restoration failed, or waits for a busy store. In every build: the identity was loaded with its private scan state marking a private payment that may be unresolved. Contact requests and acceptances, public and private, are refused with nothing read, signed or broadcast until they are restored: configuring the shielded store restores them with no keys, as binding the wallet's shielded account does, and a contact request first retries a restoration once without waiting on the store; a private sync does not, and a build without the `shielded` feature never does. A host whose persister declares the private scan state configures the shielded store when it activates the manager, before loading or creating wallets: until it does, and while a failed configure is not retried, 72 holds. `platform_wallet_get_private_contact_readiness` reads whether the payments are restored and whether the last restoration failed (`private_payments_restore_failed`; the wallet's `PrivateContactReadiness::private_payments_restore_failure` holds the reason). `platform_wallet_get_private_payments_restore_failure` gives that reason. Swift types it (`PlatformWalletError.privateContactPaymentsNotRestored`, `ErrorHandlingTests` pin) and binds both reads (`ManagedPlatformWallet.privateContactReadiness`, `privatePaymentsRestoreFailure`); SwiftExampleApp configures the shielded store when it activates a manager, before loading wallets, retries a failed configure, and shows "setting up" or the failure's reason until the readiness reads restored. Kotlin types it (`DashSdkError.PlatformWallet.PrivateContactPaymentsNotRestored`, `DashSdkErrorTest` pin) and binds both reads (`Dashpay.privateContactReadiness`, `privatePaymentsRestoreFailure`); KotlinExampleApp configures the shielded store before loading wallets, retries a failed configure, and shows "setting up" or the failure's reason | +| 73 | `ErrorPrivateContactRecoveryUnsupported` | DashPay private contact requests | Proposed. Maps `PlatformWalletError::PrivateContactRecoveryUnsupported`: the persister does not declare `PRIVATE_CONTACT_REQUEST_SCAN_STATE` (bit 15) 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. The FFI constant is `PLATFORM_WALLET_PERSISTENCE_CAPABILITY_PRIVATE_CONTACT_REQUEST_SCAN_STATE` (`1 << 15`). Swift types it (`PlatformWalletError.privateContactRecoveryUnsupported`, `ErrorHandlingTests` pin), SwiftExampleApp shows it as a storage configuration error, and the SwiftData handler declares bit 0 and bit 15 with the begin and end callbacks wired; Kotlin types it (`DashSdkError.PlatformWallet.PrivateContactRecoveryUnsupported`, `DashSdkErrorTest` pin), KotlinExampleApp shows it as a storage configuration error, and the Room handler declares bit 0 and bit 15 with the begin and end callbacks wired | +| 74 | `ErrorPrivateContactStoreNotRead` | DashPay private contact requests | Proposed. Maps `PlatformWalletError::PrivateContactStoreNotRead`: DashPay's private contact request store was not read to its end for the identity, with the keys Platform proves it holds, before a contact request or acceptance, public or private, was decided (every one reads it from the identity's cursor first). Nothing was reserved, sealed, paid or broadcast; calling again continues the read from where it stopped. A node behind the store already seen, or keys that kept changing, stop it. It was reported as 71 before, which says not to submit again. Mapped by the blanket conversion and kept by the shielded spend contract (`map_spend_result`). Swift types it (`PlatformWalletError.privateContactStoreNotRead`, `ErrorHandlingTests` pin) and SwiftExampleApp offers a retry with no pending row; Kotlin types it (`DashSdkError.PlatformWallet.PrivateContactStoreNotRead`, `DashSdkErrorTest` pin) and KotlinExampleApp offers a retry with no pending row | +| 75 | `ErrorSdkPinnedBelowProtocolVersion` | DashPay private contact requests | Proposed. The SDK refused a request without sending it because the SDK itself is pinned below the protocol version the request needs (`dash_sdk::Error::ProtocolVersionNotActive` with `pinned`), whatever the network runs: for example every contact request's private store read with the SDK pinned below protocol version 15 on a network at 15. A network upgrade does not lift it; only raising or removing the pin does. Before, it shared 70 with a network proven below the version, which lifts by itself. Mapped by the blanket conversion. Swift types it (`PlatformWalletError.sdkPinnedBelowProtocolVersion`, `ErrorHandlingTests` pin) and SwiftExampleApp shows a developer-facing message; Kotlin types it (`DashSdkError.PlatformWallet.SdkPinnedBelowProtocolVersion`, `DashSdkErrorTest` pin) and KotlinExampleApp shows a developer-facing message | **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-unified-sdk-jni/src/dashpay.rs b/packages/rs-unified-sdk-jni/src/dashpay.rs index 8cc319d48c2..368735947d5 100644 --- a/packages/rs-unified-sdk-jni/src/dashpay.rs +++ b/packages/rs-unified-sdk-jni/src/dashpay.rs @@ -1379,34 +1379,41 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_DashpayNative_managed }) } -/// Whether a private contact request from this identity to `contactId` is -/// being paid for, or was paid for and is not yet read back from the store, -/// off a managed-identity handle (bridges -/// `managed_identity_get_private_reply_pending`). A pure read; the wallet -/// keeps it in memory only. ← Swift `ManagedIdentity.privateReplyPending(contactId:)`. +/// Whether a contact request from `senderId`, one of the wallet's identities, +/// to `recipientId`, public or private, is held for a private one, as a send +/// decides it (bridges `platform_wallet_get_private_pair_held`): a private +/// request in flight, paid and not yet read back, or kept by the shielded +/// store's journal. A journal record discharged since is applied first. +/// Throws on an unknown wallet or sender, returning false. +/// ← Swift `ManagedPlatformWallet.privatePairHeld(senderId:recipientId:)`. #[no_mangle] -pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_DashpayNative_managedIdentityPrivateReplyPending( +pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_DashpayNative_privatePairHeld( mut env: JNIEnv, _class: JClass, - identity_handle: jlong, - contact_id: JByteArray, + wallet_handle: jlong, + sender_id: JByteArray, + recipient_id: JByteArray, ) -> jboolean { guard(&mut env, JNI_FALSE, |env| { - let Some(id) = read_id32(env, &contact_id, "contactId") else { + let Some(sender) = read_id32(env, &sender_id, "senderId") else { + return JNI_FALSE; + }; + let Some(recipient) = read_id32(env, &recipient_id, "recipientId") else { return JNI_FALSE; }; - let mut pending = false; + let mut held = false; let result = unsafe { - platform_wallet_ffi::managed_identity_get_private_reply_pending( - identity_handle as Handle, - id.as_ptr(), - &mut pending, + platform_wallet_ffi::platform_wallet_get_private_pair_held( + wallet_handle as Handle, + sender.as_ptr(), + recipient.as_ptr(), + &mut held, ) }; if take_pwffi_error(env, result) { return JNI_FALSE; } - if pending { + if held { JNI_TRUE } else { JNI_FALSE @@ -1414,10 +1421,52 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_DashpayNative_managed }) } +/// `DashpayNative.privatePaymentsRestoreFailure`: why the last restoration +/// of the wallet's private payments failed, while they are not restored for +/// `identity_id` (bridges `platform_wallet_get_private_payments_restore_failure`), +/// or null when none failed. Throws on an unknown wallet or identity, +/// returning null. +/// ← Swift `ManagedPlatformWallet.privatePaymentsRestoreFailure(identityId:)`. +#[no_mangle] +pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_DashpayNative_privatePaymentsRestoreFailure( + mut env: JNIEnv, + _class: JClass, + wallet_handle: jlong, + identity_id: JByteArray, +) -> jstring { + guard(&mut env, ptr::null_mut(), |env| { + let Some(id) = read_id32(env, &identity_id, "identityId") else { + return ptr::null_mut(); + }; + let mut reason: *mut c_char = ptr::null_mut(); + let result = unsafe { + platform_wallet_ffi::platform_wallet_get_private_payments_restore_failure( + wallet_handle as Handle, + id.as_ptr(), + &mut reason, + ) + }; + if take_pwffi_error(env, result) { + return ptr::null_mut(); + } + if reason.is_null() { + return ptr::null_mut(); + } + // SAFETY: a non-null `reason` is the NUL-terminated string the call + // allocated; it is read once, then released to its allocator. + let text = unsafe { CStr::from_ptr(reason) } + .to_string_lossy() + .into_owned(); + unsafe { platform_wallet_ffi::platform_wallet_string_free(reason) }; + new_jstring(env, text) + }) +} + /// `DashpayNative.privateContactReadiness`: the wallet's readiness for /// `identity_id`'s contact requests -/// (`platform_wallet_get_private_contact_readiness`) as three flags, -/// `[privatePaymentsRestored, privateStoreRead, unresolvedPrivatePayment]`. +/// (`platform_wallet_get_private_contact_readiness`) as four flags, +/// `[privatePaymentsRestored, privateStoreRead, unresolvedPrivatePayment, +/// privatePaymentsRestoreFailed]`. /// Throws on an unknown wallet or identity, returning null. #[no_mangle] pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_DashpayNative_privateContactReadiness( @@ -1460,12 +1509,13 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_DashpayNative_private } /// The readiness flags in the order Kotlin reads them. -fn readiness_flags(readiness: &PrivateContactReadinessFFI) -> [jboolean; 3] { +fn readiness_flags(readiness: &PrivateContactReadinessFFI) -> [jboolean; 4] { let flag = |set: bool| if set { JNI_TRUE } else { JNI_FALSE }; [ flag(readiness.private_payments_restored), flag(readiness.private_store_read), flag(readiness.unresolved_private_payment), + flag(readiness.private_payments_restore_failed), ] } @@ -1767,11 +1817,23 @@ mod tests { private_payments_restored: true, private_store_read: false, unresolved_private_payment: true, + private_payments_restore_failed: false, }; - assert_eq!(readiness_flags(&readiness), [JNI_TRUE, JNI_FALSE, JNI_TRUE]); + assert_eq!( + readiness_flags(&readiness), + [JNI_TRUE, JNI_FALSE, JNI_TRUE, JNI_FALSE] + ); + let failed = PrivateContactReadinessFFI { + private_payments_restore_failed: true, + ..PrivateContactReadinessFFI::default() + }; + assert_eq!( + readiness_flags(&failed), + [JNI_FALSE, JNI_FALSE, JNI_FALSE, JNI_TRUE] + ); assert_eq!( readiness_flags(&PrivateContactReadinessFFI::default()), - [JNI_FALSE; 3] + [JNI_FALSE; 4] ); } From 67d8c7e1bcbba6f6512ffc4aff15d6cf0e22fd7a Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Tue, 10 Nov 2026 17:57:10 +0700 Subject: [PATCH 238/269] feat(kotlin-example-app): open the shielded store at activation and show each private contact code Ports the SwiftExampleApp change. - The shielded store opens before persisted wallets load. A failed open is kept in AppContainer.shieldedStoreFailure, retried on each DashPay reload, and shown. - Pending reads use the per-pair hold. A pair refused with 71 re-enables only when that read says it is free. - 72 shows "setting up" or why the restoration (or the store) failed. 74 is a retry message with no pending row, and 75 a developer-facing message. - ContactRequestOutcomesTest pins each. Co-Authored-By: Claude Opus 5.5 --- .../dashfoundation/example/di/AppContainer.kt | 66 +++++-- .../example/ui/dashpay/AddContactScreen.kt | 41 +++-- .../ui/dashpay/ContactRequestOutcomes.kt | 161 ++++++++++++------ .../ui/dashpay/ContactRequestsScreen.kt | 37 ++-- .../example/ui/dashpay/DashPaySyncHelpers.kt | 44 +++-- .../ui/dashpay/ContactRequestOutcomesTest.kt | 145 +++++++++++++--- 6 files changed, 364 insertions(+), 130 deletions(-) diff --git a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/di/AppContainer.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/di/AppContainer.kt index e8f13a71848..aa78175b62b 100644 --- a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/di/AppContainer.kt +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/di/AppContainer.kt @@ -153,23 +153,17 @@ class AppContainer(private val context: Context) { network = sdk.network, loadWallets = { val activatedManager = walletManagerStore.activate(sdk.network, sdk) + // Open the shielded store before any wallet is loaded or + // created: this app's persister keeps the private contact + // request scan state, so Rust holds every wallet published + // while no store is configured (code 72) until its private + // payment journal is read, and a later bind may never come (no + // mnemonic, declined biometric). With the store open first, + // each wallet's journal is read as it is published, and the + // binds' own calls on the same path are no-ops. A failure is + // recorded and retried; the wallets stay held meanwhile. + configureShieldedStore(activatedManager) activatedManager.loadPersistedWallets() - // Open the shielded store right after the load: Rust then - // restores, with no keys, the unconfirmed private contact - // request payments of every wallet that bound a shielded - // account in an earlier process. Until it does, their public - // contact requests are refused (code 71), and a later bind - // may never come (no mnemonic, declined biometric). The same - // path the bind opens, so the bind's call is a no-op. - if (shieldedService.isAvailable) { - try { - activatedManager.configureShielded(shieldedDbPath(activatedManager.network)) - } catch (e: kotlinx.coroutines.CancellationException) { - throw e - } catch (e: Exception) { - android.util.Log.w(TAG, "Failed to configure the shielded store after the load", e) - } - } org.dashfoundation.example.ui.dashpay.DashPayWalletLoad( value = activatedManager, loadedWalletIdsHex = activatedManager.wallets.value.keys, @@ -216,6 +210,46 @@ class AppContainer(private val context: Context) { } } + private val _shieldedStoreFailure = MutableStateFlow(null) + + /** + * Why opening the active network's shielded store failed, until a later + * attempt succeeds. The DashPay screens show it while the wallet's private + * payments wait for that store, and retry it ([shieldedStoreFailureRetrying]). + * ← Swift `WalletManagerStore.shieldedStoreFailures`. + */ + val shieldedStoreFailure: StateFlow = _shieldedStoreFailure.asStateFlow() + + /** + * Open [manager]'s network's shielded store, recording why it failed, or + * clearing that once it opens. A repeat on the same path does not wait + * on the store. + */ + private suspend fun configureShieldedStore(manager: org.dashfoundation.dashsdk.wallet.PlatformWalletManager) { + if (!shieldedService.isAvailable) return + try { + manager.configureShielded(shieldedDbPath(manager.network)) + _shieldedStoreFailure.value = null + } catch (e: kotlinx.coroutines.CancellationException) { + throw e + } catch (e: Exception) { + _shieldedStoreFailure.value = e.message ?: e.toString() + android.util.Log.w(TAG, "Failed to configure the shielded store", e) + } + } + + /** + * Why the active network's shielded store could not be opened, after + * trying it again, or null when it is open. + * ← Swift `WalletManagerStore.shieldedStoreFailure(retrying:)`. + */ + suspend fun shieldedStoreFailureRetrying(): String? { + if (_shieldedStoreFailure.value == null) return null + val manager = walletManagerStore.activeManager.value ?: return _shieldedStoreFailure.value + configureShieldedStore(manager) + return _shieldedStoreFailure.value + } + /** * Wallet-scoped rebind — drive the manager-wide sync loops from the * deterministic first wallet (← iOS `rebindWalletScopedServices` in diff --git a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/AddContactScreen.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/AddContactScreen.kt index a7a72bc985c..90663b10189 100644 --- a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/AddContactScreen.kt +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/AddContactScreen.kt @@ -102,12 +102,18 @@ fun AddContactScreen(identityIdHex: String, navController: NavHostController) { // could not be confirmed): the screen only offers to close, never to // submit again, as the send screen does for an unconfirmed spend. var unconfirmedNotice by remember { mutableStateOf(null) } - // Whether the wallet has a private request to the resolved recipient in - // flight, or paid and not yet read back (`privateRepliesPending`). While - // it does, Send and the collision accept are off and the screen offers a - // private sync, which settles it. Read from the wallet, so reopening this - // screen keeps it. A failed read keeps them off until a read succeeds. + // Whether the wallet holds the pair with the resolved recipient for a + // private request, as a send decides it (`privateRepliesPending`): in + // flight, paid and not yet read back, or kept by the shielded store's + // journal until the payment is proven never to land. While it does, Send + // and the collision accept are off and the screen offers a private sync, + // which reads a landed request back. Read from the wallet, so reopening + // this screen keeps it. A failed read keeps them off until a read + // succeeds. var recipientPending by remember { mutableStateOf(PrivateRepliesPendingSnapshot()) } + // Why the shielded store could not be opened, as last read with the + // pending state; null when it is open. + var shieldedStoreFailure by remember { mutableStateOf(null) } var isSyncingPrivate by remember { mutableStateOf(false) } var pendingReadVersion by remember { mutableStateOf(0) } var collisionRecipient by remember { mutableStateOf(null) } @@ -189,10 +195,11 @@ fun AddContactScreen(identityIdHex: String, navController: NavHostController) { val recipientHex = resolvedRecipient?.toHex() LaunchedEffect(recipientHex, wallet, pendingReadVersion) { val contactIds = listOfNotNull(resolvedRecipient) - recipientPending = recipientPending.recorded( - privateRepliesPending(wallet, idBytes, contactIds), - contactIds.map { it.toHex() }, - ).withReadiness(privateContactReadiness(wallet, idBytes)) + val pendingRead = privateRepliesPending(wallet, idBytes, contactIds) + val readiness = privateContactReadiness(wallet, idBytes) + recipientPending = recipientPending.recorded(pendingRead, contactIds.map { it.toHex() }) + .withReadiness(readiness?.readiness, readiness?.restoreFailure) + shieldedStoreFailure = container.shieldedStoreFailureRetrying() } // Binding the shielded account restores the wallet's private payments: // read the readiness again then. @@ -236,15 +243,19 @@ fun AddContactScreen(identityIdHex: String, navController: NavHostController) { navController.popBackStack() } catch (e: Exception) { when (val outcome = ContactRequestSubmissionOutcome.of(e)) { - // Nothing was sent, and the wallet may hold nothing for - // it: the screen shows it as pending and offers the - // private sync that settles it. + // Nothing was sent: the wallet holds the pair. The screen + // shows it as pending, with the private sync, until the + // per-contact read says it is free. ContactRequestSubmissionOutcome.PrivateRequestPending -> recipientPending = recipientPending.withRefusal(recipient.toHex()) - // Nothing was sent: the screen shows the wallet restoring + // Nothing was sent: the screen shows the wallet setting up // its private payments until its readiness says restored. ContactRequestSubmissionOutcome.PrivatePaymentsNotRestored -> recipientPending = recipientPending.withRestoringRefusal() + // Nothing was sent and nothing is pending: Send stays on, + // and sending again continues the read. + ContactRequestSubmissionOutcome.PrivateStoreNotRead -> + errorMessage = ContactRequestSubmissionOutcome.PRIVATE_STORE_NOT_READ_MESSAGE ContactRequestSubmissionOutcome.MayHaveGoneThrough -> { kickDashPaySync(scope, m) unconfirmedNotice = ContactRequestSubmissionOutcome.MAY_HAVE_GONE_THROUGH_MESSAGE @@ -311,6 +322,8 @@ fun AddContactScreen(identityIdHex: String, navController: NavHostController) { recipientPending = recipientPending.withRefusal(recipient.toHex()) ContactRequestSubmissionOutcome.PrivatePaymentsNotRestored -> recipientPending = recipientPending.withRestoringRefusal() + ContactRequestSubmissionOutcome.PrivateStoreNotRead -> + errorMessage = ContactRequestSubmissionOutcome.PRIVATE_STORE_NOT_READ_MESSAGE is ContactRequestSubmissionOutcome.Failed -> errorMessage = "Accept failed: ${outcome.message}" } @@ -421,6 +434,8 @@ fun AddContactScreen(identityIdHex: String, navController: NavHostController) { ContactRequestSubmissionOutcome.rowMessage( pending = recipientPending.isPending(recipient.toHex()), restoring = recipientPending.isRestoringPrivatePayments, + restoreFailure = recipientPending.restoreFailure, + shieldedStoreFailure = shieldedStoreFailure, unavailable = recipientPending.isUnavailable, error = null, ).orEmpty(), diff --git a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/ContactRequestOutcomes.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/ContactRequestOutcomes.kt index 5afbfc297a6..02d9802e03b 100644 --- a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/ContactRequestOutcomes.kt +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/ContactRequestOutcomes.kt @@ -6,28 +6,37 @@ import org.dashfoundation.dashsdk.tokens.Dashpay /** * How a contact request send or accept that threw ended, for the DashPay * screens. Follows the send screen (and iOS `SendViewModel.swift`): a shielded - * spend whose result could not be confirmed may already be on chain (Rust - * keeps its notes reserved), so the screen keeps it pending and never offers a - * fresh submission; the next sync settles it. A private request Rust reports - * as pending (one to the same contact in flight, or paid and not yet read - * back), which a public send or accept can also meet, sent nothing new: the - * screen shows it as pending and offers a private sync, which settles it, - * after which the user may try again. Unrestored private payments (code 72) - * hold the wallet, not one contact: the screen shows "restoring private - * payments" until the wallet's readiness reads restored; no private sync - * lifts it. Every other error is an ordinary failure the user may retry. + * spend whose result could not be confirmed (code 18) may already be on chain + * (Rust keeps its notes reserved), so the screen keeps it pending and never + * offers a fresh submission; the next sync settles it. A pair Rust reports as + * held for a private request (code 71), which a public send or accept can + * also meet, sent nothing new: the screen shows it as pending until the + * wallet's per-contact read says the pair is free. Unrestored private + * payments (code 72) hold the wallet, not one contact: the screen shows + * "setting up" until the wallet's readiness reads restored, or why the last + * restoration failed; no private sync lifts it. A private store read that did + * not finish (code 74) sent nothing and is not pending: the user may try + * again. Every other error is an ordinary failure the user may retry; a + * storage backend that cannot protect private payments (73) and an SDK + * pinned below the protocol version (75) are shown as configuration problems. * ← Swift `ContactRequestSubmissionOutcome` (`ContactRequestOutcomes.swift`). */ sealed interface ContactRequestSubmissionOutcome { /** The private reply may have gone through: do not submit again. */ data object MayHaveGoneThrough : ContactRequestSubmissionOutcome - /** A private request to the contact is pending in the wallet; nothing was sent. */ + /** The wallet holds the pair for a private request; nothing was sent. */ data object PrivateRequestPending : ContactRequestSubmissionOutcome /** The wallet's private payments are not restored yet; nothing was sent. */ data object PrivatePaymentsNotRestored : ContactRequestSubmissionOutcome + /** + * The private store read every contact request runs first did not + * finish; nothing was sent, and trying again continues it. + */ + data object PrivateStoreNotRead : ContactRequestSubmissionOutcome + /** It failed; the message is the error's. */ data class Failed(val message: String) : ContactRequestSubmissionOutcome @@ -36,11 +45,15 @@ sealed interface ContactRequestSubmissionOutcome { "after the next sync confirms it; do not submit it again." const val PRIVATE_REQUEST_PENDING_MESSAGE = "A private request to this contact is " + - "pending. Sync private requests to settle it, then try again if it did not " + - "go through." + "pending. It clears once the request is read back from the private store, " + + "or once its payment can no longer land, which can take a while. Sync " + + "private requests to check." + + const val SETTING_UP_PRIVATE_PAYMENTS_MESSAGE = "Setting up this wallet's private " + + "payments. Contact requests wait until it finishes." - const val RESTORING_PRIVATE_PAYMENTS_MESSAGE = "Restoring this wallet's private " + - "payments. Contact requests wait until they are restored." + const val PRIVATE_STORE_NOT_READ_MESSAGE = "Still reading private contact requests. " + + "Nothing was sent; try again in a moment." /** * Code 73 is a configuration error of the app's storage, which @@ -49,29 +62,62 @@ sealed interface ContactRequestSubmissionOutcome { const val RECOVERY_UNSUPPORTED_MESSAGE = "This storage backend can't protect " + "private payments, so private contact requests are off." + /** + * Code 75 is the developer's setting: only raising or removing the + * SDK's protocol version pin lifts it. + */ + const val SDK_PINNED_MESSAGE = "This build's SDK is pinned below the protocol " + + "version private contact requests need. Raise or remove the SDK's protocol " + + "version pin." + + /** The wallet's last restoration of its private payments failed. */ + fun restoreFailedMessage(reason: String): String = + "Restoring this wallet's private payments failed: $reason. Contact requests " + + "wait until a restore succeeds." + + /** The app could not open the shielded store, which restores the wallet's private payments. */ + fun shieldedStoreFailedMessage(reason: String): String = + "The shielded store could not be opened: $reason. Contact requests wait " + + "until it opens." + + /** + * The notice shown while the wallet's private payments are not + * restored: why opening the shielded store failed, why the last + * restoration failed, or that they are being set up. + */ + fun restoringMessage(restoreFailure: String?, shieldedStoreFailure: String?): String = when { + shieldedStoreFailure != null -> shieldedStoreFailedMessage(shieldedStoreFailure) + restoreFailure != null -> restoreFailedMessage(restoreFailure) + else -> SETTING_UP_PRIVATE_PAYMENTS_MESSAGE + } + fun of(error: Throwable): ContactRequestSubmissionOutcome = when (error) { is DashSdkError.PlatformWallet.ShieldedSpendUnconfirmed -> MayHaveGoneThrough is DashSdkError.PlatformWallet.PrivateContactRequestPending -> PrivateRequestPending is DashSdkError.PlatformWallet.PrivateContactPaymentsNotRestored -> PrivatePaymentsNotRestored + is DashSdkError.PlatformWallet.PrivateContactStoreNotRead -> PrivateStoreNotRead is DashSdkError.PlatformWallet.PrivateContactRecoveryUnsupported -> Failed(RECOVERY_UNSUPPORTED_MESSAGE) + is DashSdkError.PlatformWallet.SdkPinnedBelowProtocolVersion -> Failed(SDK_PINNED_MESSAGE) else -> Failed(error.message ?: "unknown error") } /** * The message a request row shows: the restoring notice while the * wallet's private payments are not restored, the pending notice - * while the wallet has a private request to the contact pending, the - * unavailable notice while the wallet could not be read, and - * whatever error an earlier attempt left otherwise. + * while the wallet holds the pair, the unavailable notice while the + * wallet could not be read, and whatever error an earlier attempt + * left otherwise. */ fun rowMessage( pending: Boolean, restoring: Boolean = false, + restoreFailure: String? = null, + shieldedStoreFailure: String? = null, unavailable: Boolean = false, error: String?, ): String? = when { - restoring -> RESTORING_PRIVATE_PAYMENTS_MESSAGE + restoring -> restoringMessage(restoreFailure, shieldedStoreFailure) pending -> PRIVATE_REQUEST_PENDING_MESSAGE unavailable -> PrivateRepliesPendingSnapshot.UNAVAILABLE_MESSAGE else -> error @@ -80,39 +126,41 @@ sealed interface ContactRequestSubmissionOutcome { } /** - * Which contacts (lower hex ids) have a private request pending in the - * wallet, as the screens last read it (`privateRepliesPending`). While one - * is, Rust refuses another send or reply to that contact, so the screens turn - * off what would submit one (Accept, Ignore, Send). A failed read is not - * "nothing pending": the last successful snapshot stays, and those controls - * stay off for every contact until a read covering it succeeds. + * Which contacts (lower hex ids) the wallet holds a pair with for a private + * request, as the screens last read it (`privateRepliesPending`, one pair + * read per contact, which answers as a send decides it). While one is held, + * Rust refuses another send or reply to that contact, so the screens turn off + * what would submit one (Accept, Ignore, Send). A failed read is not "nothing + * pending": the last successful snapshot stays, and those controls stay off + * for every contact until a read covering it succeeds. * - * A send or accept Rust refused as pending (code 71) is kept apart from the - * read: code 71 also means the private store could not be read to its end - * before the first send or accept since load, with nothing reserved, so the - * read may find nothing pending. That contact stays pending, with the - * private sync offered, until the wallet's readiness says the store was - * read to its end. + * A send or accept Rust refused as pending (code 71) marks the contact + * pending at once; the next successful read covering it decides, as it reads + * what the refusal read. Controls for a pair re-enable only when that read + * says it is free: a payment that will never land frees it only once the + * wallet proves so, which the read shows. * * Whether the wallet's private payments are restored is the wallet's * readiness (`Dashpay.privateContactReadiness`), not anything the screens * conclude: a code 72 refusal, or a readiness read that says not restored, - * shows "restoring private payments" for every contact, and only a - * readiness read that says restored lifts it. A private sync changes - * neither on its own. + * shows "setting up" (or why the last restoration failed) for every contact, + * and only a readiness read that says restored lifts it. A private sync + * changes neither on its own. * ← Swift `PrivateRepliesPendingSnapshot` (`ContactRequestOutcomes.swift`). */ data class PrivateRepliesPendingSnapshot( - /** The pending contacts the last successful read found. */ + /** The held contacts the last successful read found. */ val pending: Set = emptySet(), /** The contacts the last successful read covered. */ val covered: Set = emptySet(), /** False after a failed read, until a read succeeds. */ val available: Boolean = true, - /** Contacts a send or accept was refused for as pending (code 71), until the store is read to its end. */ + /** Contacts a send or accept was refused for as pending (code 71) since the last successful read covering them. */ val refused: Set = emptySet(), /** The wallet's readiness for the identity, as last read; null until a read succeeds. */ val readiness: Dashpay.PrivateContactReadiness? = null, + /** Why the last restoration failed, as last read with the readiness; null when none failed or it could not be read. */ + val restoreFailure: String? = null, /** Set by a code 72 refusal; cleared only by a readiness read that says restored. */ val restoringRefused: Boolean = false, ) { @@ -120,14 +168,15 @@ data class PrivateRepliesPendingSnapshot( val isUnavailable: Boolean get() = !available /** - * This snapshot after a read of [contactIds]: [read] holds the pending - * ones, or is null when the read failed. + * This snapshot after a read of [contactIds]: [read] holds the held + * ones, or is null when the read failed. A successful read decides each + * contact it covered, a code 71 refusal included. */ fun recorded(read: Set?, contactIds: Collection): PrivateRepliesPendingSnapshot = if (read == null) { copy(available = false) } else { - copy(pending = read, covered = contactIds.toSet(), available = true) + copy(pending = read, covered = contactIds.toSet(), refused = refused - contactIds.toSet(), available = true) } /** This snapshot after a send or accept to [contactId] was refused as pending (code 71). */ @@ -137,19 +186,22 @@ data class PrivateRepliesPendingSnapshot( fun withRestoringRefusal(): PrivateRepliesPendingSnapshot = copy(restoringRefused = true) /** - * This snapshot after a read of the wallet's readiness, or null when it - * failed, which keeps what the last read said. A readiness that says - * restored lifts a code 72 refusal; one that says the private store was - * read to its end leaves a contact's pending state to the pending read. + * This snapshot after a read of the wallet's readiness and, when it flags + * a failed restoration, the reason read with it; a null readiness (the + * read failed) keeps what the last read said. A readiness that says + * restored lifts a code 72 refusal and its failure. */ - fun withReadiness(read: Dashpay.PrivateContactReadiness?): PrivateRepliesPendingSnapshot = + fun withReadiness( + read: Dashpay.PrivateContactReadiness?, + restoreFailure: String? = null, + ): PrivateRepliesPendingSnapshot = if (read == null) { this } else { copy( readiness = read, + restoreFailure = restoreFailure.takeIf { read.privatePaymentsRestoreFailed }, restoringRefused = restoringRefused && !read.privatePaymentsRestored, - refused = if (read.privateStoreRead) emptySet() else refused, ) } @@ -162,15 +214,14 @@ data class PrivateRepliesPendingSnapshot( get() = restoringRefused || readiness?.privatePaymentsRestored == false /** - * Whether [contactId] is pending: the last successful read found a - * private request to it, or a send or accept to it was refused as - * pending and the store has not been read to its end since. + * Whether [contactId] is pending: the last successful read found the + * pair held, or a send or accept to it was refused as pending since. */ fun isPending(contactId: String): Boolean = contactId in pending || contactId in refused /** * Whether a send or reply to [contactId] may be offered: a successful - * read covered it and found nothing pending. + * read covered it and found the pair free. */ fun allowsSubmission(contactId: String): Boolean = available && contactId in covered && !isPending(contactId) && !isRestoringPrivatePayments @@ -198,10 +249,10 @@ object PrivateContactRefreshNotice { } /** The notice a pass that failed leaves, or null when the network does not serve the private store yet. */ - fun notice(error: Throwable): String? = - if (error is DashSdkError.PlatformWallet.ProtocolVersionNotActive) { - null - } else { - "Private requests could not be read: ${error.message ?: "unknown error"}" - } + fun notice(error: Throwable): String? = when (error) { + is DashSdkError.PlatformWallet.ProtocolVersionNotActive -> null + is DashSdkError.PlatformWallet.SdkPinnedBelowProtocolVersion -> + ContactRequestSubmissionOutcome.SDK_PINNED_MESSAGE + else -> "Private requests could not be read: ${error.message ?: "unknown error"}" + } } diff --git a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/ContactRequestsScreen.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/ContactRequestsScreen.kt index addfee36b81..fbd17e87858 100644 --- a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/ContactRequestsScreen.kt +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/ContactRequestsScreen.kt @@ -92,15 +92,17 @@ fun ContactRequestsScreen(identityIdHex: String, navController: NavHostControlle var removedOverlayIds by remember { mutableStateOf(emptySet()) } var rowErrors by remember { mutableStateOf(emptyMap()) } var isRefreshing by remember { mutableStateOf(false) } - // Hex ids with a private reply in flight, or paid and not yet read back, - // as the wallet records it (`privateRepliesPending`): the row stays - // pending, without its buttons, until a private sync settles it. Read - // from the wallet, not kept by this screen, so leaving and reopening it - // keeps the state. Re-read on composition and row changes, after an - // accept, on refresh and after each sync pass. A failed read keeps the - // last snapshot and leaves every row without its buttons until a read - // succeeds. + // Hex ids whose pair the wallet holds for a private request, as a send + // decides it (`privateRepliesPending`): the row stays pending, without its + // buttons, until the wallet's read says the pair is free. Read from the + // wallet, not kept by this screen, so leaving and reopening it keeps the + // state. Re-read on composition and row changes, after an accept, on + // refresh and after each sync pass. A failed read keeps the last snapshot + // and leaves every row without its buttons until a read succeeds. var walletPending by remember { mutableStateOf(PrivateRepliesPendingSnapshot()) } + // Why the shielded store could not be opened, as last read with the + // pending state; null when it is open. + var shieldedStoreFailure by remember { mutableStateOf(null) } var pendingReadVersion by remember { mutableStateOf(0) } // What the last private-store refresh has to tell the user, if anything. var privateRefreshNotice by remember { mutableStateOf(null) } @@ -166,8 +168,10 @@ fun ContactRequestsScreen(identityIdHex: String, navController: NavHostControlle ourIdentityId = idBytes, contactIds = contactIds, ) + val readiness = privateContactReadiness(wallet, idBytes) walletPending = walletPending.recorded(read, contactIds.map { it.toHex() }) - .withReadiness(privateContactReadiness(wallet, idBytes)) + .withReadiness(readiness?.readiness, readiness?.restoreFailure) + shieldedStoreFailure = container.shieldedStoreFailureRetrying() } // Binding the shielded account restores the wallet's private payments: // read the readiness again then. @@ -207,10 +211,9 @@ fun ContactRequestsScreen(identityIdHex: String, navController: NavHostControlle kickDashPaySync(scope, m) } ContactRequestSubmissionOutcome.PrivateRequestPending -> { - // Nothing was sent, and the wallet may hold nothing - // for it (the store was not read to its end): the row - // stays pending until the wallet's readiness says the - // store was read (pull to refresh reads it). + // Nothing was sent: the wallet holds the pair. The + // row stays pending until its per-contact read says + // free. walletPending = walletPending.withRefusal(hex) kickDashPaySync(scope, m) } @@ -219,6 +222,12 @@ fun ContactRequestsScreen(identityIdHex: String, navController: NavHostControlle // restored; every row waits for the readiness to say so. walletPending = walletPending.withRestoringRefusal() } + ContactRequestSubmissionOutcome.PrivateStoreNotRead -> { + // Nothing was sent and nothing is pending: the user may + // accept again, which continues the read. + rowErrors = rowErrors + + (hex to ContactRequestSubmissionOutcome.PRIVATE_STORE_NOT_READ_MESSAGE) + } is ContactRequestSubmissionOutcome.Failed -> rowErrors = rowErrors + (hex to "Accept failed: ${outcome.message}") } @@ -307,6 +316,8 @@ fun ContactRequestsScreen(identityIdHex: String, navController: NavHostControlle errorMessage = ContactRequestSubmissionOutcome.rowMessage( pending = walletPending.isPending(hex), restoring = walletPending.isRestoringPrivatePayments, + restoreFailure = walletPending.restoreFailure, + shieldedStoreFailure = shieldedStoreFailure, unavailable = walletPending.isUnavailable, error = rowErrors[hex], ), diff --git a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/DashPaySyncHelpers.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/DashPaySyncHelpers.kt index 4214772e305..9bec0c0f8bf 100644 --- a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/DashPaySyncHelpers.kt +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/DashPaySyncHelpers.kt @@ -4,6 +4,7 @@ import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch +import org.dashfoundation.dashsdk.tokens.Dashpay import org.dashfoundation.dashsdk.wallet.ManagedPlatformWallet import org.dashfoundation.dashsdk.wallet.PlatformWalletManager import org.dashfoundation.example.util.toHex @@ -37,14 +38,15 @@ fun kickDashPaySync(scope: CoroutineScope, manager: PlatformWalletManager) { } /** - * The hex ids of the contacts among [contactIds] that [ourIdentityId] has a - * private request to in flight, or paid and not yet read back, as the wallet - * records it (one snapshot). While one is, Rust refuses another send or reply - * to it, so the screens show it as pending and offer neither; a private sync - * settles it. Empty when the identity is not managed, as the wallet then - * holds nothing for it. Null when the wallet could not be read (no wallet, - * or a read failed), which the screens keep apart from "nothing pending" - * ([PrivateRepliesPendingSnapshot]). + * The hex ids of the contacts among [contactIds] whose pair with + * [ourIdentityId] the wallet holds for a private request, as a send decides + * it (`Dashpay.privateRepliesPending`, one pair read per contact): a private + * request in flight, paid and not yet read back, or kept by the shielded + * store's journal. While a pair is held, Rust refuses another send or reply + * to it, so the screens show it as pending and offer neither. Empty when the + * identity is not managed, as the wallet then holds nothing for it. Null when + * the wallet could not be read (no wallet, or a read failed), which the + * screens keep apart from "nothing pending" ([PrivateRepliesPendingSnapshot]). * ← Swift `privateRepliesPending` (`ContactsView.swift`). */ suspend fun privateRepliesPending( @@ -63,24 +65,40 @@ suspend fun privateRepliesPending( } } +/** The wallet's readiness for an identity's contact requests and, when it flags a failed restoration, why. */ +data class PrivateContactReadinessRead( + val readiness: Dashpay.PrivateContactReadiness, + val restoreFailure: String?, +) + /** - * The wallet's readiness for [ourIdentityId]'s contact requests, or null - * when it could not be read ([PrivateRepliesPendingSnapshot.withReadiness] - * keeps the last one then). + * The wallet's readiness for [ourIdentityId]'s contact requests and, when it + * flags a failed restoration, why it failed; null when the readiness could + * not be read ([PrivateRepliesPendingSnapshot.withReadiness] keeps the last + * one then). * ← Swift `privateContactReadiness(wallet:identityId:)` (`ContactsView.swift`). */ suspend fun privateContactReadiness( wallet: ManagedPlatformWallet?, ourIdentityId: ByteArray, -): org.dashfoundation.dashsdk.tokens.Dashpay.PrivateContactReadiness? { +): PrivateContactReadinessRead? { if (wallet == null) return null - return try { + val readiness = try { wallet.dashpay.privateContactReadiness(ourIdentityId) } catch (e: CancellationException) { throw e + } catch (e: Exception) { + return null + } + if (!readiness.privatePaymentsRestoreFailed) return PrivateContactReadinessRead(readiness, null) + val reason = try { + wallet.dashpay.privatePaymentsRestoreFailure(ourIdentityId) + } catch (e: CancellationException) { + throw e } catch (e: Exception) { null } + return PrivateContactReadinessRead(readiness, reason) } /** diff --git a/packages/kotlin-sdk/KotlinExampleApp/app/src/test/java/org/dashfoundation/example/ui/dashpay/ContactRequestOutcomesTest.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/test/java/org/dashfoundation/example/ui/dashpay/ContactRequestOutcomesTest.kt index 0bac7ae22c4..390bcd1c401 100644 --- a/packages/kotlin-sdk/KotlinExampleApp/app/src/test/java/org/dashfoundation/example/ui/dashpay/ContactRequestOutcomesTest.kt +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/test/java/org/dashfoundation/example/ui/dashpay/ContactRequestOutcomesTest.kt @@ -1,6 +1,7 @@ package org.dashfoundation.example.ui.dashpay import org.dashfoundation.dashsdk.errors.DashSdkError +import org.dashfoundation.dashsdk.tokens.Dashpay import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertNull @@ -8,9 +9,14 @@ import org.junit.Assert.assertTrue import org.junit.Test /** - * Port of `ContactRequestOutcomesTests.swift`: an unconfirmed private reply - * stays pending (no fresh submission is offered), and a network that does - * not serve the private store yet is quiet while real failures are shown. + * Port of `ContactRequestOutcomesTests.swift`, code by code: an unconfirmed + * private send or reply (18) stays pending with no fresh submission offered, + * a held pair (71) is pending until the per-contact read says free, + * unrestored payments (72) show "setting up" or why the restoration failed, + * an unsupported store (73) and a pinned SDK (75) are configuration messages, + * an unfinished private store read (74) is a retry with no pending row, and a + * network that does not serve the private store yet is quiet while real + * failures are shown. */ class ContactRequestOutcomesTest { @@ -83,17 +89,16 @@ class ContactRequestOutcomesTest { } /** - * A send refused as pending (code 71) when the wallet reserved nothing - * (its store was not read to its end): the following read finds nothing - * pending, yet the contact stays pending, with the private sync offered, - * until the wallet's readiness says the store was read. + * A send refused as pending (code 71) marks the contact pending at once; + * the next read of the pair, which reads what the refusal read, decides: + * held keeps it pending, free re-enables it. The readiness alone never + * re-enables it. */ @Test - fun shouldKeepARefusedContactPendingUntilTheReadinessSaysTheStoreWasRead() { + fun shouldReEnableARefusedPairOnlyWhenThePerContactReadSaysFree() { var snapshot = PrivateRepliesPendingSnapshot() - .withRefusal("01") .recorded(emptySet(), listOf("01")) - assertFalse(snapshot.isUnavailable) + .withRefusal("01") assertTrue(snapshot.isPending("01")) assertFalse(snapshot.allowsSubmission("01")) assertEquals( @@ -101,21 +106,77 @@ class ContactRequestOutcomesTest { ContactRequestSubmissionOutcome.rowMessage(pending = snapshot.isPending("01"), error = null), ) - // A pending read that finds nothing does not lift it; the wallet's - // readiness saying the store was read to its end does. - snapshot = snapshot.recorded(emptySet(), listOf("01")) - assertTrue(snapshot.isPending("01")) + // A readiness that says the store was read and nothing is held for + // the wallet does not free one pair. snapshot = snapshot.withReadiness(readiness(restored = true, storeRead = true)) + assertFalse(snapshot.allowsSubmission("01")) + + // The pair's read says held: still pending. + snapshot = snapshot.recorded(setOf("01"), listOf("01")) + assertTrue(snapshot.isPending("01")) + assertFalse(snapshot.allowsSubmission("01")) + + // A payment that will never land frees the pair once the wallet + // proves so; the next read says free and the controls come back. + snapshot = snapshot.recorded(emptySet(), listOf("01")) assertFalse(snapshot.isPending("01")) assertTrue(snapshot.allowsSubmission("01")) } - private fun readiness(restored: Boolean, storeRead: Boolean, unresolved: Boolean = false) = - org.dashfoundation.dashsdk.tokens.Dashpay.PrivateContactReadiness( - privatePaymentsRestored = restored, - privateStoreRead = storeRead, - unresolvedPrivatePayment = unresolved, + /** A read that does not cover a refused contact leaves its refusal. */ + @Test + fun shouldKeepARefusalAReadDidNotCover() { + val snapshot = PrivateRepliesPendingSnapshot() + .withRefusal("01") + .recorded(emptySet(), listOf("02")) + assertTrue(snapshot.isPending("01")) + assertTrue(snapshot.allowsSubmission("02")) + } + + /** + * Code 74: the private store read every contact request runs first did + * not finish. Nothing was sent and nothing is pending: its own outcome, + * with a retry message, and no pending row. + */ + @Test + fun shouldOfferARetryForAnUnfinishedPrivateStoreRead() { + assertEquals( + ContactRequestSubmissionOutcome.PrivateStoreNotRead, + ContactRequestSubmissionOutcome.of( + DashSdkError.PlatformWallet.PrivateContactStoreNotRead("not read to its end"), + ), ) + val snapshot = PrivateRepliesPendingSnapshot().recorded(emptySet(), listOf("01")) + assertFalse("the screen records no refusal for 74", snapshot.isPending("01")) + assertTrue(snapshot.allowsSubmission("01")) + assertTrue( + ContactRequestSubmissionOutcome.PRIVATE_STORE_NOT_READ_MESSAGE != + ContactRequestSubmissionOutcome.PRIVATE_REQUEST_PENDING_MESSAGE, + ) + } + + /** Code 75 is the developer's setting, shown as such, on a send and on a refresh alike. */ + @Test + fun shouldShowAPinnedSdkAsADeveloperMessage() { + val error = DashSdkError.PlatformWallet.SdkPinnedBelowProtocolVersion("pinned at 14") + assertEquals( + ContactRequestSubmissionOutcome.Failed(ContactRequestSubmissionOutcome.SDK_PINNED_MESSAGE), + ContactRequestSubmissionOutcome.of(error), + ) + assertEquals(ContactRequestSubmissionOutcome.SDK_PINNED_MESSAGE, PrivateContactRefreshNotice.notice(error)) + } + + private fun readiness( + restored: Boolean, + storeRead: Boolean, + unresolved: Boolean = false, + restoreFailed: Boolean = false, + ) = Dashpay.PrivateContactReadiness( + privatePaymentsRestored = restored, + privateStoreRead = storeRead, + unresolvedPrivatePayment = unresolved, + privatePaymentsRestoreFailed = restoreFailed, + ) /** A store that cannot protect private payments (code 73) is a configuration error, shown as such. */ @Test @@ -138,11 +199,55 @@ class ContactRequestOutcomesTest { ), ) assertEquals( - ContactRequestSubmissionOutcome.RESTORING_PRIVATE_PAYMENTS_MESSAGE, + ContactRequestSubmissionOutcome.SETTING_UP_PRIVATE_PAYMENTS_MESSAGE, ContactRequestSubmissionOutcome.rowMessage(pending = true, restoring = true, error = null), ) } + /** + * Code 72 after a failed restoration shows why it failed, read with the + * readiness, until a readiness says restored; a shielded store that did + * not open is shown over both. + */ + @Test + fun shouldShowWhyARestorationFailedUntilTheReadinessSaysRestored() { + var snapshot = PrivateRepliesPendingSnapshot() + .recorded(emptySet(), listOf("01")) + .withReadiness( + readiness(restored = false, storeRead = false, restoreFailed = true), + "the journal does not read", + ) + assertTrue(snapshot.isRestoringPrivatePayments) + assertEquals("the journal does not read", snapshot.restoreFailure) + assertEquals( + ContactRequestSubmissionOutcome.restoreFailedMessage("the journal does not read"), + ContactRequestSubmissionOutcome.rowMessage( + pending = false, + restoring = snapshot.isRestoringPrivatePayments, + restoreFailure = snapshot.restoreFailure, + error = null, + ), + ) + assertEquals( + ContactRequestSubmissionOutcome.shieldedStoreFailedMessage("disk full"), + ContactRequestSubmissionOutcome.rowMessage( + pending = false, + restoring = true, + restoreFailure = snapshot.restoreFailure, + shieldedStoreFailure = "disk full", + error = null, + ), + ) + + // A readiness that no longer flags a failure drops the reason. + snapshot = snapshot.withReadiness(readiness(restored = false, storeRead = false), "stale") + assertNull(snapshot.restoreFailure) + + snapshot = snapshot.withReadiness(readiness(restored = true, storeRead = false)) + assertFalse(snapshot.isRestoringPrivatePayments) + assertTrue(snapshot.allowsSubmission("01")) + } + /** * A code 72 hold survives a successful pending read that finds nothing, * and a readiness read after a private sync that still says not From 94ca8dbf0b5fb121efa29ee5043548f0cd9e7a48 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Tue, 10 Nov 2026 17:57:10 +0700 Subject: [PATCH 239/269] docs(sdk): record the pair-hold read, the restore failure and codes 74 and 75 in the parity manifest Co-Authored-By: Claude Opus 5.5 --- docs/sdk/sdk-parity-manifest.json | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/docs/sdk/sdk-parity-manifest.json b/docs/sdk/sdk-parity-manifest.json index 17e5a54f81c..0f012de030e 100644 --- a/docs/sdk/sdk-parity-manifest.json +++ b/docs/sdk/sdk-parity-manifest.json @@ -32,7 +32,6 @@ "managed_identity_get_contested_dpns_names": "packages/rs-platform-wallet-ffi/src/dpns.rs", "managed_identity_get_dashpay_payments": "packages/rs-platform-wallet-ffi/src/dashpay_payment.rs", "managed_identity_get_private_contact": "packages/rs-platform-wallet-ffi/src/private_contact_request.rs", - "managed_identity_get_private_reply_pending": "packages/rs-platform-wallet-ffi/src/private_contact_request.rs", "on_load_private_contact_scan_state_fn": "packages/rs-platform-wallet-ffi/src/persistence.rs", "on_load_private_contact_scan_state_free_fn": "packages/rs-platform-wallet-ffi/src/persistence.rs", "on_load_shielded_viewing_keys_fn": "packages/rs-platform-wallet-ffi/src/persistence.rs", @@ -47,6 +46,8 @@ "platform_wallet_create_invitation": "packages/rs-platform-wallet-ffi/src/invitation.rs", "platform_wallet_drain_pending_contact_crypto": "packages/rs-platform-wallet-ffi/src/dashpay.rs", "platform_wallet_get_private_contact_readiness": "packages/rs-platform-wallet-ffi/src/contact_request.rs", + "platform_wallet_get_private_pair_held": "packages/rs-platform-wallet-ffi/src/private_contact_request.rs", + "platform_wallet_get_private_payments_restore_failure": "packages/rs-platform-wallet-ffi/src/private_contact_request.rs", "platform_wallet_manager_accept_contact_request_replying_in_kind": "packages/rs-platform-wallet-ffi/src/private_contact_request.rs", "platform_wallet_manager_accept_private_contact_request": "packages/rs-platform-wallet-ffi/src/private_contact_request.rs", "platform_wallet_manager_create_with_persistence_capabilities": "packages/rs-platform-wallet-ffi/src/manager.rs", @@ -783,7 +784,7 @@ }, { "id": "dashpay.private_contact_requests", - "title": "DashPay private contact requests: store sync, shielded send, accept replying in kind, private-reply accept, private contact and pending reads, codes 68 to 73, readiness", + "title": "DashPay private contact requests: store sync, shielded send, accept replying in kind, private-reply accept, private contact and pair-hold reads, codes 68 to 75, readiness and restore failure", "area": "correctness", "shared_apis": [ "platform_wallet_sync_private_contact_requests", @@ -791,8 +792,9 @@ "platform_wallet_manager_accept_contact_request_replying_in_kind", "platform_wallet_manager_accept_private_contact_request", "managed_identity_get_private_contact", - "managed_identity_get_private_reply_pending", - "platform_wallet_get_private_contact_readiness" + "platform_wallet_get_private_pair_held", + "platform_wallet_get_private_contact_readiness", + "platform_wallet_get_private_payments_restore_failure" ], "required_persistence_capabilities": [ "atomic_changesets", @@ -803,13 +805,13 @@ "sdk": "partial", "example_app": "partial", "restart": "required", - "reason": "PrivateContactRequests.swift wraps the store sync, the shielded send, the accept replying in kind (the wallet replies privately to a request that came privately, and with a document otherwise; a public or adopted reply needs no shielded support or payment mnemonic read, and a build without shielded support refuses only a private reply, with code 69), the explicit private-reply accept, PrivateContactInfo and the wallet's pending-reply read, all signed with the identity signer the public calls take and counted as native manager operations; codes 68 to 73 are typed and pinned in ErrorHandlingTests. The sync, send and accepts have no automated or recorded gate: a private reply needs a protocol version 15 network with shielded funds. The private marker (core height 0) round-trips through the persisted coreHeightCreatedAt, but no test covers a restart, and the wallet keeps a pending reply in memory only. The example app has the send-privately toggle, accepts with acceptContactRequestReplyingInKind, keeps a pending or unconfirmed private send or reply pending by reading the wallet's flag (a failed read keeps the last snapshot and the controls off), offers a private sync for a pending request a public send or accept meets (code 71), and reports private refresh failures (quiet before protocol version 15), with no recorded gate. Code 72 (unrestored private payments) is typed apart from 71, and the readiness getter is bound; the example app shows \"restoring private payments\" from 72 or the readiness, lifted only by a readiness read that says restored, never by a private sync. Code 73 (a store that cannot keep a private payment's recovery state) is typed and shown as a storage configuration error; the host declares atomic changesets with the scan state, so it should not occur." + "reason": "PrivateContactRequests.swift wraps the store sync, the shielded send, the accept replying in kind (the wallet replies privately to a request that came privately, and with a document otherwise; a public or adopted reply needs no shielded support or payment mnemonic read, and a build without shielded support refuses only a private reply, with code 69), the explicit private-reply accept, PrivateContactInfo and the wallet's pair-hold read (privatePairHeld, which answers as a send decides it, the shielded store's journal included), all signed with the identity signer the public calls take and counted as native manager operations; codes 68 to 75 are typed and pinned in ErrorHandlingTests. The sync, send and accepts have no automated or recorded gate: a private reply needs a protocol version 15 network with shielded funds. The private marker (core height 0) round-trips through the persisted coreHeightCreatedAt, but no test covers a restart; a paid reply's hold survives one in the shielded store's payment journal, which no host test exercises. The example app has the send-privately toggle, accepts with acceptContactRequestReplyingInKind, keeps a held or unconfirmed private send or reply pending by reading the wallet's per-pair hold (a failed read keeps the last snapshot and the controls off), re-enables a pair only when that read says free (a payment that never lands frees it only once the wallet proves it expired), offers a private sync for a pending request a public send or accept meets (code 71), and reports private refresh failures (quiet before protocol version 15), with no recorded gate. Code 72 (unrestored private payments) is typed apart from 71, and the readiness getter and the restore-failure reason are bound; the example app configures the shielded store when it activates a manager, before loading wallets, retries a failed configure, and shows \"setting up\", why the last restoration failed or why the store did not open, from 72 or the readiness, lifted only by a readiness read that says restored, never by a private sync. Code 73 (a store that cannot keep a private payment's recovery state) is typed and shown as a storage configuration error; the host declares atomic changesets with the scan state, so it should not occur. Code 74 (a private store read that did not finish) is a retry with no pending row, and code 75 (an SDK pinned below the protocol version) a developer-facing message." }, "kotlin": { "sdk": "partial", "example_app": "partial", "restart": "required", - "reason": "Dashpay.syncPrivateContactRequests, privateContact and privateRepliesPending (fresh managed-identity snapshots) and PlatformWalletManager.sendPrivateContactRequest / acceptContactRequestReplyingInKind / acceptPrivateContactRequest bridge the same FFI (signed with the manager's identity signer, behind the manager's teardown gate); the in-kind accept is exported in every build, and codes 68 to 73 map to typed PlatformWallet errors (DashSdkErrorTest). JNI descriptors are covered only by the FfiSmokeTest symbol test; the flows need a protocol version 15 network with shielded funds and have no recorded gate, no test covers a restart, and the wallet keeps a pending reply in memory only. The example app accepts with acceptContactRequestReplyingInKind, keeps a pending or unconfirmed reply pending by reading the wallet's flag on its Requests rows and Add Contact (a failed read keeps the last snapshot and the controls off), offers a private sync for a pending request a public send or accept meets (code 71), and reports private refresh failures (quiet before protocol version 15); it has no send-privately toggle. Code 72 (unrestored private payments) is typed apart from 71, and the readiness getter is bound; the example app shows \"restoring private payments\" from 72 or the readiness, lifted only by a readiness read that says restored, never by a private sync. Code 73 (a store that cannot keep a private payment's recovery state) is typed and shown as a storage configuration error; the host declares atomic changesets with the scan state, so it should not occur." + "reason": "Dashpay.syncPrivateContactRequests, privateContact, privateRepliesPending (one pair-hold read per contact, as a send decides it, the shielded store's journal included), privateContactReadiness and privatePaymentsRestoreFailure, and PlatformWalletManager.sendPrivateContactRequest / acceptContactRequestReplyingInKind / acceptPrivateContactRequest bridge the same FFI (signed with the manager's identity signer, behind the manager's teardown gate); the in-kind accept is exported in every build, and codes 68 to 75 map to typed PlatformWallet errors (DashSdkErrorTest). JNI descriptors are covered only by the FfiSmokeTest symbol test; the flows need a protocol version 15 network with shielded funds and have no recorded gate, no test covers a restart, and a paid reply's hold survives one only in the shielded store's payment journal, which no host test exercises. The example app accepts with acceptContactRequestReplyingInKind, keeps a held or unconfirmed reply pending by reading the wallet's per-pair hold on its Requests rows and Add Contact (a failed read keeps the last snapshot and the controls off), re-enables a pair only when that read says free, offers a private sync for a pending request a public send or accept meets (code 71), and reports private refresh failures (quiet before protocol version 15); it has no send-privately toggle. Code 72 (unrestored private payments) is typed apart from 71, and the readiness getter and the restore-failure reason are bound; the example app configures the shielded store when it activates the manager, before loading wallets, retries a failed configure, and shows \"setting up\", why the last restoration failed or why the store did not open, from 72 or the readiness, lifted only by a readiness read that says restored, never by a private sync. Code 73 (a store that cannot keep a private payment's recovery state) is typed and shown as a storage configuration error; the host declares atomic changesets with the scan state (bit 15), so it should not occur. Code 74 (a private store read that did not finish) is a retry with no pending row, and code 75 (an SDK pinned below the protocol version) a developer-facing message." } }, "verification": [ @@ -841,8 +843,8 @@ "host": "shared", "kind": "unit", "file": "packages/rs-platform-wallet-ffi/src/private_contact_request.rs", - "id": "should_read_whether_a_private_reply_is_pending", - "command": "cargo test -p platform-wallet-ffi --lib should_read_whether_a_private_reply_is_pending", + "id": "should_read_a_pair_s_hold_and_the_restore_failure_from_the_wallet", + "command": "cargo test -p platform-wallet-ffi --lib should_read_a_pair_s_hold_and_the_restore_failure_from_the_wallet", "covers_restart": false }, { From f95f634ef7137816ac462dc701411d787307a73a Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Tue, 10 Nov 2026 18:41:54 +0700 Subject: [PATCH 240/269] fix(platform-wallet): keep journal records a lost flush left, carry the proved store size, hold marked identities on registration After a flush of a wallet's records failed for good, no read-back is noted for the rest of the process: a read of the entry again finds the request tracked in memory and stores nothing, so its record may be in memory only. A restoration that finds a journal record whose request its sender does not track rewinds the sender's read to the position the record kept, so the next process reads the entry again, records the request durably and discharges the record. The largest store size a page proved is kept for the process, per wallet, and a read reaches the store's end only once its cursor reaches it, across bounded passes and calls: a later page from a node behind no longer ends a read early. A registration, and a load, take from the persister's snapshot the private scan state records kept marked for the wallet's id, the wallet's own identities' and those kept after the wallet's removal (new ClientStartState::retained_private_contact_scan_states), and hold each identity they name as it is added, in every build. Only the marker is taken; the read starts from the store's start. The persistence docs state that a backend keeps a marked record through the wallet's removal until a record stored cleared replaces it. Also: stale doc comments on the store read limits, the private send and the restoration, and the readiness read, which applies discharges first. Co-Authored-By: Claude Opus 5.5 --- packages/rs-platform-wallet/README.md | 9 +- .../src/changeset/client_start_state.rs | 23 +- .../src/changeset/persistence_capabilities.rs | 14 + .../src/changeset/traits.rs | 7 +- packages/rs-platform-wallet/src/error.rs | 25 +- .../rs-platform-wallet/src/manager/load.rs | 16 +- .../src/manager/wallet_lifecycle.rs | 91 +++- .../network/private_contact_requests.rs | 493 ++++++++++++++++-- .../managed_identity/contact_requests.rs | 28 + .../identity/state/manager/lifecycle.rs | 28 + .../src/wallet/identity/state/manager/mod.rs | 10 +- .../src/wallet/persister.rs | 45 +- .../src/wallet/platform_wallet.rs | 23 +- 13 files changed, 726 insertions(+), 86 deletions(-) diff --git a/packages/rs-platform-wallet/README.md b/packages/rs-platform-wallet/README.md index beef86e0fbf..0c956800f43 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, the SHA-256 of the whole entry, the position the sender's read of DashPay's private store rested at when it paid, 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` with `fullfsync` (on Apple platforms a plain sync does not flush the drive's own cache), 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, judged against Platform's recorded anchors fetched before the shielded sync pass and proved at a height H: the spend's anchor, which the record keeps with the proved height of the set it was read from, was read at or below H and is absent from the set at H, the pass's note scan covers H, and its entry is absent from DashPay's private store read from the lowest position the records being judged kept through its end, with every page proved at or above H. An entry counts as the record's only when its whole bytes hash to the hash the record keeps: one that only starts with the commitment, which anyone who saw the commitment can append, does not hold the record. A record journaled before the hash and the position were kept matches by its commitment and is read for from the store's start. Whether its inputs are spent does not matter: the transition appends its entry in the block that spends them. 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). A restoration that finds a record whose request the sender's tracked request already records or supersedes, as a private sync read it back in a process that ended before discharging it, discharges it, unless a flush of the wallet's records failed for good in this process (the tracked request may then be in memory only). Nothing else, a relay's refusal or a missing redrive record included, discharges a record. +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, the SHA-256 of the whole entry, the position the sender's read of DashPay's private store rested at when it paid, 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` with `fullfsync` (on Apple platforms a plain sync does not flush the drive's own cache), 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, judged against Platform's recorded anchors fetched before the shielded sync pass and proved at a height H: the spend's anchor, which the record keeps with the proved height of the set it was read from, was read at or below H and is absent from the set at H, the pass's note scan covers H, and its entry is absent from DashPay's private store read from the lowest position the records being judged kept through its end, with every page proved at or above H. An entry counts as the record's only when its whole bytes hash to the hash the record keeps: one that only starts with the commitment, which anyone who saw the commitment can append, does not hold the record. A record journaled before the hash and the position were kept matches by its commitment and is read for from the store's start. Whether its inputs are spent does not matter: the transition appends its entry in the block that spends them. 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). A restoration that finds a record whose request the sender's tracked request already records or supersedes, as a private sync read it back in a process that ended before discharging it, discharges it. Once a flush of the wallet's records failed for good in a process, the backend dropping what it buffered, no record is discharged on the strength of a tracked request for the rest of that process: no read-back is noted, a read of the entry again included (it finds the request tracked in memory and stores nothing), and a restoration leaves the records it finds settled. A restoration that finds a record whose request its sender does not track rewinds the sender's read to the position the record kept (the store's start for a record that kept none), so the entry is read again, its request recorded, and the record discharged once that is durable. 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 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. These hold it: @@ -131,14 +131,17 @@ Until a payment's reservation is restored from the journal, nothing shows that t - In a build with the `shielded` feature, `load_from_persistor` also 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 a build with the `shielded` feature, a wallet stays held while its last restoration failed, or waits for a busy store. - 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. +- In every build, an identity added to a wallet registered or loaded under an id whose record the backend kept marked after removing the wallet (`ClientStartState::retained_private_contact_scan_states`) is held the same way, before it can send or accept, whether it is added from Platform or by discovery. Only the marker is taken: the record's read position covers requests that went with the wallet's other rows, so the store is read for the identity from its start. A backend declaring `PRIVATE_CONTACT_REQUEST_SCAN_STATE` keeps a marked record through the wallet's removal and any purge of its rows, until a record stored cleared for that wallet and owner replaces it; the wallet never asks for one to be deleted, as `remove_wallet` stores nothing and `remove_identity` refuses while the identity's record is marked. A private contact request is therefore paid for only over a backend attesting `PRIVATE_CONTACT_REQUEST_SCAN_STATE` (bit 15) together with `ATOMIC_CHANGESETS` (bit 0), the two `PersistenceCapabilities::PRIVATE_CONTACT_SCAN_STATE_RESTART` names, 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. The wallet holds it, beyond the sender identity, for as long as the journal keeps the payment's record: removing the identity and loading it again does not release it. The first `PlatformWalletManager::configure_shielded` restores from the journal, with no keys, the payments of every held wallet and of every wallet the journal may keep payments of; a wallet registered or loaded after it is restored as it is published. A repeat call, as a host makes on every bind, never waits for a store that a scan holds: it restores only the wallets still held, each deferred to a task while the store is busy. Binding a wallet's shielded account restores its own, deferring the same way, the wallet held until it succeeds. A contact request refused as not restored first runs one more restoration, which does not wait on the store. A private sync does not restore. A host whose persister attests the private scan state therefore configures the shielded store when it activates the manager, before loading or creating wallets: until then every such wallet's contact requests are refused with 72, and a configure that fails keeps them refused until one succeeds. A host that opens no shielded store this session keeps those wallets' and identities' contact requests refused, and a build without the `shielded` feature keeps the marked identities' 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, why the last restoration failed if it did, whether the private store was read to its end, and whether a payment may be unresolved (the marker, or a journal record the wallet holds the pair for), so a host shows the wallet's state rather than inferring it from refusals. `DashPayView::private_pair_held` reads one pair's hold as a send decides it. -Every contact request and acceptance, public or private, first reads DashPay's private contact request store for the identity from its cursor to the store's end, with the keys Platform proves the identity holds, so a private request that landed since the last read (from another device of the identity, or a contact's request to it) is seen before the request is decided; the claim of a public pair checks again, in its lock, that the read still reaches the end. A pass reaches the end only at the highest store size any of its pages proved, so a later page from a node behind does not end it early, and after a key change rewinds the read, it counts only when the page that reaches the end was proved at or above the height the earlier read had reached. While the read cannot finish, the request is refused with `PrivateContactStoreNotRead` (FFI code 74), nothing reserved, sealed, paid or broadcast; calling again continues the read. On a network proven below the protocol version that serves private documents, nothing is read. An SDK pinned below that version refuses the read (FFI code 75), whatever the network runs. +Every contact request and acceptance, public or private, first reads DashPay's private contact request store for the identity from its cursor to the store's end, with the keys Platform proves the identity holds, so a private request that landed since the last read (from another device of the identity, or a contact's request to it) is seen before the request is decided; the claim of a public pair checks again, in its lock, that the read still reaches the end. A read reaches the end only at the largest store size a page proved to the wallet in this process, in any pass or call, as the store only grows, so a later page from a node behind does not end it early, and after a key change rewinds the read, it counts only when the page that reaches the end was proved at or above the height the earlier read had reached. While the read cannot finish, the request is refused with `PrivateContactStoreNotRead` (FFI code 74), nothing reserved, sealed, paid or broadcast; calling again continues the read. On a network proven below the protocol version that serves private documents, nothing is read. An SDK pinned below that version refuses the read (FFI code 75), whatever the network runs. -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 clears with a store for the wallet's network. A journal record that does not read back is never removed, so it clears only once a known-good store file is restored; one whose wallet id does not read holds every wallet. The undecodable transition and the payment with no request 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. The readiness gives the failure's reason while the wallet is held. +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 clears with a store for the wallet's network. A journal record that does not read back is never removed, so it clears only once a known-good store file is restored; one whose wallet id does not read holds every wallet. The undecodable transition and the payment with no request 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: removing the wallet and adding it again does not clear it, as the backend keeps the marked record, unless the host discards that record too. The readiness gives the failure's reason while the wallet is held. + +A build without the `shielded` feature keeps no shielded store, journal or restoration. What it protects: an identity whose record is marked, loaded or kept through its wallet's removal, sends and accepts no contact request, public or private (72), and every contact request reads the private store to its end first, so a pair whose private request is in the store is never answered or followed publicly. What it does not: it pays for no private request itself (the private send and accept take the payment as a caller's closure), it never lifts a marker's hold (only a shielded build's restoration can show the payment resolved), and nothing but the marker holds a pair whose payment has not landed: a host that discards a marked record leaves that pair unheld. ### Shielded spends whose broadcast may have been delivered diff --git a/packages/rs-platform-wallet/src/changeset/client_start_state.rs b/packages/rs-platform-wallet/src/changeset/client_start_state.rs index c63e5a262be..c109e4bba3e 100644 --- a/packages/rs-platform-wallet/src/changeset/client_start_state.rs +++ b/packages/rs-platform-wallet/src/changeset/client_start_state.rs @@ -9,6 +9,9 @@ use std::collections::BTreeMap; +use dpp::prelude::Identifier; + +use crate::changeset::changeset::PrivateContactRequestsPosition; use crate::changeset::client_wallet_start_state::ClientWalletStartState; use crate::changeset::platform_address_sync_start_state::PlatformAddressSyncStartState; #[cfg(feature = "shielded")] @@ -38,11 +41,29 @@ pub struct ClientStartState { /// balance reads work without re-decrypting the chain. #[cfg(feature = "shielded")] pub shielded: ShieldedSyncStartState, + /// The private contact request scan state records + /// (`ContactChangeSet::private_contact_requests_positions`) the backend + /// kept for wallets it does not return in [`Self::wallets`], keyed by + /// wallet id, then owner identity: those of a removed wallet whose + /// marker (`PrivateContactRequestsPosition::private_payments_unresolved`) + /// is set. A backend that removes a wallet keeps each such record until a + /// record stored cleared for that wallet and owner replaces it, as a + /// private payment it stands for may still execute. + /// + /// Only the marker is used: an identity added to a wallet registered or + /// loaded under that id is held, as one loaded with its record marked is, + /// in every build (`PlatformWalletError::PrivateContactPaymentsNotRestored`). + /// The read position is not, as the requests read before it went with the + /// wallet's other rows. + pub retained_private_contact_scan_states: + BTreeMap>, } impl ClientStartState { pub fn is_empty(&self) -> bool { - let core_empty = self.platform_addresses.is_empty() && self.wallets.is_empty(); + let core_empty = self.platform_addresses.is_empty() + && self.wallets.is_empty() + && self.retained_private_contact_scan_states.is_empty(); #[cfg(feature = "shielded")] { core_empty && self.shielded.is_empty() diff --git a/packages/rs-platform-wallet/src/changeset/persistence_capabilities.rs b/packages/rs-platform-wallet/src/changeset/persistence_capabilities.rs index 1cc17db81c9..55c549c015b 100644 --- a/packages/rs-platform-wallet/src/changeset/persistence_capabilities.rs +++ b/packages/rs-platform-wallet/src/changeset/persistence_capabilities.rs @@ -134,6 +134,20 @@ impl PersistenceCapabilities { /// ([`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. + /// + /// A backend declaring it keeps a position record whose + /// `private_payments_unresolved` is set when it removes the record's + /// wallet or purges that wallet's rows, until a record stored cleared for + /// the same wallet and owner replaces it, and returns such records for a + /// wallet it no longer holds in + /// `ClientStartState::retained_private_contact_scan_states`. The marker + /// stands for a private payment that may still execute: a wallet + /// registered again under the id holds the identity it names, in every + /// build, until a restoration from the shielded store's journal shows the + /// payment resolved. The wallet itself never asks for a marked record to + /// be deleted: `PlatformWalletManager::remove_wallet` stores nothing, and + /// `IdentityManager::remove_identity` refuses while the identity's record + /// is marked. pub const PRIVATE_CONTACT_REQUEST_SCAN_STATE: Self = Self(1 << 15); /// Index of the highest bit declared above. It lives here, beside the diff --git a/packages/rs-platform-wallet/src/changeset/traits.rs b/packages/rs-platform-wallet/src/changeset/traits.rs index 11e5d2c58a7..326820934c4 100644 --- a/packages/rs-platform-wallet/src/changeset/traits.rs +++ b/packages/rs-platform-wallet/src/changeset/traits.rs @@ -444,7 +444,12 @@ pub trait PlatformWalletPersistence: Send + Sync { /// Called once at startup — this is a whole-client operation, not a /// per-wallet one, because `ClientStartState::platform_addresses` is /// already keyed by wallet id and the sub-changesets carry their own - /// wallet attribution where needed. + /// wallet attribution where needed. A registration reads it too, for the + /// wallet it registers: the cursors and backfill record of a wallet the + /// backend already holds, and the private contact request scan state + /// records it kept marked for that id + /// (`ClientStartState::retained_private_contact_scan_states`, see + /// [`PersistenceCapabilities::PRIVATE_CONTACT_REQUEST_SCAN_STATE`]). fn load(&self) -> Result; /// Look up a single core transaction record by `txid` for `wallet_id`. diff --git a/packages/rs-platform-wallet/src/error.rs b/packages/rs-platform-wallet/src/error.rs index 66eec7c61f1..36625e4eb83 100644 --- a/packages/rs-platform-wallet/src/error.rs +++ b/packages/rs-platform-wallet/src/error.rs @@ -705,17 +705,24 @@ pub enum PlatformWalletError { )] PrivateContactStoreNotRead(Identifier), - /// This wallet bound a shielded account before this process started, or - /// the identity was loaded with its private scan state marking a private + /// The wallet's private contact request payments from an earlier process + /// or wallet generation may hold pairs no reservation shows yet, which + /// only a restoration from the shielded store's journal shows: in a build + /// with the `shielded` feature, the wallet was published with no shielded + /// store configured while its persister keeps the private scan state, it + /// bound a shielded account before this process started, or its last + /// restoration failed or waits for a busy store; in every build, 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 + /// (`PrivateContactRequestsPosition::private_payments_unresolved`), or was + /// added to a wallet whose persister kept such a record after an earlier + /// wallet under the id was removed. No contact request, public or + /// private, is sent or accepted, nothing signed or broadcast, until they + /// are restored. Opening the shielded store /// (`PlatformWalletManager::configure_shielded`) restores them, with no - /// keys; so does binding the wallet's shielded account. A private sync - /// does not, and a build without the `shielded` feature never does. + /// keys; so does binding the wallet's shielded account, and a contact + /// request retries a restoration once. 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/manager/load.rs b/packages/rs-platform-wallet/src/manager/load.rs index 7c37ee0525a..002186924f9 100644 --- a/packages/rs-platform-wallet/src/manager/load.rs +++ b/packages/rs-platform-wallet/src/manager/load.rs @@ -92,6 +92,7 @@ impl PlatformWalletManager

{ wallets, #[cfg(feature = "shielded")] shielded, + retained_private_contact_scan_states, } = start_state; // Shielded restore happens lazily on `bind_shielded`, not here: only // which wallets bound a shielded account is kept from the snapshot. @@ -270,7 +271,20 @@ impl PlatformWalletManager

{ { identity_manager.forget_private_contact_scan_state(); } - IdentityManager::from(identity_manager) + let mut identity_manager = IdentityManager::from(identity_manager); + // Records the host kept marked for this id after an + // earlier wallet under it was removed hold their + // identities, those loaded now and those added later, as + // a record of their own marked would. + identity_manager.retain_private_payment_markers( + retained_private_contact_scan_states + .get(&expected_wallet_id) + .into_iter() + .flat_map(|records| records.iter()) + .filter(|(_, record)| record.private_payments_unresolved) + .map(|(owner, _)| *owner), + ); + identity_manager }, tracked_asset_locks, dpns_name_states: std::collections::BTreeMap::new(), diff --git a/packages/rs-platform-wallet/src/manager/wallet_lifecycle.rs b/packages/rs-platform-wallet/src/manager/wallet_lifecycle.rs index 17ff6aeb896..81b84cee8a5 100644 --- a/packages/rs-platform-wallet/src/manager/wallet_lifecycle.rs +++ b/packages/rs-platform-wallet/src/manager/wallet_lifecycle.rs @@ -1,8 +1,10 @@ //! Wallet creation, registration, and removal on [`PlatformWalletManager`]. +use std::collections::{BTreeMap, BTreeSet}; use std::sync::Arc; use dash_spv::chain::CheckpointManager; +use dpp::prelude::Identifier; use key_wallet::wallet::initialization::WalletAccountCreationOptions; use key_wallet::wallet::managed_wallet_info::ManagedWalletInfo; use key_wallet::wallet::Wallet; @@ -11,8 +13,9 @@ use key_wallet::Network; #[cfg(any(feature = "bls", feature = "eddsa"))] use crate::changeset::ProviderKeyExtendedPubKey; use crate::changeset::{ - AccountAddressPoolEntry, AccountRegistrationEntry, PlatformWalletChangeSet, - PlatformWalletPersistence, ProviderKeyAccountEntry, WalletMetadataEntry, + AccountAddressPoolEntry, AccountRegistrationEntry, ClientWalletStartState, + PlatformWalletChangeSet, PlatformWalletPersistence, PrivateContactRequestsPosition, + ProviderKeyAccountEntry, WalletMetadataEntry, }; use crate::error::PlatformWalletError; use crate::wallet::core::WalletGeneration; @@ -353,7 +356,7 @@ impl PlatformWalletManager

{ .map(|root| Wallet::compute_wallet_id_from_root_extended_pub_key(&root, None)) .unwrap_or(wallet.wallet_id); - let platform_info = PlatformWalletInfo { + let mut platform_info = PlatformWalletInfo { observed_input_conflicts: Default::default(), core_wallet: wallet_info, generation: Arc::clone(&generation), @@ -410,31 +413,47 @@ impl PlatformWalletManager

{ // must measure durable writes against the host's cursor, not the one // it just built, and keeps the host's DashPay backfill record for the // receival accounts it holds (see the seed below). + // + // And the private contact request scan state records the host kept + // marked for this id, its wallet's identities' or those kept after the + // wallet's removal: a private payment one stands for may still + // execute, while the identity is added afresh, with no reservation or + // record of it. Each such identity is held as it is added, before it + // can send or accept, in every build. let load_persister: Arc = Arc::clone(&self.persister) as _; - let (mut persisted_platform_addresses, mut host_wallets) = + let (mut persisted_platform_addresses, mut host_wallets, retained_markers) = match super::run_blocking_load(move || load_persister.load()).await { Ok(crate::changeset::ClientStartState { platform_addresses, wallets, + retained_private_contact_scan_states, .. - }) => ( - platform_addresses, - wallets - .into_iter() - .map(|(id, state)| { - ( - id, + }) => { + let retained_markers = retained_private_payment_markers( + registration_wallet_id, + &wallets, + &retained_private_contact_scan_states, + ); + ( + platform_addresses, + wallets + .into_iter() + .map(|(id, state)| { ( - state.wallet_info.metadata.synced_height, - state.dashpay_backfill, - ), - ) - }) - .collect::>(), - ), + id, + ( + state.wallet_info.metadata.synced_height, + state.dashpay_backfill, + ), + ) + }) + .collect::>(), + retained_markers, + ) + } Err(e) => { tracing::error!( wallet_id = %hex::encode(registration_wallet_id), @@ -502,6 +521,9 @@ impl PlatformWalletManager

{ // runs first, so a wallet still registered under this id never has // its record overwritten; every publisher holds the cursor lock, so // none can appear between that check and the insert. + platform_info + .identity_manager + .retain_private_payment_markers(retained_markers); let created_cursor = platform_info.core_wallet.metadata.synced_height; let (snapshot_cursor, mut host_backfill) = match host_wallets.remove(®istration_wallet_id) { @@ -1073,6 +1095,33 @@ impl PlatformWalletManager

{ } } +/// The identities of `wallet_id` whose private contact request scan state +/// record the host kept marked +/// (`PrivateContactRequestsPosition::private_payments_unresolved`): those of +/// its own identities, when the host still holds the wallet, and those it kept +/// after the wallet's removal +/// (`ClientStartState::retained_private_contact_scan_states`). +pub(super) fn retained_private_payment_markers( + wallet_id: WalletId, + wallets: &BTreeMap, + retained: &BTreeMap>, +) -> BTreeSet { + let held = wallets + .get(&wallet_id) + .into_iter() + .flat_map(|state| state.identity_manager.wallet_identities.values()) + .flat_map(|identities| identities.values()) + .filter(|managed| managed.dashpay().private_payments_unresolved()) + .map(|managed| managed.id()); + let kept = retained + .get(&wallet_id) + .into_iter() + .flat_map(|records| records.iter()) + .filter(|(_, record)| record.private_payments_unresolved) + .map(|(owner, _)| *owner); + held.chain(kept).collect() +} + #[cfg(test)] mod scoped_wallet_id_tests { use key_wallet::mnemonic::Mnemonic; 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 584f8986ac8..270a8430be7 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 @@ -178,19 +178,20 @@ pub struct PrivateContactSyncReport { /// 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: sixteen windows of pages fetched at once. With the read -/// position kept across restarts, the read is from there to the end. +/// How many pages of the store each private sync reads that a contact +/// request or acceptance runs before it is decided +/// ([`DashPayView::read_private_store_before_deciding`]): sixteen windows of +/// pages fetched at once, from the identity's cursor. 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 -/// from where they stopped. +/// How many such syncs a contact request or acceptance runs before it +/// refuses with [`PlatformWalletError::PrivateContactStoreNotRead`]: a later +/// call goes on from where they stopped. const PRIVATE_STORE_READ_PASSES: usize = 4; /// How many rounds of a store read and a proved fetch of the sender identity @@ -450,8 +451,9 @@ fn release_reservation( /// 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]); +/// recipient, account reference, the entry's commitment, the sender's read +/// position of the store when it paid, when the journal record kept it). +pub(crate) type JournaledPrivatePayment = (Identifier, Identifier, u32, [u8; 32], Option); /// What [`reserve_unresolved_private_payments`] reserved. #[derive(Debug, Default)] @@ -509,7 +511,7 @@ pub(crate) fn reserve_unresolved_private_payments( return Err(PlatformWalletError::WalletNotFound(hex::encode(wallet_id))); } let mut reserved = ReservedPrivatePayments::default(); - for &(sender, recipient, account_reference, commitment) in payments { + for &(sender, recipient, account_reference, commitment, _) in payments { if persister.private_entry_discharged(&commitment) { continue; } @@ -564,6 +566,24 @@ pub(crate) async fn restore_journaled_private_payments( } = reserve_unresolved_private_payments( info, generation, wallet_id, persister, payments, None, )?; + // A payment whose request its sender does not track may have had its + // entry read past while what recorded it was lost (a flush the backend + // failed for good): the sender's read goes back to where it was when it + // paid, so the entry is read again, its request recorded, and the record + // discharged once that is durable. A sender's read already before it + // stays. + let untracked: BTreeSet<[u8; 32]> = unresolved + .iter() + .map(|(commitment, _, _)| *commitment) + .collect(); + for &(sender, _, _, commitment, store_position) in payments { + if !untracked.contains(&commitment) { + continue; + } + if let Some(managed) = info.identity_manager.managed_identity_mut(&sender) { + managed.rewind_private_store_read_to(store_position.unwrap_or(0)); + } + } // The wallet holds each unresolved payment's pair too, beyond its sender // identity, and whether or not it manages the sender, until its record is // discharged. @@ -829,8 +849,9 @@ impl DashPayView<'_, B> { /// (`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. + /// so a later private request to the same recipient rotates past this + /// one; a later public one is refused + /// ([`PlatformWalletError::PrivateContactRequestNeedsPrivateReply`]). /// /// 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 @@ -843,21 +864,23 @@ impl DashPayView<'_, B> { /// 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, 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 - /// payment is unconfirmed. + /// request stays pending until a later sync reads it. Any outcome of the + /// payment keeps it pending the same way, unless its transition provably + /// never left the wallet, which releases it, as 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 reservation is in memory; a + /// payment that may reach the network is first journaled in the shielded + /// store, whose restoration after a restart holds its pair again + /// (`PlatformWallet::restore_private_contact_payments`). /// - /// 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. + /// Every contact request reads the store from the identity's cursor to its + /// end first, with the keys Platform proves the identity holds, which + /// records a request paid for in an earlier process or on another device; + /// while that read cannot finish, the send is refused with + /// [`PlatformWalletError::PrivateContactStoreNotRead`], nothing sealed or + /// paid. #[allow(clippy::too_many_arguments)] pub async fn send_private_contact_request( &self, @@ -1358,7 +1381,9 @@ impl DashPayView<'_, B> { } /// What a contact request from `identity_id` waits for, as the wallet - /// decides it ([`PrivateContactReadiness`]). A pure read. + /// decides it ([`PrivateContactReadiness`]). Not a pure read: a journal + /// record discharged since is applied first, under the wallet's write + /// lock, as a send would see it; then it reads live wallet state. pub async fn private_contact_readiness( &self, identity_id: &Identifier, @@ -1928,10 +1953,11 @@ impl DashPayView<'_, B> { // was appended or later. let mut recipients: BTreeMap = BTreeMap::new(); report.next_position = start; - // The highest store size a page of this pass showed: the pass reaches - // the store's end only there. A later page from a node behind, its - // store ending at that page's end, shows an older end. - let mut pass_total: u64 = 0; + // The largest store size a page proved, in this pass or an earlier + // pass or call (`WalletPersister::private_store_size_proved`): the pass + // reaches the store's end only there. A later page from a node behind, + // its store ending at that page's end, shows an older end. + let mut pass_total: u64 = self.persister.private_store_size_proved(); let persists_scan_state = self.persister.persists_private_contact_scan_state(); // The pages are fetched many at once while earlier ones are processed, @@ -2011,6 +2037,8 @@ impl DashPayView<'_, B> { self.sdk.version(), )?; pass_total = pass_total.max(read.total_count); + self.persister + .note_private_store_size_proved(read.total_count); let reaches_end = read.next_position >= pass_total; // Each recipient is fetched on its own, so one that cannot be read // holds back only the identities whose sent requests it is for. @@ -2439,7 +2467,7 @@ impl DashPayView<'_, B> { let payments: Vec = journaled .iter() .map(|&(account_reference, commitment)| { - (*sender, *recipient, account_reference, commitment) + (*sender, *recipient, account_reference, commitment, None) }) .collect(); let mut wm = self.wallet_manager.write().await; @@ -2577,6 +2605,7 @@ impl DashPayView<'_, B> { recipient, request.account_reference, *sent.entry.commitment(), + None, )); } let mut wm = self.wallet_manager.write().await; @@ -3031,6 +3060,7 @@ mod tests { #[cfg(feature = "shielded")] viewing_key: Option<[u8; 96]>, stored: Mutex>, + retained: BTreeMap>, } impl SnapshotPersistence { @@ -3093,6 +3123,7 @@ mod tests { #[allow(unused_mut)] let mut state = ClientStartState { wallets, + retained_private_contact_scan_states: self.retained.clone(), ..Default::default() }; #[cfg(feature = "shielded")] @@ -3138,6 +3169,7 @@ mod tests { .to_fvk_bytes(), ), stored: Mutex::default(), + retained: BTreeMap::new(), } } @@ -3172,6 +3204,7 @@ mod tests { #[cfg(feature = "shielded")] viewing_key: None, stored: Mutex::default(), + retained: BTreeMap::new(), } } @@ -3550,6 +3583,145 @@ mod tests { assert_eq!(fixture.journaled().await, 1, "kept for the next process"); } + /// After a flush failed for good, a rewind that reads the entry again in + /// the same process finds the request already tracked and notes no + /// read-back, so the journal record stays. The next process, whose disk + /// kept a read position past the entry and no record of the request (a + /// later flush stored the one, the lost flush held the other), restores + /// the pair, rewinds the sender's read to where the record says it was + /// when it paid, reads the entry again, records the request durably and + /// discharges the record. + #[cfg(feature = "shielded")] + #[tokio::test] + async fn should_hold_a_record_whose_request_a_lost_flush_dropped_until_a_restart_reads_it_again( + ) { + 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 wallet_id = fixture.wallet.wallet_id(); + // What the disk keeps of Alice: no request to Bob, read past it. + let mut disk = scan_state_snapshot(&fixture.wallet, &alice.id(), false).await; + if let Some(managed) = disk.identities.get_mut(&0) { + let record = managed.private_contact_requests_position_record(); + managed.apply_private_contact_requests_position(PrivateContactRequestsPosition { + position: 1, + ..record + }); + } + let entry = fixture + .pay_unconfirmed(ExpiryFixture::ANCHOR, ExpiryFixture::ANCHOR_SET_HEIGHT) + .await; + fixture + .wallet + .restore_private_contact_payments(&fixture.coordinator) + .await + .expect("restored"); + publish(&fixture.wallet, 0, vec![entry.to_vec()]).await; + let view = fixture.wallet.identity().dashpay(); + fixture + .persistence + .flush_fails_fatally + .store(true, Ordering::SeqCst); + view.sync_private_contact_requests(&alice_crypto, 4) + .await + .expect("the entry is read back"); + fixture + .persistence + .flush_fails_fatally + .store(false, Ordering::SeqCst); + // A rewind reads the entry again, its request already tracked. + { + let iw = fixture.wallet.identity(); + let mut wm = iw.wallet_manager.write().await; + let managed = wm + .get_wallet_info_mut(&wallet_id) + .expect("wallet info") + .identity_manager + .managed_identity_mut(&alice.id()) + .expect("Alice"); + let carol = Identifier::from([0xCC; 32]); + let _ = managed.ignore_sender(&carol); + let _ = managed.unignore_sender(&carol); + assert_eq!(managed.dashpay().private_contact_requests_position(), 0); + } + view.sync_private_contact_requests(&alice_crypto, 4) + .await + .expect("the entry is read again"); + fixture + .coordinator + .discharge_read_back_private_payments() + .await; + assert_eq!(fixture.journaled().await, 1, "the record stays"); + + // The next process. + let manager = PlatformWalletManager::new( + Arc::new( + dashpay_sdk( + &[&alice, &bob], + vec![entry.to_vec()], + PlatformVersion::latest(), + None, + ) + .await, + ), + Arc::new(disk), + Arc::new(NoopEventHandler), + ); + manager + .load_from_persistor() + .await + .expect("the relaunch loads"); + manager + .configure_shielded(&fixture.db_path) + .await + .expect("the shielded store is configured"); + let restarted = manager + .get_wallet(&wallet_id) + .await + .expect("the relaunched wallet"); + managed(&restarted, ALICE, |managed| { + assert_eq!( + managed.dashpay().private_contact_requests_position(), + 0, + "rewound to where Alice paid from" + ); + }) + .await; + let restarted_view = restarted.identity().dashpay(); + assert!(restarted_view + .private_pair_held(&alice.id(), &bob.id()) + .await + .expect("the hold")); + restarted_view + .sync_private_contact_requests(&alice_crypto, 4) + .await + .expect("the entry is read again"); + managed(&restarted, ALICE, |managed| { + assert!(managed.dashpay().outgoing_request(&bob.id()).is_some()); + }) + .await; + let coordinator = manager.shielded_coordinator().await.expect("configured"); + restarted + .restore_private_contact_payments(&coordinator) + .await + .expect("restored again"); + assert!( + coordinator + .store() + .read() + .await + .private_payment_journal(wallet_id) + .expect("the journal") + .is_empty(), + "discharged" + ); + assert!(!restarted_view + .private_pair_held(&alice.id(), &bob.id()) + .await + .expect("the hold")); + } + /// An entry that only starts with a journal record's commitment, as anyone /// who saw the commitment can append, does not show the record's payment /// executed: the record expires once its anchor is proven pruned. @@ -3879,6 +4051,100 @@ mod tests { .await; } + /// The largest store size a page proved survives the pass it was read in: + /// with chunks of two, a first pass bounded to one page proves five + /// entries at H; a second pass, its page from a node at H - 1 proving + /// four, stops at four and leaves the store not read to its end; a third + /// reads position 4, Alice's private request, and the store is read. + #[tokio::test] + async fn should_carry_the_largest_proved_store_size_across_bounded_passes() { + const H: u64 = 20; + 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 (_manager, bob_wallet) = wallet(BOB_PHRASE, &bob, &[&alice, &bob], vec![]).await; + serve_pages( + &bob_wallet, + H, + vec![ + (0, vec![vec![0xA0; 589], vec![0xA1; 589]], 5, H), + (2, vec![vec![0xA2; 589], vec![0xA3; 589]], 4, H - 1), + (4, vec![entry], 5, H), + ], + ) + .await; + let view = bob_wallet.identity().dashpay(); + let first = view + .sync_private_contact_requests(&bob_crypto, 1) + .await + .expect("the first pass"); + assert_eq!(first.next_position, 2, "its page budget"); + let second = view + .sync_private_contact_requests(&bob_crypto, 16) + .await + .expect("the second pass"); + assert_eq!(second.next_position, 4); + assert!(!second.complete, "short of the five the first pass proved"); + assert!(!store_read(&bob_wallet, BOB).await); + view.sync_private_contact_requests(&bob_crypto, 16) + .await + .expect("the third pass"); + assert!(store_read(&bob_wallet, BOB).await); + managed(&bob_wallet, BOB, |managed| { + assert!(managed.dashpay().incoming_request(&alice.id()).is_some()); + }) + .await; + } + + /// A store size an earlier call proved bounds the read a public request + /// makes: after a sync bounded to one page proved five entries at H, Bob's + /// public request to Alice, whose pages from 2 come from a node at H - 1 + /// proving four, reads on to position 4, Alice's private request, and is + /// refused. + #[tokio::test] + async fn should_not_end_a_public_request_s_read_below_a_size_an_earlier_call_proved() { + const H: u64 = 20; + 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![]).await; + serve_pages( + &bob_wallet, + H, + vec![ + (0, vec![vec![0xA0; 589], vec![0xA1; 589]], 5, H), + (2, vec![vec![0xA2; 589], vec![0xA3; 589]], 4, H - 1), + (4, vec![entry], 5, H), + ], + ) + .await; + let view = bob_wallet.identity().dashpay(); + view.sync_private_contact_requests(&bob_crypto, 1) + .await + .expect("a sync bounded to one page"); + 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(sender)) + if sender == alice.id() + ), + "{public:?}" + ); + } + /// `wallet`'s mock answers each page `(start, entries, total, height)` with /// those entries, in chunks of two, showing `total` and proved at /// `height`; every other response is proved at `base`. @@ -9453,7 +9719,7 @@ mod tests { } let view = alice_wallet.identity().dashpay(); let restored = view - .restore_private_payments(&[(alice.id(), bob.id(), 1, commitment)], true) + .restore_private_payments(&[(alice.id(), bob.id(), 1, commitment, None)], true) .await .expect("restored"); assert!(restored.is_empty(), "the sender is not managed"); @@ -9522,7 +9788,7 @@ mod tests { let (_manager, alice_wallet) = wallet(ALICE_PHRASE, &alice, &[&alice, &bob], vec![]).await; let view = alice_wallet.identity().dashpay(); let restored = view - .restore_private_payments(&[(alice.id(), bob.id(), 1, [0x5E; 32])], true) + .restore_private_payments(&[(alice.id(), bob.id(), 1, [0x5E; 32], None)], true) .await .expect("restored"); assert_eq!(restored, vec![(alice.id(), bob.id())]); @@ -10125,7 +10391,6 @@ mod tests { } /// A manager over `persistence`, with Alice and Bob known to its SDK. - #[cfg(feature = "shielded")] async fn manager_on( persistence: Arc

, ) -> PlatformWalletManager

{ @@ -10139,7 +10404,6 @@ mod tests { /// `manager`'s wallet of `phrase`, created as a host creates one, holding /// `own` as loaded from Platform: a fresh managed identity, no marker. - #[cfg(feature = "shielded")] async fn create_holding( manager: &PlatformWalletManager

, phrase: &str, @@ -10165,6 +10429,157 @@ mod tests { wallet } + /// A persister that kept a removed wallet's marked private scan state + /// record, as `ClientStartState::retained_private_contact_scan_states` + /// asks, and holds nothing else of it. + struct RetainingPersistence { + wallet_id: WalletId, + owner: Identifier, + capabilities: PersistenceCapabilities, + } + + impl PlatformWalletPersistence for RetainingPersistence { + fn persistence_capabilities(&self) -> PersistenceCapabilities { + self.capabilities + } + + 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 mut state = ClientStartState::default(); + state.retained_private_contact_scan_states.insert( + self.wallet_id, + BTreeMap::from([( + self.owner, + PrivateContactRequestsPosition { + position: 7, + key_fingerprint: [0x5C; 32], + private_payments_unresolved: true, + }, + )]), + ); + Ok(state) + } + } + + /// An identity whose private scan state record its persister kept marked + /// after the wallet was removed is held once the same seed is registered + /// again by a new manager and the identity loaded afresh from Platform, + /// before any shielded store is configured, in every build: a public + /// request is refused as not restored. Its read starts from the store's + /// start, not the position kept, as the requests read before it went + /// with the wallet's other rows. + #[tokio::test] + async fn should_hold_an_identity_whose_marked_record_outlived_its_wallet() { + 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 wallet_id = { + let manager = manager_on(RecordingPersistence::attesting( + PersistenceCapabilities::NONE, + )) + .await; + let first = create_holding(&manager, ALICE_PHRASE, &alice).await; + let wallet_id = first.wallet_id(); + manager.remove_wallet(&wallet_id).await.expect("removed"); + wallet_id + }; + for capabilities in [ + PersistenceCapabilities::PRIVATE_CONTACT_SCAN_STATE_RESTART, + PersistenceCapabilities::NONE, + ] { + let manager = manager_on(Arc::new(RetainingPersistence { + wallet_id, + owner: alice.id(), + capabilities, + })) + .await; + let again = create_holding(&manager, ALICE_PHRASE, &alice).await; + assert_eq!(again.wallet_id(), wallet_id); + managed(&again, ALICE, |managed| { + assert!( + managed.dashpay().private_payments_unrestored(), + "held by the kept marker" + ); + assert_eq!( + managed.dashpay().private_contact_requests_position(), + 0, + "read from the store's start" + ); + }) + .await; + let public = again + .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) + ), + "{capabilities:?}: {public:?}" + ); + } + } + + /// A load honours a record its persister kept marked for the wallet's id + /// after an earlier wallet under it was removed, though the identity's + /// own record is cleared: the loaded identity is held. + #[tokio::test] + async fn should_hold_a_loaded_identity_whose_record_a_persister_kept_marked() { + 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(), false).await; + kept.retained = BTreeMap::from([( + before.wallet_id(), + BTreeMap::from([( + alice.id(), + PrivateContactRequestsPosition { + position: 3, + key_fingerprint: [0x5C; 32], + private_payments_unresolved: true, + }, + )]), + )]); + 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"); + managed(&restarted, ALICE, |managed| { + assert!(managed.dashpay().private_payments_unrestored()); + }) + .await; + } + /// A wallet published with no shielded store configured, its persister /// keeping the private scan state, is held until the journal is read, /// whatever its history: a fresh wallet is refused as not restored, and @@ -11578,8 +11993,8 @@ mod tests { let recorded = view .restore_private_payments( &[ - (bob.id(), alice.id(), reference, [9; 32]), - (alice.id(), bob.id(), reference, [8; 32]), + (bob.id(), alice.id(), reference, [9; 32], None), + (alice.id(), bob.id(), reference, [8; 32], None), ], true, ) @@ -11589,7 +12004,7 @@ mod tests { assert!(!pending_to(&bob_wallet, BOB, &alice).await); let rotation = view - .restore_private_payments(&[(bob.id(), alice.id(), rotated, [9; 32])], true) + .restore_private_payments(&[(bob.id(), alice.id(), rotated, [9; 32], None)], 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 243092f26c7..b5148aca61b 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 @@ -850,6 +850,23 @@ impl ManagedIdentity { } } + /// Rewind this identity's read of DashPay's private contact request + /// store to `position` when it is past it, as + /// [`Self::rewind_private_store_read`] rewinds it to 0: the read is no + /// longer to the end, and the height it rested on becomes the floor a + /// read since must reach. Returns whether it moved. A restoration rewinds + /// to the position a journal record kept, so a private payment whose + /// request the identity does not track has its entry read again. + #[cfg(feature = "shielded")] + pub(crate) fn rewind_private_store_read_to(&mut self, position: u64) -> bool { + if self.dashpay.private_contact_requests_position <= position { + return false; + } + self.rewind_private_store_read(); + self.dashpay.private_contact_requests_position = position; + true + } + /// Note that a private sync read a page of DashPay's private contact /// request store, proved at `height`, for this identity /// (`DashPayState::private_store_read_height`). @@ -899,6 +916,17 @@ impl ManagedIdentity { self.dashpay.private_payments_unrestored = position.private_payments_unresolved; } + /// Hold this identity as a load holds one whose position record is + /// marked (`DashPayState::private_payments_unrestored`): a persister kept + /// its record marked though the identity was added afresh, with no + /// reservation or record of the payment the marker stands for, as after + /// its wallet was removed and registered again. Only the hold is taken: + /// the record's read position covers requests that went with the wallet's + /// other rows, so the store is read for the identity from its start. + pub(crate) fn hold_for_retained_private_payment_marker(&mut self) { + self.dashpay.private_payments_unrestored = true; + } + /// 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 diff --git a/packages/rs-platform-wallet/src/wallet/identity/state/manager/lifecycle.rs b/packages/rs-platform-wallet/src/wallet/identity/state/manager/lifecycle.rs index c5ad0eabb31..e376103283e 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/state/manager/lifecycle.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/state/manager/lifecycle.rs @@ -96,6 +96,11 @@ impl IdentityManager { // iterate values know which wallet + slot they came from // without re-threading the keys. managed_identity.wallet_id = Some(wallet_id); + // Held before anything can send or accept for it, when a persister + // kept its record marked. + if self.retained_private_payment_markers.remove(&identity_id) { + managed_identity.hold_for_retained_private_payment_marker(); + } self.wallet_identities .entry(wallet_id) @@ -114,6 +119,29 @@ impl IdentityManager { Ok(()) } + /// Hold every identity in `owners`, whose private contact request scan + /// state record a persister kept marked + /// (`PrivateContactRequestsPosition::private_payments_unresolved`), as a + /// load holds one loaded with its record marked + /// (`DashPayState::private_payments_unrestored`): one this manager holds + /// now at once, every other when it is added. Its private payment may + /// still execute, while the identity, added afresh, holds no reservation + /// or record of it. Only a restoration from the shielded store's journal + /// releases the hold. + pub(crate) fn retain_private_payment_markers( + &mut self, + owners: impl IntoIterator, + ) { + for owner in owners { + match self.managed_identity_mut(&owner) { + Some(managed) => managed.hold_for_retained_private_payment_marker(), + None => { + self.retained_private_payment_markers.insert(owner); + } + } + } + } + /// Add an identity to the out-of-wallet (observed read-only) bucket. /// /// There is no separate `WatchedIdentity` type; observed identities diff --git a/packages/rs-platform-wallet/src/wallet/identity/state/manager/mod.rs b/packages/rs-platform-wallet/src/wallet/identity/state/manager/mod.rs index 32237f583e4..236f259e4b2 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/state/manager/mod.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/state/manager/mod.rs @@ -35,7 +35,7 @@ use crate::changeset::{IdentityManagerStartState, IdentityScanStateEntry}; use crate::wallet::platform_wallet::WalletId; use dpp::identity::accessors::IdentityGettersV0; use dpp::prelude::Identifier; -use std::collections::BTreeMap; +use std::collections::{BTreeMap, BTreeSet}; /// Plain alias for the BIP-9 HD identity index used as the inner-bucket /// key for wallet-owned identities. Keeps the type signatures readable @@ -107,6 +107,13 @@ pub struct IdentityManager { /// [`IdentityManagerStartState::scan_states`] for why that is deliberately /// not read as "complete". identity_scan_states: BTreeMap, + + /// The identities whose private contact request scan state record a + /// persister kept marked, for a wallet removed or registered again, that + /// this manager does not hold yet + /// (`ClientStartState::retained_private_contact_scan_states`): each is held + /// as it is added ([`Self::retain_private_payment_markers`]). + retained_private_payment_markers: BTreeSet, } impl From for IdentityManager { @@ -141,6 +148,7 @@ impl From for IdentityManager { wallet_identities, location_index, identity_scan_states: scan_states, + retained_private_payment_markers: BTreeSet::new(), } } } diff --git a/packages/rs-platform-wallet/src/wallet/persister.rs b/packages/rs-platform-wallet/src/wallet/persister.rs index 7a8fb4895b8..bb3e29bc480 100644 --- a/packages/rs-platform-wallet/src/wallet/persister.rs +++ b/packages/rs-platform-wallet/src/wallet/persister.rs @@ -5,7 +5,7 @@ //! a fixed `wallet_id` so callers don't need to pass the ID on every call. use std::collections::{BTreeMap, BTreeSet}; -use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::sync::Arc; use dashcore::Txid; @@ -106,6 +106,10 @@ pub struct WalletPersister { /// next process tracks what its load read from disk. #[cfg(feature = "shielded")] buffered_writes_lost: Arc, + /// The largest size of DashPay's private contact request store a page + /// this wallet read proved, in this process + /// ([`Self::private_store_size_proved`]). In memory only. + private_store_size_proved: Arc, /// Why the last restoration of this wallet's private payments failed, /// while no later one succeeded (`PlatformWalletError::PrivateContactPaymentsNotRestored` /// then holds for it, not only for one not run yet). Shared by the @@ -170,6 +174,7 @@ impl WalletPersister { stored_private_markers: Arc::default(), #[cfg(feature = "shielded")] buffered_writes_lost: Arc::default(), + private_store_size_proved: Arc::default(), private_payment_restore_failure: Arc::default(), #[cfg(feature = "shielded")] private_payment_restore_retry: Arc::default(), @@ -273,6 +278,14 @@ impl WalletPersister { /// the pairs they hold, stay until noted. The host's flush runs with none /// of the persister's locks held, so a call during it, from another thread /// or the flush itself, neither waits nor loses what it adds. + /// + /// None is noted once a flush of the wallet's records failed for good in + /// this process ([`Self::buffered_writes_lost`]): a request read back + /// since may be one the wallet tracked before the loss, which a read of + /// its entry again finds tracked and stores nothing for, so its record + /// may be in memory only. Those journal records stay for the next + /// process, whose restoration reads their entries again from what is on + /// disk. #[cfg(feature = "shielded")] pub(crate) fn note_read_back_once_durable( &self, @@ -286,6 +299,14 @@ impl WalletPersister { if pending.is_empty() { return; } + if self.buffered_writes_lost() { + tracing::warn!( + dropped = pending.len(), + "private requests read back after this wallet's records were lost are not \ + noted; their journal records stay for the next process" + ); + return; + } if !self.store_commits_inline() { if let Err(error) = self.flush() { if error.is_transient() { @@ -540,13 +561,31 @@ impl WalletPersister { /// Whether a flush of this wallet's records failed for good in this /// process (anything but a transient failure, which keeps the buffer): /// what the wallet tracked before it may not be on disk, though it is in - /// memory. A restoration then leaves the journal records it finds settled - /// by tracked requests for the next process to discharge. + /// memory. For the rest of the process no journal record is discharged on + /// the strength of a tracked request: no read-back is noted + /// ([`Self::note_read_back_once_durable`]), and a restoration leaves the + /// records it finds settled for the next process. #[cfg(feature = "shielded")] pub(crate) fn buffered_writes_lost(&self) -> bool { self.buffered_writes_lost.load(Ordering::SeqCst) } + /// Note that a page this wallet read proved DashPay's private contact + /// request store held `size` entries. + pub(crate) fn note_private_store_size_proved(&self, size: u64) { + self.private_store_size_proved + .fetch_max(size, Ordering::SeqCst); + } + + /// The largest size of DashPay's private contact request store a page + /// this wallet read proved, in this process, 0 before any. The store only + /// grows and every size is read from a proof, so the store holds at least + /// this many entries from then on: a read reaches its end only once its + /// cursor reaches it, whatever a later page from a node behind shows. + pub(crate) fn private_store_size_proved(&self) -> u64 { + self.private_store_size_proved.load(Ordering::SeqCst) + } + pub(crate) fn store_commits_inline(&self) -> bool { self.inner.store_commits_inline() } diff --git a/packages/rs-platform-wallet/src/wallet/platform_wallet.rs b/packages/rs-platform-wallet/src/wallet/platform_wallet.rs index 48ada321060..51b978a3dcf 100644 --- a/packages/rs-platform-wallet/src/wallet/platform_wallet.rs +++ b/packages/rs-platform-wallet/src/wallet/platform_wallet.rs @@ -1884,13 +1884,20 @@ impl PlatformWallet { /// 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 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. + /// Run also by [`PlatformWalletManager::configure_shielded`] for the + /// wallets the manager holds, by the registration or load of a wallet once + /// the store is configured, and once by a contact request refused as not + /// restored: no keys are needed. Once it succeeds, the wallet's contact + /// requests go ahead, which are held + /// ([`PlatformWalletError::PrivateContactPaymentsNotRestored`]) for a wallet + /// published with no shielded store configured while its persister keeps + /// the private scan state, one loaded after binding a shielded account, one + /// whose last restoration failed or waits for a busy store, and an identity + /// whose position record, its own or one its persister kept after its + /// wallet's removal, marks a private payment that may be unresolved. A + /// payment whose request its sender does not track rewinds the sender's + /// read of the store to where it was when it paid, so the entry is read + /// again. /// /// [`PlatformWalletManager::configure_shielded`]: crate::PlatformWalletManager::configure_shielded #[cfg(feature = "shielded")] @@ -2815,6 +2822,7 @@ impl PlatformWallet { wallets: _, #[cfg(feature = "shielded")] shielded: _, + retained_private_contact_scan_states: _, } = self.load_persisted()?; if let Some(persisted) = platform_addresses.remove(&self.wallet_id) { @@ -3342,6 +3350,7 @@ where Identifier::from(record.payment.recipient), record.payment.account_reference, record.commitment, + record.store_position, ) }) .collect(), From 2fd359934482947f49bd3e66aaee5a139a52b1e4 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Tue, 10 Nov 2026 18:41:54 +0700 Subject: [PATCH 241/269] fix(platform-wallet-ffi): read the private contact readiness outside the registry guard and report an unknown identity as 7 The readiness getter took the wallet's identity view under the handle registry's read guard and waited on the wallet inside it, which could stall platform_wallet_destroy; it now clones the view out first, as the other DashPay reads do. Its doc says it applies discharges first, as the FFI branch words it. PlatformWalletError::IdentityNotFound maps to ErrorIdentityNotFound (7) in the blanket conversion instead of the catch-all, and the code 72 docs name the marker a backend kept after a wallet's removal. Co-Authored-By: Claude Opus 5.5 --- .../ERROR_CODE_REGISTRY.md | 2 +- .../src/contact_request.rs | 36 ++++++++++++------- packages/rs-platform-wallet-ffi/src/error.rs | 22 +++++++++++- 3 files changed, 45 insertions(+), 15 deletions(-) diff --git a/packages/rs-platform-wallet-ffi/ERROR_CODE_REGISTRY.md b/packages/rs-platform-wallet-ffi/ERROR_CODE_REGISTRY.md index 877ce13fa36..1ab4ec40e4d 100644 --- a/packages/rs-platform-wallet-ffi/ERROR_CODE_REGISTRY.md +++ b/packages/rs-platform-wallet-ffi/ERROR_CODE_REGISTRY.md @@ -187,7 +187,7 @@ Fork-era numbers remain in the collision history, which is immutable record. | 69 | `ErrorShieldedBuildRequired` | DashPay private contact requests | Proposed. The operation needs the shielded pool and the library was built without the `shielded` feature: accepting in kind a request that came privately, whose 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. No export in this tree returns it yet: the in-kind accept export, and the Swift and Kotlin typing, are added by the FFI PR (#5812) and the Kotlin PR (#5813) | | 70 | `ErrorProtocolVersionNotActive` | DashPay private contact requests | Proposed. The SDK refused a request without sending it (`dash_sdk::Error::ProtocolVersionNotActive`, not pinned) because the network is proven at a protocol version below the one that serves 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. An SDK pinned below the required version has its own code, 75. 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 and Kotlin typing is added by the FFI PR (#5812) and the Kotlin PR (#5813) | | 71 | `ErrorPrivateContactRequestPending` | DashPay private contact requests | 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 a private payment the shielded store's journal keeps unresolved holds the pair. Nothing new was paid; a later private contact request sync settles it once the entry is read back, and the shielded sync once the payment provably never executes. Pending, not failed: the host must not submit it again. A private store read that did not finish before the request was decided is 74, not 71. The shielded spend contract (`map_spend_result`) keeps the code rather than flattening it to `ErrorWalletOperation`. Swift and Kotlin typing is added by the FFI PR (#5812) and the Kotlin PR (#5813) | -| 72 | `ErrorPrivateContactPaymentsNotRestored` | DashPay private contact requests | Proposed. Maps `PlatformWalletError::PrivateContactPaymentsNotRestored`: wallet readiness, not one contact's pending request (71). The wallet's unconfirmed private payments may hold pairs no reservation shows yet, which only a restoration from the shielded store's journal shows. In a build with the `shielded` feature: the wallet was published (created or loaded) while no shielded store was configured, its persister declaring the private scan state (bit 15), which holds a wallet removed and registered again from the same seed, in this process or a new one; it was loaded after binding a shielded account in an earlier process; or its last restoration failed, or waits for a busy store. In every build: the identity was loaded with its private scan state marking a private payment that may be unresolved. Contact requests and acceptances, public and private, are refused with nothing read, signed or broadcast until they are restored: configuring the shielded store restores them with no keys, as binding the wallet's shielded account does, and a contact request first retries a restoration once without waiting on the store; a private sync does not, and a build without the `shielded` feature never does. A host whose persister declares the private scan state configures the shielded store when it activates the manager, before loading or creating wallets: until it does, and while a failed configure is not retried, 72 holds. `platform_wallet_get_private_contact_readiness` reads whether the payments are restored and whether the last restoration failed (`private_payments_restore_failed`; the wallet's `PrivateContactReadiness::private_payments_restore_failure` holds the reason). Swift and Kotlin typing is added by the FFI PR (#5812) and the Kotlin PR (#5813); hosts treat it as held until the readiness reads restored | +| 72 | `ErrorPrivateContactPaymentsNotRestored` | DashPay private contact requests | Proposed. Maps `PlatformWalletError::PrivateContactPaymentsNotRestored`: wallet readiness, not one contact's pending request (71). The wallet's unconfirmed private payments may hold pairs no reservation shows yet, which only a restoration from the shielded store's journal shows. In a build with the `shielded` feature: the wallet was published (created or loaded) while no shielded store was configured, its persister declaring the private scan state (bit 15), which holds a wallet removed and registered again from the same seed, in this process or a new one; it was loaded after binding a shielded account in an earlier process; or its last restoration failed, or waits for a busy store. In every build: the identity was loaded with its private scan state marking a private payment that may be unresolved, or was added to a wallet whose backend kept its record marked after an earlier wallet under the id was removed (`ClientStartState::retained_private_contact_scan_states`). Contact requests and acceptances, public and private, are refused with nothing read, signed or broadcast until they are restored: configuring the shielded store restores them with no keys, as binding the wallet's shielded account does, and a contact request first retries a restoration once without waiting on the store; a private sync does not, and a build without the `shielded` feature never does. A host whose persister declares the private scan state configures the shielded store when it activates the manager, before loading or creating wallets: until it does, and while a failed configure is not retried, 72 holds. `platform_wallet_get_private_contact_readiness` reads whether the payments are restored and whether the last restoration failed (`private_payments_restore_failed`; the wallet's `PrivateContactReadiness::private_payments_restore_failure` holds the reason). Swift and Kotlin typing is added by the FFI PR (#5812) and the Kotlin PR (#5813); hosts treat it as held until the readiness reads restored | | 73 | `ErrorPrivateContactRecoveryUnsupported` | DashPay private contact requests | Proposed. Maps `PlatformWalletError::PrivateContactRecoveryUnsupported`: the persister does not declare `PRIVATE_CONTACT_REQUEST_SCAN_STATE` (bit 15) 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. The FFI constant for bit 15 and the Swift and Kotlin typing are added by the FFI PR (#5812) and the Kotlin PR (#5813) | | 74 | `ErrorPrivateContactStoreNotRead` | DashPay private contact requests | Proposed. Maps `PlatformWalletError::PrivateContactStoreNotRead`: DashPay's private contact request store was not read to its end for the identity, with the keys Platform proves it holds, before a contact request or acceptance, public or private, was decided (every one reads it from the identity's cursor first). Nothing was reserved, sealed, paid or broadcast; calling again continues the read from where it stopped. A node behind the store already seen, or keys that kept changing, stop it. It was reported as 71 before, which says not to submit again. Mapped by the blanket conversion and kept by the shielded spend contract (`map_spend_result`). Swift and Kotlin typing is added by the FFI PR (#5812) and the Kotlin PR (#5813) | | 75 | `ErrorSdkPinnedBelowProtocolVersion` | DashPay private contact requests | Proposed. The SDK refused a request without sending it because the SDK itself is pinned below the protocol version the request needs (`dash_sdk::Error::ProtocolVersionNotActive` with `pinned`), whatever the network runs: for example every contact request's private store read with the SDK pinned below protocol version 15 on a network at 15. A network upgrade does not lift it; only raising or removing the pin does. Before, it shared 70 with a network proven below the version, which lifts by itself. Mapped by the blanket conversion. Swift and Kotlin typing is added by the FFI PR (#5812) and the Kotlin PR (#5813) | diff --git a/packages/rs-platform-wallet-ffi/src/contact_request.rs b/packages/rs-platform-wallet-ffi/src/contact_request.rs index b7d819fcedd..c61c43577ee 100644 --- a/packages/rs-platform-wallet-ffi/src/contact_request.rs +++ b/packages/rs-platform-wallet-ffi/src/contact_request.rs @@ -274,7 +274,12 @@ impl From for PrivateContactReadinessFFI { /// 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. +/// request by the wallet's own state. Not a pure read: a journal record +/// discharged since (a payment proven never to execute) is applied first, +/// briefly taking the wallet's write lock, as a send would see it; then it +/// reads live wallet state. Why a failed restoration failed is +/// `platform_wallet_get_private_payments_restore_failure`; one pair's hold is +/// `platform_wallet_get_private_pair_held`. /// /// `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 @@ -292,17 +297,19 @@ pub unsafe extern "C" fn platform_wallet_get_private_contact_readiness( 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); + // Look the identity up under the registry guard, but wait outside it: the + // read can wait on the wallet's write lock, and a registry read guard held + // across that wait would stall `platform_wallet_destroy` (a registry + // write) and every registry reader queued behind it. + let option = + PLATFORM_WALLET_STORAGE.with_item(wallet_handle, |wallet| wallet.identity().clone()); + let identity = unwrap_option_or_return!(option); + let readiness = unwrap_result_or_return!(block_on_worker(async move { + identity + .dashpay() + .private_contact_readiness(&identity_id) + .await + })); unsafe { *out_readiness = readiness.into() }; PlatformWalletFFIResult::ok() } @@ -546,7 +553,10 @@ mod tests { &mut readiness, ) }; - assert_ne!(stranger.code, PlatformWalletFFIResultCode::Success); + assert_eq!( + stranger.code, + PlatformWalletFFIResultCode::ErrorIdentityNotFound + ); assert_eq!(readiness, PrivateContactReadinessFFI::default(), "reset"); PLATFORM_WALLET_STORAGE.remove(handle); } diff --git a/packages/rs-platform-wallet-ffi/src/error.rs b/packages/rs-platform-wallet-ffi/src/error.rs index ea9f145388b..197d4ebbf8c 100644 --- a/packages/rs-platform-wallet-ffi/src/error.rs +++ b/packages/rs-platform-wallet-ffi/src/error.rs @@ -695,7 +695,9 @@ pub enum PlatformWalletFFIResultCode { /// after binding a shielded account in an earlier process; or its last /// restoration failed, or waits for a busy store. In every build: the /// identity was loaded with its private scan state marking a private - /// payment that may be unresolved. Configuring the shielded store restores + /// payment that may be unresolved, or was added to a wallet whose backend + /// kept its record marked after an earlier wallet under the id was + /// removed. Configuring the shielded store restores /// them with no keys, as binding the wallet's shielded account does, and a /// contact request retries a failed restoration once without waiting; a /// private sync does not. A host whose persister declares the private @@ -1013,6 +1015,11 @@ impl From for PlatformWalletFFIResult { PlatformWalletError::WalletAlreadyExists(..) => { PlatformWalletFFIResultCode::ErrorWalletAlreadyExists } + // An identity the wallet does not hold, as a read or a contact + // request names one: its own code, which Swift and Kotlin type. + PlatformWalletError::IdentityNotFound(..) => { + PlatformWalletFFIResultCode::ErrorIdentityNotFound + } // The two shielded broadcast/wait variants. Today nothing routes // them through this blanket impl — the dedicated match in // `platform_wallet_manager_shielded_identity_create_from_pool` @@ -3109,4 +3116,17 @@ mod private_contact_request_error_tests { ); } } + + /// An identity the wallet does not hold reaches the host by its own code, + /// not the catch-all. + #[test] + fn should_report_an_identity_the_wallet_does_not_hold_by_its_own_code() { + let result = + PlatformWalletFFIResult::from(PlatformWalletError::IdentityNotFound([7; 32].into())); + assert_eq!( + result.code, + PlatformWalletFFIResultCode::ErrorIdentityNotFound + ); + assert_eq!(result.code as u32, 7); + } } From 034b73ddbe006bcee703a3e86e347247ac9fd509 Mon Sep 17 00:00:00 2001 From: QuantumExplorer Date: Tue, 10 Nov 2026 19:23:54 +0700 Subject: [PATCH 242/269] fix(platform-wallet): save shielded spend records before broadcast and report unproved outcomes as unconfirmed (#5837) Co-authored-by: Claude Fable 5.2 --- .../IdentityRegistrationController.kt | 21 +- .../services/RegistrationCoordinator.kt | 6 +- .../ui/wallet/SendTransactionScreen.kt | 9 +- .../services/RegistrationCoordinatorTest.kt | 8 +- .../dashsdk/errors/DashSdkError.kt | 37 +- .../dashsdk/wallet/PlatformWalletManager.kt | 9 +- .../dashsdk/errors/DashSdkErrorTest.kt | 2 +- .../ERROR_CODE_REGISTRY.md | 8 +- packages/rs-platform-wallet-ffi/src/error.rs | 92 +- .../src/masternode_withdrawal.rs | 13 +- .../src/shielded_send.rs | 32 +- packages/rs-platform-wallet/Cargo.toml | 5 +- .../src/broadcast_outcome.rs | 360 ++- packages/rs-platform-wallet/src/error.rs | 54 +- .../src/wallet/masternode_withdrawal.rs | 337 +- .../src/wallet/platform_wallet.rs | 5 + .../src/wallet/shielded/operations.rs | 2801 ++++++++++++----- .../src/wallet/shielded/store.rs | 47 +- .../src/platform/transition/broadcast.rs | 8 +- .../PlatformWalletManagerMasternodes.swift | 9 +- .../PlatformWalletManagerShieldedSync.swift | 94 +- .../PlatformWallet/PlatformWalletResult.swift | 107 +- .../Core/ViewModels/SendViewModel.swift | 10 +- .../Core/Views/MasternodeDetailView.swift | 8 +- .../IdentityRegistrationController.swift | 54 +- .../Views/CreateIdentityView.swift | 18 +- .../Views/RegistrationProgressView.swift | 35 +- .../CreateIdentityResumableTests.swift | 2 +- 28 files changed, 2986 insertions(+), 1205 deletions(-) diff --git a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/services/IdentityRegistrationController.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/services/IdentityRegistrationController.kt index d5c38601053..184bc463eae 100644 --- a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/services/IdentityRegistrationController.kt +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/services/IdentityRegistrationController.kt @@ -65,10 +65,10 @@ class IdentityRegistrationController( data class Failed(val message: String) : Phase /** - * Broadcast-succeeded-but-confirmation-failed terminal state - * (shielded only). The identity at [identityId] MAY already be live; - * the slot must stay held so a re-submission can't burn funds against - * the registered-key-hash check. + * Unconfirmed terminal state (shielded only): the transition may have + * reached a node and its result is not proved. The identity at + * [identityId] MAY already be live; the slot must stay held so a + * re-submission can't burn funds against the registered-key-hash check. */ data class Unconfirmed(val identityId: ByteArray, val message: String) : Phase { override fun equals(other: Any?): Boolean = @@ -90,12 +90,13 @@ class IdentityRegistrationController( /** * Which stage a shielded [Phase.Failed] failed at, so the progress - * screen can attribute the red marker. Only [BroadcastRejected] is - * attributable with confidence; every other failure leaves this null and - * the progress screen falls back to its elapsed-time heuristic. + * screen can attribute the red marker. Only [BroadcastNotSent] (the + * transition was refused before anything was sent) is attributable with + * confidence; every other failure leaves this null and the progress + * screen falls back to its elapsed-time heuristic. * ← Swift `FailureStage`. */ - enum class FailureStage { BroadcastRejected } + enum class FailureStage { BroadcastNotSent } private val _phase = MutableStateFlow(Phase.Idle) val phase: StateFlow = _phase.asStateFlow() @@ -136,7 +137,7 @@ class IdentityRegistrationController( fun submit( body: suspend () -> ByteArray, isUnconfirmed: (Throwable) -> ByteArray? = { null }, - isBroadcastRejected: (Throwable) -> Boolean = { false }, + isBroadcastNotSent: (Throwable) -> Boolean = { false }, ) { when (_phase.value) { is Phase.Idle, is Phase.PreparingKeys, is Phase.Failed -> Unit @@ -157,7 +158,7 @@ class IdentityRegistrationController( terminalAt = now() _phase.value = Phase.Unconfirmed(unconfirmedId, e.message ?: "Confirmation pending") } else { - _failureStage.value = if (isBroadcastRejected(e)) FailureStage.BroadcastRejected else null + _failureStage.value = if (isBroadcastNotSent(e)) FailureStage.BroadcastNotSent else null terminalAt = now() _phase.value = Phase.Failed(e.message ?: "Registration failed") } diff --git a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/services/RegistrationCoordinator.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/services/RegistrationCoordinator.kt index 553f3b0967a..69c590ef517 100644 --- a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/services/RegistrationCoordinator.kt +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/services/RegistrationCoordinator.kt @@ -73,7 +73,7 @@ class RegistrationCoordinator( fundingKind: IdentityRegistrationController.FundingKind = IdentityRegistrationController.FundingKind.AssetLock, body: suspend () -> ByteArray, isUnconfirmed: (Throwable) -> ByteArray? = { null }, - isBroadcastRejected: (Throwable) -> Boolean = { false }, + isBroadcastNotSent: (Throwable) -> Boolean = { false }, ): IdentityRegistrationController { val key = SlotKey(walletId.toHex(), identityIndex) val existing = _controllers.value[key] @@ -89,7 +89,7 @@ class RegistrationCoordinator( -> { // Legitimate restart: a fresh idle controller, or a retry after failure. existing.enterPreparingKeys() - existing.submit(body, isUnconfirmed, isBroadcastRejected) + existing.submit(body, isUnconfirmed, isBroadcastNotSent) scheduleRetentionSweep(key, existing) return existing } @@ -98,7 +98,7 @@ class RegistrationCoordinator( val controller = IdentityRegistrationController(walletId, identityIndex, fundingKind, scope, now) _controllers.update { it + (key to controller) } controller.enterPreparingKeys() - controller.submit(body, isUnconfirmed, isBroadcastRejected) + controller.submit(body, isUnconfirmed, isBroadcastNotSent) scheduleRetentionSweep(key, controller) return controller } diff --git a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/wallet/SendTransactionScreen.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/wallet/SendTransactionScreen.kt index 5656d273640..783128fef29 100644 --- a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/wallet/SendTransactionScreen.kt +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/wallet/SendTransactionScreen.kt @@ -730,10 +730,11 @@ fun SendTransactionScreen( } } } catch (e: DashSdkError.PlatformWallet.ShieldedSpendUnconfirmed) { - // Broadcast accepted but its execution result couldn't - // be confirmed — the spend may already be on chain and - // the notes stay reserved Rust-side, so this must NOT - // read as a retryable failure (← SendViewModel.swift:790). + // It may have reached a node and its result is not + // proved (a node's refusal or verdict included), so it + // may already be on chain and a retry signs a second + // transition: this must NOT read as a retryable + // failure (← SendViewModel.swift:790). successMessage = "Transaction may have gone through — waiting for " + "the next shielded sync to confirm. Do not retry." } catch (t: Throwable) { diff --git a/packages/kotlin-sdk/KotlinExampleApp/app/src/test/java/org/dashfoundation/example/services/RegistrationCoordinatorTest.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/test/java/org/dashfoundation/example/services/RegistrationCoordinatorTest.kt index ccb9e56868f..d4d86bacbfd 100644 --- a/packages/kotlin-sdk/KotlinExampleApp/app/src/test/java/org/dashfoundation/example/services/RegistrationCoordinatorTest.kt +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/test/java/org/dashfoundation/example/services/RegistrationCoordinatorTest.kt @@ -110,7 +110,7 @@ class RegistrationCoordinatorTest { } @Test - fun `broadcast-rejected classifier sets failure stage`() = runTest { + fun `broadcast-not-sent classifier sets failure stage`() = runTest { val coordinator = coordinator() val gate = CompletableDeferred() @@ -119,14 +119,14 @@ class RegistrationCoordinatorTest { identityIndex = 0, fundingKind = IdentityRegistrationController.FundingKind.ShieldedPool, body = { gate.await() }, - isBroadcastRejected = { true }, + isBroadcastNotSent = { true }, ) - gate.completeExceptionally(RuntimeException("broadcast rejected")) + gate.completeExceptionally(RuntimeException("nothing was sent")) advanceUntilIdle() assertTrue(controller.phase.value is Phase.Failed) assertEquals( - IdentityRegistrationController.FailureStage.BroadcastRejected, + IdentityRegistrationController.FailureStage.BroadcastNotSent, controller.failureStage.value, ) } diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/errors/DashSdkError.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/errors/DashSdkError.kt index 02cf721a115..e008d027cde 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/errors/DashSdkError.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/errors/DashSdkError.kt @@ -267,31 +267,32 @@ sealed class DashSdkError( /** * `ErrorShieldedSpendUnconfirmed` (native code 18). A shielded spend - * (transfer / unshield / withdraw / shield) was broadcast and - * accepted, but its execution result couldn't be confirmed — it may - * already be on chain. Rust intentionally KEEPS the spent notes' - * reservations, so the caller must NOT retry (a retry would rebuild - * the bundle against other notes and could double-spend); the next - * shielded sync reconciles the outcome. The Android analog of + * (transfer / unshield / withdraw / shield from Platform addresses) + * may have reached a node and its result is not proved (a failed + * result wait, a node's refusal or a consensus verdict, stale address + * nonce included), so it may already be on chain. Do NOT retry: a + * retry signs a second transition. Spends keep their notes reserved + * for the wallet to resolve; a shield stays pending until the + * shielded scan observes its note. The Android analog of * Swift's `PlatformWalletError.shieldedSpendUnconfirmed`, which the * iOS send flow surfaces as a non-retryable "may have gone through" * outcome rather than an error. */ class ShieldedSpendUnconfirmed(message: String, cause: Throwable? = null) : PlatformWallet( - "$message (do NOT retry: the spend may already be on chain; " + - "the wallet keeps the spent notes reserved until the next " + - "shielded sync reconciles the outcome)", + "$message (do NOT retry: it may already be on chain or may " + + "still execute, and a retry signs a second transition)", cause, ) /** * `ErrorMasternodeWithdrawalUnconfirmed` (native code 42). A - * masternode (evonode) identity credit withdrawal was broadcast and - * accepted, but its execution result couldn't be confirmed — it may - * already have executed, and the identity nonce was consumed for it, - * so a blind retry could submit a SECOND withdrawal. Do NOT retry; - * re-read the identity's claimable balance and reconcile first. The + * masternode (evonode) identity credit withdrawal may have reached a + * node and its remaining balance is not proved (a failed result wait, + * a node's refusal or a consensus verdict included). It may have + * executed; a retry signs a second withdrawal with the next identity + * nonce. Do NOT retry; re-read the claimable balance first. Only a + * failure before anything was sent keeps an ordinary code. The * Android analog of Swift's * `PlatformWalletError.masternodeWithdrawalUnconfirmed`. */ @@ -304,10 +305,10 @@ sealed class DashSdkError( /** * `ErrorShieldedBroadcastFailed` (native code 16). A DEFINITIVE - * non-execution outcome — relay/CheckTx rejected the transaction - * before it entered the chain, and any note reservations were - * released. Safe to retry (the opposite of the ambiguous - * [ShieldedSpendUnconfirmed]). + * non-execution outcome: the transition never left the wallet + * (refused before anything was sent), so it can never execute, and + * any note reservations were released. Safe to retry (the opposite + * of the ambiguous [ShieldedSpendUnconfirmed]). */ class ShieldedBroadcastFailed(message: String, cause: Throwable? = null) : PlatformWallet(message, cause) { diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/PlatformWalletManager.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/PlatformWalletManager.kt index 5f074a73d5b..325504110da 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/PlatformWalletManager.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/PlatformWalletManager.kt @@ -2010,10 +2010,11 @@ class PlatformWalletManager( // (cached by [bindShielded]), so no signer or resolver handle crosses // JNI — matching the Swift counterparts, which take no signer either. // Each blocks for the ~30s Halo 2 proof on Dispatchers.IO. On the - // AMBIGUOUS outcome (broadcast accepted, execution unconfirmed) they - // throw [org.dashfoundation.dashsdk.errors.DashSdkError.PlatformWallet.ShieldedSpendUnconfirmed] - // — the caller must NOT retry (the spent notes stay reserved Rust-side; - // the next shielded sync reconciles the outcome). + // AMBIGUOUS outcome (the spend may have reached a node and its result is + // not proved, a node's refusal or verdict included) they + // throw [org.dashfoundation.dashsdk.errors.DashSdkError.PlatformWallet.ShieldedSpendUnconfirmed]: + // the caller must NOT retry (the spent notes stay reserved Rust-side + // until a later shielded sync resolves them). /** * Shielded → shielded transfer (Type 16) — port of Swift's diff --git a/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/errors/DashSdkErrorTest.kt b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/errors/DashSdkErrorTest.kt index 9614b42fa2f..2f97310a44c 100644 --- a/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/errors/DashSdkErrorTest.kt +++ b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/errors/DashSdkErrorTest.kt @@ -132,7 +132,7 @@ class DashSdkErrorTest { DashSdkError.fromNative(DashSDKException(offset + 18, "ambiguous spend")) assertTrue(spendUnconfirmed is DashSdkError.PlatformWallet.ShieldedSpendUnconfirmed) assertFalse( - "ShieldedSpendUnconfirmed must NOT be retryable (notes stay reserved)", + "ShieldedSpendUnconfirmed must NOT be retryable (a retry signs a second transition)", spendUnconfirmed.isRetryable, ) // The message must warn against retrying, like the broadcast sibling. diff --git a/packages/rs-platform-wallet-ffi/ERROR_CODE_REGISTRY.md b/packages/rs-platform-wallet-ffi/ERROR_CODE_REGISTRY.md index 1ab4ec40e4d..891fe70df54 100644 --- a/packages/rs-platform-wallet-ffi/ERROR_CODE_REGISTRY.md +++ b/packages/rs-platform-wallet-ffi/ERROR_CODE_REGISTRY.md @@ -87,12 +87,12 @@ These are shipped ABI. Do not renumber. | 13 | `ErrorArithmeticOverflow` | Produced in-tree by `shielded_send.rs` (the shielded-send amount/fee overflow guard). Its rustdoc was corrected by #4360 (`e0b8baa850`), which documents the `PlatformWalletError::InputSumOverflow` mapping — the stale no-producer comment this row used to track is gone | | 14 | `ErrorNoSelectableInputs` | | | 15 | `ErrorWalletAlreadyExists` | | -| 16 | `ErrorShieldedBroadcastFailed` | For a shielded spend that consumes notes, since #5810: only a transition refused before anything was sent (no address to send it to, or the SDK's own refusal), its notes released. An endpoint's verdict (CheckTx or execution, which no proof backs) and every transport failure after a dispatch, which note spends reported here before, now report 18, or 17 for an identity created from the pool. A shield into the pool still reports a verdict here | +| 16 | `ErrorShieldedBroadcastFailed` | Only a transition refused before anything was sent (no address to send it to, or the SDK's own refusal), its notes released. For a shielded spend that consumes notes, since #5810: an endpoint's verdict (CheckTx or execution, which no proof backs) and every transport failure after a dispatch, which note spends reported here before, now report 18, or 17 for an identity created from the pool. A shield from Platform addresses no longer reports an endpoint's refusal or verdict here either: that is 18 | | 17 | `ErrorShieldedBroadcastUnconfirmed` | | -| 18 | `ErrorShieldedSpendUnconfirmed` | Also, since #5810, what note spends (shielded transfers, unshields, withdrawals, identity top-ups and private document creations from the pool) reported as 16 before: an endpoint's verdict and any transport failure after a dispatch, as the transition may have been delivered. Also `ShieldedBroadcastMaybeDelivered`. The notes and the redrive record are kept; a host must not pay again with other notes | +| 18 | `ErrorShieldedSpendUnconfirmed` | Also, since #5810, what note spends (shielded transfers, unshields, withdrawals, identity top-ups and private document creations from the pool) reported as 16 before: an endpoint's verdict and any transport failure after a dispatch, as the transition may have been delivered. Also `ShieldedBroadcastMaybeDelivered`. The notes and the redrive record are kept, the record saved before the broadcast and the reservation restored from it after a restart; a host must not pay again with other notes. Also what a shield from Platform addresses returns once a dispatch may have reached a node and its result is not proved, an endpoint's refusal or consensus verdict included | | 19 | `ErrorShieldedNoRecordedAnchor` | | | 20 | `ErrorTransactionBroadcastUnconfirmed` | | -| 21 | `ErrorAddressNonceMismatch` | | +| 21 | `ErrorAddressNonceMismatch` | No longer returned by a shield from Platform addresses: an endpoint's nonce verdict on a dispatched shield is 18. Transfers and withdrawals from Platform addresses, and identity registrations and top-ups from them, still return it | | 22 | `ErrorCoreInsufficientFunds` | | | 23 | `ErrorAssetLockNotTracked` | | | 24 | `ErrorAssetLockAlreadyConsumed` | | @@ -109,7 +109,7 @@ These are shipped ABI. Do not renumber. | 39 | `ErrorInsufficientIdentityCredits` | Merged 2026-08-09 by **#4348** (`6373e00f0c`) | | 40 | `ErrorContestedNameNotTradable` | Merged 2026-08-09 by **#4348** (`6373e00f0c`) | | 41 | `ErrorShieldedInsufficientBalance` | Merged 2026-08-11 by **#4360** (`e0b8baa850`) | -| 42 | `ErrorMasternodeWithdrawalUnconfirmed` | Merged 2026-08-22 by **#4451** (masternode-credit claiming). **Took the number active #4356 had claimed** for `ErrorAssetLockInputConflict` — see the proposed table's 42 note; #4356 renumbers via the frontier | +| 42 | `ErrorMasternodeWithdrawalUnconfirmed` | Merged 2026-08-22 by **#4451** (masternode-credit claiming). **Took the number active #4356 had claimed** for `ErrorAssetLockInputConflict`; see the proposed table's 42 note. #4356 renumbers via the frontier. Returned once a dispatch may have reached a node and the result is not proved, an endpoint's refusal or consensus verdict included; only a withdrawal that never left the wallet keeps an ordinary, retryable code | | 46 | `ErrorMasternodeListUnavailable` | Merged 2026-08-24 by **#4465** (`8dd964277`). Initially minted as 43 (already held by active #4313's `ErrorShieldedInviteAlreadyClaimed` across all three layers) — collision flagged in review and renumbered to the then-frontier same day, Rust and Swift together | | 98 | `NotFound` | Sentinel — `Option` returned as an error | | 99 | `ErrorUnknown` | Sentinel — unmapped/flattened errors | diff --git a/packages/rs-platform-wallet-ffi/src/error.rs b/packages/rs-platform-wallet-ffi/src/error.rs index 197d4ebbf8c..3e9c2b88d6a 100644 --- a/packages/rs-platform-wallet-ffi/src/error.rs +++ b/packages/rs-platform-wallet-ffi/src/error.rs @@ -135,42 +135,51 @@ pub enum PlatformWalletFFIResultCode { /// result message for logging/detail. ErrorWalletAlreadyExists = 15, /// Maps `PlatformWalletError::ShieldedBroadcastFailed`. The shielded - /// transition (identity-create, unshield, transfer, or withdrawal) was - /// 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`] ([`Self::ErrorShieldedBroadcastUnconfirmed`] - /// for an identity create), 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 + /// transition (a shield from Platform addresses, identity-create, + /// identity top-up, unshield, transfer, or withdrawal) was DEFINITIVELY + /// not executed: it never left the wallet, refused before anything was + /// sent (no address to send it to, or the SDK's own refusal). An + /// endpoint's refusal or verdict (CheckTx or execution) proves nothing, + /// as no proof backs it: that is [`Self::ErrorShieldedSpendUnconfirmed`] + /// ([`Self::ErrorShieldedBroadcastUnconfirmed`] for an identity create), + /// as is any broadcast that may have been delivered. Any note + /// reservations were released, with the spend's recovery record, 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, /// Maps `PlatformWalletError::ShieldedBroadcastUnconfirmed`. The broadcast - /// was ACCEPTED by the relay but the SDK could not confirm its execution - /// result (a transient result-proof fetch/verify failure, not a platform - /// rejection), and a direct fetch of the derived id also came back empty. - /// The identity may already exist on chain, so the caller must NOT treat + /// was accepted by the relay, or may have been delivered, but the SDK + /// could not confirm its execution result (a broadcast attempt that failed + /// in transport, an endpoint's refusal or verdict, a result-proof + /// fetch/verify failure, …), and a direct fetch of the derived id also + /// came back empty. The wallet keeps the notes reserved and the create's + /// recovery record, which it saved before the broadcast. The identity may + /// already exist on chain, so the caller must NOT treat /// it as unregistered or re-submit. UNLIKE every other error code, /// `out_identity_id` IS written (the 32-byte derived id) on this code so /// the caller can hold the slot and surface the pending identity. ErrorShieldedBroadcastUnconfirmed = 17, - /// Maps `PlatformWalletError::ShieldedSpendUnconfirmed` (unshield / - /// shielded transfer / shielded withdrawal / identity top-up / private - /// document create from the pool; an identity created from the pool - /// reports [`Self::ErrorShieldedBroadcastUnconfirmed`] (17) instead), 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. + /// Maps `PlatformWalletError::ShieldedSpendUnconfirmed` (shield from + /// Platform addresses or from an identity / unshield / shielded transfer / + /// shielded withdrawal / identity top-up / private document create from + /// the pool; an identity created from the pool reports + /// [`Self::ErrorShieldedBroadcastUnconfirmed`] (17) instead), and + /// `PlatformWalletError::ShieldedBroadcastMaybeDelivered`. + /// The 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, an endpoint's refusal or consensus verdict, + /// a DAPI wait timeout, a result-proof fetch/verify failure, …). It may + /// have executed on chain, or may still execute. For a spend that consumes + /// notes the wallet intentionally KEEPS the notes reserved and its + /// recovery record, saved before the broadcast and restored after a + /// restart: 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. A shield from Platform addresses reserves nothing + /// and keeps no record: its activity row stays pending until the scan + /// sees its note. The host must NOT auto-retry: a retry would select + /// different unreserved notes, or sign a second shield with the inputs' + /// next nonces, and could double-send if the original landed. ErrorShieldedSpendUnconfirmed = 18, /// Maps `PlatformWalletError::ShieldedNoRecordedAnchor`. The wallet could /// not build the spend against any Platform-recorded anchor yet: its local @@ -194,15 +203,16 @@ pub enum PlatformWalletFFIResultCode { /// auto-retry. Shielded sibling: [`Self::ErrorShieldedSpendUnconfirmed`]. ErrorTransactionBroadcastUnconfirmed = 20, /// Maps `PlatformWalletError::AddressNonceMismatch`. Platform rejected an - /// address-funds transition (shield, or identity top-up-from-addresses) + /// address-funds transition (a transfer or withdrawal from Platform + /// addresses, or an identity registration or top-up from them) /// because the submitted address nonce raced Platform's expected next - /// value (a lagging DAPI replica stale read; consensus code 40603). Same - /// definitively-failed / notes-released / safe-to-retry contract as - /// [`Self::ErrorShieldedBroadcastFailed`] — the transition did NOT execute - /// and any note reservations were released (a shield reserves none) — but - /// as its OWN code so hosts can recognize this specific, self-healing + /// value (a lagging DAPI replica stale read; consensus code 40603). Its + /// OWN code so hosts can recognize this specific, self-healing /// failure and retry: the retry re-fetches the address nonce, resolving - /// the mismatch without host intervention. The submitted and Platform- + /// the mismatch without host intervention. A shield from Platform + /// addresses does not report it: an endpoint's nonce verdict on a shield + /// that was dispatched reads as [`Self::ErrorShieldedSpendUnconfirmed`], + /// as no proof backs it. The submitted and Platform- /// expected nonce values travel in the result `message` (the typed /// `Display`); they are not exposed as structured out-fields (that would /// require an ABI-breaking change to `PlatformWalletFFIResult`). @@ -439,13 +449,17 @@ pub enum PlatformWalletFFIResultCode { ErrorShieldedInsufficientBalance = 41, /// Maps `PlatformWalletError::MasternodeWithdrawalUnconfirmed`. A /// masternode (evonode) identity credit withdrawal was broadcast and - /// accepted, but its execution result could not be confirmed — it may - /// already have executed, and the identity nonce was consumed for it, so + /// accepted, or may have been delivered, but its execution result could + /// not be confirmed (a broadcast attempt that failed in transport, an + /// endpoint's refusal or consensus verdict, a wait timeout, …). It may + /// already have executed, or may still execute, and the identity nonce + /// was taken for it, so /// a blind retry could submit a SECOND withdrawal. The host must NOT /// retry; it must re-read the identity's claimable balance (and the - /// payout) and let the user decide from the reconciled state. Definitive - /// rejections (consensus errors, transport verdicts) keep their ordinary - /// codes and stay retryable. Siblings: [`Self::ErrorShieldedSpendUnconfirmed`], + /// payout) and let the user decide from the reconciled state. Only a + /// failure before the broadcast, or a broadcast refused before anything + /// was sent, keeps its ordinary code and stays retryable. Siblings: + /// [`Self::ErrorShieldedSpendUnconfirmed`], /// [`Self::ErrorTransactionBroadcastUnconfirmed`]. ErrorMasternodeWithdrawalUnconfirmed = 42, /// The deterministic masternode list isn't available yet (SPV not diff --git a/packages/rs-platform-wallet-ffi/src/masternode_withdrawal.rs b/packages/rs-platform-wallet-ffi/src/masternode_withdrawal.rs index 4b83446381d..f8e10ecc16f 100644 --- a/packages/rs-platform-wallet-ffi/src/masternode_withdrawal.rs +++ b/packages/rs-platform-wallet-ffi/src/masternode_withdrawal.rs @@ -177,12 +177,13 @@ pub unsafe extern "C" fn platform_wallet_manager_masternode_withdrawal_keys( /// invalid. The resolver is invoked exactly once, at signing time; the /// handle-storage guard is not held across it. /// -/// Outcome codes after the transition leaves the wallet: a definitive -/// rejection returns an ordinary error (retryable); an AMBIGUOUS outcome — -/// broadcast accepted (or its ACK lost) and the result wait failed — returns -/// `ErrorMasternodeWithdrawalUnconfirmed` (42): the claim may have executed -/// and the identity nonce was consumed, so the host must NOT retry until it -/// has re-read the claimable balance. +/// Outcome codes of the broadcast: a transition that never left the wallet +/// (refused before anything was sent) returns an ordinary error (retryable). +/// Once a dispatch may have reached a node, anything short of a proved +/// result, an endpoint's refusal or consensus verdict included, returns +/// `ErrorMasternodeWithdrawalUnconfirmed` (42): the claim may have executed, +/// or may still execute, and the identity nonce was taken for it, so the +/// host must NOT retry until it has re-read the claimable balance. /// /// # Safety /// `wallet_id` / `pro_tx_hash` must point at 32 readable bytes; diff --git a/packages/rs-platform-wallet-ffi/src/shielded_send.rs b/packages/rs-platform-wallet-ffi/src/shielded_send.rs index c67edeafcdc..98aa8046d22 100644 --- a/packages/rs-platform-wallet-ffi/src/shielded_send.rs +++ b/packages/rs-platform-wallet-ffi/src/shielded_send.rs @@ -602,12 +602,13 @@ pub(crate) fn map_spend_result( ) -> PlatformWalletFFIResult { match result { Ok(()) => PlatformWalletFFIResult::ok(), - // Ambiguous: the broadcast was accepted but its execution result - // couldn't be confirmed — the host must NOT re-submit. For the - // spend-based operations the notes stay reserved wallet-side; a - // 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. + // Outcome not known: a dispatch may have reached a node and the + // execution result couldn't be confirmed, so the host must NOT + // re-submit. For the spend-based operations the notes stay reserved + // wallet-side, with the recovery record saved before the broadcast; + // a shield from Platform addresses reserves nothing. Either way the + // next sync reconciles a transition that landed; the typed Display + // already carries the operation name and guidance. // 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. @@ -654,18 +655,19 @@ 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 (for a note - // spend, refused before anything was sent); the notes were released - // and the host may retry. + // Definitive failure: the transition never left the wallet (refused + // before anything was sent); a spend's notes were released and the + // host may retry. 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 - // nonce mismatch — a plain retry re-fetches the nonce. Without this arm - // it would regress to the generic `ErrorWalletOperation` below. + // An address-nonce race keeps its own code, so the host can recognize + // the self-healing nonce mismatch: a plain retry re-fetches the nonce. + // No operation mapped here reports it today: a shield from Platform + // addresses reads an endpoint's nonce verdict on a dispatched shield + // as unconfirmed, above. The arm keeps the code from regressing to the + // generic `ErrorWalletOperation` below if one does. Err(e @ PlatformWalletError::AddressNonceMismatch { .. }) => PlatformWalletFFIResult::err( PlatformWalletFFIResultCode::ErrorAddressNonceMismatch, format!("{operation} failed: {e}"), @@ -769,7 +771,7 @@ const SPEND_PANIC_GUIDANCE: &str = "The spend may or may not have been broadcast /// a definitive failure code: a panic can strike after the notes were reserved and even after the /// transition was broadcast, so the outcome is genuinely ambiguous. That code's contract is /// exactly the conservative one this needs — the host must not auto-retry, the reservation stays -/// in place, and the next nullifier sync (or an app restart) reconciles whether the spend landed. +/// in place, and the next nullifier sync reconciles whether the spend landed. fn catch_spend_panic( operation: &str, body: impl FnOnce() -> PlatformWalletFFIResult, diff --git a/packages/rs-platform-wallet/Cargo.toml b/packages/rs-platform-wallet/Cargo.toml index 7557d6db3ae..4dbe5e97c00 100644 --- a/packages/rs-platform-wallet/Cargo.toml +++ b/packages/rs-platform-wallet/Cargo.toml @@ -114,8 +114,9 @@ static_assertions = "1.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } # `test-util` brings `tokio::time::pause` / `tokio::test(start_paused = true)` # so time-driven manager, startup and SPV runtime logic can be tested -# deterministically without sleeping wall-clock seconds. -tokio = { version = "1", features = ["sync", "rt", "time", "macros", "test-util"] } +# deterministically without sleeping wall-clock seconds. `net` gives the +# broadcast tests a loopback port they hold bound and unlistened. +tokio = { version = "1", features = ["sync", "rt", "time", "macros", "test-util", "net"] } # Re-enable the SDK with mocks feature for test-only mock builders; # the non-test build keeps the leaner default-feature SDK above. dash-sdk = { path = "../rs-sdk", default-features = false, features = ["dashpay-contract", "dpns-contract", "wallet", "mocks"] } diff --git a/packages/rs-platform-wallet/src/broadcast_outcome.rs b/packages/rs-platform-wallet/src/broadcast_outcome.rs index bcb54c1983e..e06b22214cb 100644 --- a/packages/rs-platform-wallet/src/broadcast_outcome.rs +++ b/packages/rs-platform-wallet/src/broadcast_outcome.rs @@ -1,14 +1,17 @@ //! Classifying the outcome of a state-transition broadcast / result wait. //! -//! 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 that consume notes broadcast through +//! Shared by every Platform money-moving path that must keep "never left the +//! wallet, safe to retry" apart from "may have reached a node, may still +//! execute, do not retry". Shielded spends that consume notes, shields from +//! Platform addresses and masternode credit withdrawals 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. +//! refusal: only a refusal before anything was sent reads as failed. The +//! shield from an identity keeps the SDK's own broadcast and reads its final +//! error with [`broadcast_was_refused`], which is no proof and only decides +//! whether it waits for a result: it reports unconfirmed either way. 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: @@ -41,34 +44,37 @@ pub(crate) fn carries_consensus_rejection(err: &dash_sdk::Error) -> bool { } } -/// Whether a failed `broadcast()` call DEFINITIVELY left the transition -/// out of every mempool, so any note reservations may be released and the -/// caller may rebuild and retry: +/// Whether the final error of a failed `broadcast()` call reads as a +/// refusal of the transition, rather than as a call that got no answer: /// /// - a consensus verdict ([`carries_consensus_rejection`]): CheckTx /// evaluated the transition and refused it; /// - a gRPC response whose status code is a server-side rejection or a -/// connection-establishment failure. `Unavailable` is the common shape -/// of a connect-refused/offline attempt — classifying it as definitive -/// keeps the no-network failure's notes immediately re-spendable -/// instead of stranding them until the next restart — and rejection -/// codes (`InvalidArgument`, `ResourceExhausted` = mempool full, …) are -/// verdicts that the tx was refused admission; +/// connection-establishment failure: `Unavailable` is the common shape +/// of a connect-refused/offline attempt, and rejection codes +/// (`InvalidArgument`, `ResourceExhausted` = mempool full, …) say the +/// transition was refused admission; /// - no usable DAPI addresses at all (nothing was ever sent). /// -/// `Unavailable` is NOT an absolute never-delivered guarantee: HTTP/2 -/// stream resets after the request bytes left can surface the same code, -/// and the dapi-client's cross-address retry only retains the LAST +/// This is NOT proof that the transition was left out of every mempool, +/// and it never authorizes a retry, or a release of what the transition +/// spends. An endpoint's answer is unauthenticated, and one that kept or +/// forwarded the signed transition can answer with any verdict. HTTP/2 +/// stream resets after the request bytes left can surface `Unavailable` +/// too, 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 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. +/// later attempt's final error, whatever its code. A transition reported +/// failed while it may still execute invites a second one, so every path +/// whose retry signs a new transition classifies with +/// [`broadcast_tracking_delivery`] instead: shielded spends, shields from +/// Platform addresses (to the wallet's own notes or to a recipient's) and +/// masternode credit withdrawals. The one caller left here is the shield +/// from an identity, which holds a durable guard on the exact signed +/// transition: a refusal only makes it skip the result wait for a proven +/// read of the identity nonce, and it reports unconfirmed either way. /// -/// Everything else leaves the outcome unknown and the caller must fall -/// through to the result wait instead of failing: `AlreadyExists` proves +/// Everything else reads as no answer, and the caller falls through to the +/// result wait: `AlreadyExists` proves /// the tx IS in the mempool or on chain (a lost-ACK attempt was re-sent /// by the dapi-client retry and hit tenderdash's dedupe), and /// timeout/cancellation/no-response shapes (`TimeoutReached`, @@ -76,7 +82,8 @@ pub(crate) fn carries_consensus_rejection(err: &dash_sdk::Error) -> bool { /// `Internal`/`Unknown`/`Aborted`/`DataLoss`, which DAPI also uses for /// its own tenderdash-side failures that can postdate delivery) allow /// the request to have outlived its lost ACK. -pub(crate) fn broadcast_definitely_failed(e: &dash_sdk::Error) -> bool { +#[cfg(feature = "shielded")] +pub(crate) fn broadcast_was_refused(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; @@ -104,14 +111,13 @@ pub(crate) fn broadcast_definitely_failed(e: &dash_sdk::Error) -> bool { dash_sdk::Error::DapiClientError(DapiClientError::NoAvailableAddressesToRetry(t)) => { status_is_verdict(t) } - dash_sdk::Error::NoAvailableAddressesToRetry(inner) => broadcast_definitely_failed(inner), + dash_sdk::Error::NoAvailableAddressesToRetry(inner) => broadcast_was_refused(inner), _ => false, } } -/// What the dispatches of one shielded spend's broadcast proved about its +/// What the dispatches of one transition'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`). @@ -121,7 +127,8 @@ pub(crate) enum BroadcastDelivery { /// 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. + /// unconfirmed outcome keeps what the transition spends held (a shielded + /// spend's notes stay reserved). MaybeDelivered(dash_sdk::Error), /// 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 @@ -133,7 +140,6 @@ pub(crate) enum BroadcastDelivery { /// 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 @@ -142,13 +148,14 @@ pub(crate) fn spend_broadcast_dispatches(sdk: &dash_sdk::Sdk) -> usize { .saturating_add(1) } -/// Broadcast a shielded spend `state_transition` in single dispatches (no +/// Broadcast `state_transition` (a shielded spend, a shield from Platform +/// addresses, a masternode credit withdrawal) in single dispatches (no /// 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. +/// notes, journal and contact stays with its caller. /// /// 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 @@ -157,7 +164,17 @@ pub(crate) fn spend_broadcast_dispatches(sdk: &dash_sdk::Sdk) -> usize { /// 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")] +/// +/// A verdict ends the dispatches, with one exception the SDK's own broadcast +/// makes too +/// ([`is_address_nonce_ahead`](dash_sdk::platform::transition::broadcast::is_address_nonce_ahead)): +/// a node that reads an input address's nonce as ahead of its own state is +/// behind the node the nonce was read from, so the same signed bytes go to +/// the next dispatch's node. A single dispatch gets none of the SDK's +/// failover, and it does not step off the answering node as the SDK's loop +/// does, so a later dispatch can reach that node again and spend one of the +/// budget's dispatches on the same answer. The broadcast is maybe delivered +/// either way. pub(crate) async fn broadcast_tracking_delivery( sdk: &dash_sdk::Sdk, state_transition: &dpp::state_transition::StateTransition, @@ -173,22 +190,26 @@ pub(crate) async fn broadcast_tracking_delivery( }, ..PutSettings::default() }; - track_delivery(spend_broadcast_dispatches(sdk), || { + track_delivery(state_transition, 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, at most -/// `dispatches` of them. -#[cfg(feature = "shielded")] -async fn track_delivery(dispatches: usize, mut dispatch: F) -> BroadcastDelivery +/// The classification [`broadcast_tracking_delivery`] makes of +/// `state_transition`'s broadcast, over the outcomes `dispatch` returns, one +/// call per single dispatch, at most `dispatches` of them. +async fn track_delivery( + state_transition: &dpp::state_transition::StateTransition, + dispatches: usize, + mut dispatch: F, +) -> BroadcastDelivery where F: FnMut() -> Fut, Fut: std::future::Future>, { use dash_sdk::dapi_client::DapiClientError; + use dash_sdk::platform::transition::broadcast::is_address_nonce_ahead; let mut maybe_delivered: Option = None; for _ in 0..dispatches.max(1) { @@ -216,8 +237,11 @@ where None => BroadcastDelivery::NeverDelivered(error), }; } - // An endpoint's verdict ends the dispatches, and proves nothing. - if carries_consensus_rejection(&error) { + // An endpoint's verdict ends the dispatches, and proves nothing. The + // verdict of a node that is behind does not end them: the same bytes + // go to the next dispatch's node. + if carries_consensus_rejection(&error) && !is_address_nonce_ahead(state_transition, &error) + { return BroadcastDelivery::MaybeDelivered(error); } maybe_delivered = Some(error); @@ -231,12 +255,105 @@ where } } -#[cfg(all(test, feature = "shielded"))] +/// Network SDKs for the tests of the flows that broadcast through +/// [`broadcast_tracking_delivery`]. +#[cfg(test)] +pub(crate) mod test_sdks { + 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::prelude::Identifier; + use dpp::version::PlatformVersion; + use std::str::FromStr; + use std::sync::Arc; + + /// A context provider that knows no contract and no quorum key: the + /// tests' requests fail before a proof is verified. + 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) + } + } + + fn sdk(addresses: AddressList) -> dash_sdk::Sdk { + dash_sdk::SdkBuilder::new(addresses) + .with_network(Network::Testnet) + .with_version(PlatformVersion::latest()) + .with_context_provider(NoKeys) + .build() + .expect("a network SDK") + } + + /// A network SDK with no address to send to: every dispatch is refused + /// before anything is sent. + pub(crate) fn sdk_without_addresses() -> dash_sdk::Sdk { + sdk(AddressList::new()) + } + + /// A loopback port that refuses every connection for as long as this is + /// held: bound, so nothing else can take it, and never listened on. + pub(crate) struct RefusingPort { + _bound: tokio::net::TcpSocket, + } + + /// A network SDK whose only node refuses the connection, and the port + /// that does so while it is held: every dispatch fails in transport, the + /// way an offline attempt does. + pub(crate) fn sdk_refusing_connections() -> (dash_sdk::Sdk, RefusingPort) { + let bound = tokio::net::TcpSocket::new_v4().expect("a socket"); + bound + .bind(std::net::SocketAddr::from(([127, 0, 0, 1], 0))) + .expect("a loopback port"); + let address = bound.local_addr().expect("its address"); + ( + sdk(AddressList::from_str(&format!("http://{address}")).expect("an address")), + RefusingPort { _bound: bound }, + ) + } +} + +#[cfg(test)] mod tests { use super::*; use dash_sdk::dapi_client::transport::TransportError; use dash_sdk::dapi_client::DapiClientError; use dash_sdk::dapi_grpc::tonic::{Code, Status}; + use dpp::address_funds::PlatformAddress; + use dpp::consensus::state::address_funds::AddressInvalidNonceError; + use dpp::consensus::state::state_error::StateError; + use dpp::state_transition::address_funds_transfer_transition::v0::AddressFundsTransferTransitionV0; + use dpp::state_transition::identity_credit_withdrawal_transition::v1::IdentityCreditWithdrawalTransitionV1; + use dpp::state_transition::StateTransition; + use std::collections::BTreeMap; use std::sync::Mutex; /// Another error of the same shape as `error`, which is not `Clone`. @@ -289,12 +406,21 @@ mod tests { } /// What [`track_delivery`] makes of dispatches whose outcome `outcome` - /// gives by dispatch number, from 0, and how many dispatches it made. + /// gives by dispatch number, from 0, and how many dispatches it made: + /// of a transition with no address input, which no node can be behind on. async fn tracked( outcome: impl Fn(usize) -> Result<(), dash_sdk::Error>, + ) -> (BroadcastDelivery, usize) { + tracked_for(&withdrawal(), outcome).await + } + + /// [`tracked`], of `transition`. + async fn tracked_for( + transition: &StateTransition, + outcome: impl Fn(usize) -> Result<(), dash_sdk::Error>, ) -> (BroadcastDelivery, usize) { let dispatched = Mutex::new(0usize); - let delivery = track_delivery(BUDGET, || { + let delivery = track_delivery(transition, BUDGET, || { let mut count = dispatched.lock().expect("count"); let result = outcome(*count); *count += 1; @@ -305,6 +431,41 @@ mod tests { (delivery, dispatched) } + /// An identity credit withdrawal, which is broadcast in every build and + /// spends from no Platform address. + fn withdrawal() -> StateTransition { + IdentityCreditWithdrawalTransitionV1 { + amount: 1_000_000, + nonce: 1, + ..Default::default() + } + .into() + } + + /// The Platform address [`address_spend`] spends from, and the nonce it + /// signed. + const SPENT_FROM: PlatformAddress = PlatformAddress::P2pkh([7; 20]); + const SIGNED_NONCE: u32 = 5; + + /// A transition that spends from a Platform address, as a shield does. + fn address_spend() -> StateTransition { + AddressFundsTransferTransitionV0 { + inputs: BTreeMap::from([(SPENT_FROM, (SIGNED_NONCE, 2_000_000))]), + ..Default::default() + } + .into() + } + + /// A node's verdict that `SPENT_FROM` was spent with `SIGNED_NONCE` where + /// the node expects `expected`. + fn nonce_verdict(expected: u32) -> dash_sdk::Error { + dash_sdk::Error::Protocol(dpp::ProtocolError::ConsensusError(Box::new( + dpp::consensus::ConsensusError::StateError(StateError::AddressInvalidNonceError( + AddressInvalidNonceError::new(SPENT_FROM, SIGNED_NONCE, expected), + )), + ))) + } + /// The dispatch budget the tests run with. const BUDGET: usize = 6; @@ -369,10 +530,14 @@ mod tests { 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))) - }) + let delivery = track_delivery( + &withdrawal(), + 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); @@ -454,6 +619,99 @@ mod tests { } } + /// The broadcast of a signed transition through a network SDK: with no + /// address to send it to it never left, and a node that refuses the + /// connection leaves it maybe delivered, as a transport failure cannot + /// show nothing was sent. Here an identity credit withdrawal, which is + /// broadcast in every build. + #[tokio::test] + async fn should_track_the_delivery_of_a_broadcast_through_a_network_sdk() { + let transition = withdrawal(); + + let delivery = + broadcast_tracking_delivery(&test_sdks::sdk_without_addresses(), &transition).await; + assert!( + matches!(delivery, BroadcastDelivery::NeverDelivered(_)), + "{delivery:?}" + ); + let (refusing, _port) = test_sdks::sdk_refusing_connections(); + let delivery = broadcast_tracking_delivery(&refusing, &transition).await; + assert!( + matches!(delivery, BroadcastDelivery::MaybeDelivered(_)), + "{delivery:?}" + ); + } + + /// A node that reads an input address's nonce as ahead of its own state + /// is behind the node the nonce was read from: the same signed + /// transition goes to the next dispatch's node, as the SDK's own + /// broadcast sends it, and a node that admits it ends the dispatches. + #[tokio::test] + async fn should_send_a_transition_a_node_is_behind_on_to_another_node() { + let behind = SIGNED_NONCE - 1; + let (delivery, dispatched) = tracked_for(&address_spend(), |dispatch| match dispatch { + 0 | 1 => Err(nonce_verdict(behind)), + _ => Ok(()), + }) + .await; + assert!( + matches!(delivery, BroadcastDelivery::Admitted), + "{delivery:?}" + ); + assert_eq!(dispatched, 3); + + // Every node behind: the budget ends the dispatches, and the + // transition stays maybe delivered, as every node that answered holds + // its bytes. + let (delivery, dispatched) = + tracked_for(&address_spend(), |_| Err(nonce_verdict(behind))).await; + assert!( + matches!(delivery, BroadcastDelivery::MaybeDelivered(_)), + "{delivery:?}" + ); + assert_eq!(dispatched, BUDGET); + } + + /// Every other verdict ends the dispatches: a nonce the node has already + /// passed, a nonce of an address the transition does not spend from, and + /// the same verdict on a transition that spends from no address. + #[tokio::test] + async fn should_end_the_dispatches_on_a_verdict_no_other_node_changes() { + let (delivery, dispatched) = + tracked_for(&address_spend(), |_| Err(nonce_verdict(SIGNED_NONCE + 1))).await; + assert!( + matches!(delivery, BroadcastDelivery::MaybeDelivered(_)), + "a nonce the node has passed: {delivery:?}" + ); + assert_eq!(dispatched, 1); + + let another_address = || { + dash_sdk::Error::Protocol(dpp::ProtocolError::ConsensusError(Box::new( + dpp::consensus::ConsensusError::StateError(StateError::AddressInvalidNonceError( + AddressInvalidNonceError::new( + PlatformAddress::P2pkh([8; 20]), + SIGNED_NONCE, + SIGNED_NONCE - 1, + ), + )), + ))) + }; + let (delivery, dispatched) = + tracked_for(&address_spend(), |_| Err(another_address())).await; + assert!( + matches!(delivery, BroadcastDelivery::MaybeDelivered(_)), + "another address: {delivery:?}" + ); + assert_eq!(dispatched, 1); + + let (delivery, dispatched) = tracked(|_| Err(nonce_verdict(SIGNED_NONCE - 1))).await; + assert!( + matches!(delivery, BroadcastDelivery::MaybeDelivered(_)), + "no address input: {delivery:?}" + ); + assert_eq!(dispatched, 1); + } + /// A dispatch a node admitted, or already holds, is admitted, after any /// number of failed ones. #[tokio::test] diff --git a/packages/rs-platform-wallet/src/error.rs b/packages/rs-platform-wallet/src/error.rs index 36625e4eb83..7da9bac682f 100644 --- a/packages/rs-platform-wallet/src/error.rs +++ b/packages/rs-platform-wallet/src/error.rs @@ -1052,6 +1052,13 @@ pub enum PlatformWalletError { #[error("Shielded build error: {0}")] ShieldedBuildError(String), + /// A shielded transition (a shield from Platform addresses, or a spend + /// that consumes notes) never left the wallet: its broadcast was refused + /// before anything was sent. It can never execute, so a spend's notes are + /// released, its saved record retired, and the caller may retry. Once a + /// dispatch may have reached a node the outcome is + /// [`Self::ShieldedSpendUnconfirmed`] (or + /// [`Self::ShieldedBroadcastUnconfirmed`]) instead. #[error("Shielded broadcast failed: {0}")] ShieldedBroadcastFailed(String), @@ -1067,11 +1074,13 @@ pub enum PlatformWalletError { #[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 + /// The shielded identity-create transition was broadcast and accepted by the relay, or may + /// have been delivered, but the SDK could not confirm its execution result (a broadcast + /// attempt that failed in transport, an endpoint's refusal or verdict, a result-proof + /// fetch/verify failure). The identity with `identity_id` may /// already exist on chain, so the caller must NOT treat it as unregistered: the slot stays held - /// against re-submission and the spent notes' reservations are left in place (the next nullifier + /// against re-submission and the spent notes' reservations are left in place, with the + /// recovery record saved before the broadcast (the next nullifier /// sync reconciles them). `reason` carries the underlying SDK error for diagnostics. #[error( "Shielded broadcast succeeded but its execution result could not be confirmed; \ @@ -1099,16 +1108,19 @@ pub enum PlatformWalletError { #[error("shielded account {account_index} is required for payment recovery: {reason}; restore its original viewing keys or inspect the unresolved payment before choosing recovery")] ShieldedRecoveryKeysRequired { account_index: u32, reason: String }, - /// A shielded transition (`operation` is `"shield"`, `"unshield"`, `"transfer"` or - /// `"withdraw"`) 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 or timeout, not a platform rejection). The operation may already be executed on - /// chain, so re-submitting risks a double-execution. For the spend-based operations the spent + /// A shielded transition (`operation` names it: `"shield"`, `"unshield"`, `"transfer"`, + /// `"withdraw"`, an identity top-up, a private document create) was broadcast and accepted + /// by the relay, or may have been delivered, but the SDK could not confirm its execution + /// result (a broadcast attempt that failed in transport, an endpoint's refusal or consensus + /// verdict, which no proof backs, a result-proof fetch/verify failure or a timeout). The + /// operation may already be executed on chain, or may still execute, so re-submitting risks + /// a double-execution. For the spend-based operations the spent /// notes' reservations are intentionally left in place rather than released — releasing them - /// would invite re-selecting notes whose nullifiers may already be consumed; a shield spends - /// no notes, so it has nothing reserved. The next sync (or an app restart, since reservations - /// are in-memory only) reconciles the outcome either way. `reason` carries the underlying SDK - /// error for diagnostics. + /// would invite re-selecting notes whose nullifiers may already be consumed. Their recovery + /// record is saved before the broadcast, so a restart restores the reservations from it, and + /// the next sync reconciles the outcome. A shield from Platform addresses spends no notes and + /// keeps no record: its activity row stays pending until the scan sees its note. `reason` + /// carries the underlying SDK error for diagnostics. /// /// The identity-create sibling is [`Self::ShieldedBroadcastUnconfirmed`], which additionally /// carries the derived identity id so the caller can hold the registration slot. @@ -1122,11 +1134,13 @@ pub enum PlatformWalletError { reason: String, }, - /// A masternode (evonode) identity credit withdrawal was **broadcast and - /// accepted**, but its execution result could not be confirmed (the - /// result-proof fetch/verify failed — transient DAPI/proof error or - /// timeout, not a Platform rejection). The claim may already have - /// executed, and the SDK's identity-nonce cache was bumped for it, so + /// A masternode (evonode) identity credit withdrawal was broadcast and + /// accepted, or may have been delivered, but its execution result could + /// not be confirmed (a broadcast attempt that failed in transport, an + /// endpoint's refusal or consensus verdict, which no proof backs, a + /// result-proof fetch/verify failure or a timeout). The claim may already + /// have executed, or may still execute, and the SDK's identity-nonce + /// cache was bumped for it, so /// re-submitting could execute a SECOND withdrawal with the next nonce. /// Callers must NOT retry until they have re-read the identity's /// claimable balance (and the payout) and reconciled the outcome. @@ -1348,7 +1362,7 @@ pub fn is_instant_lock_timeout(error: &PlatformWalletError) -> bool { /// Matches the three `dash_sdk::Error` shapes that can carry a consensus /// verdict — `StateTransitionBroadcastError` (wait-stream), `Protocol( /// ConsensusError)` (CheckTx), and a `NoAvailableAddressesToRetry` envelope it -/// recurses into — staying in lockstep with `broadcast_definitely_failed`. +/// recurses into, staying in lockstep with `carries_consensus_rejection`. pub fn as_address_invalid_nonce(error: &dash_sdk::Error) -> Option<&AddressInvalidNonceError> { use dpp::consensus::state::state_error::StateError; @@ -1858,7 +1872,7 @@ mod address_nonce_tests { fn extracts_nonce_error_wrapped_in_no_available_addresses_to_retry() { // The dapi-client wraps the last rejection in `NoAvailableAddressesToRetry` // when every address is exhausted mid-retry; the extractor must recurse - // into it (lockstep with `broadcast_definitely_failed`). + // into it (lockstep with `carries_consensus_rejection`). let inner = Box::new(protocol_shape(9, 10)); let wrapped = dash_sdk::Error::NoAvailableAddressesToRetry(inner); let got = as_address_invalid_nonce(&wrapped).expect("must unwrap the retry envelope"); diff --git a/packages/rs-platform-wallet/src/wallet/masternode_withdrawal.rs b/packages/rs-platform-wallet/src/wallet/masternode_withdrawal.rs index 32bf9206254..bf96ebddfee 100644 --- a/packages/rs-platform-wallet/src/wallet/masternode_withdrawal.rs +++ b/packages/rs-platform-wallet/src/wallet/masternode_withdrawal.rs @@ -54,9 +54,10 @@ use dpp::state_transition::identity_credit_withdrawal_transition::{ IdentityCreditWithdrawalTransition, MIN_CORE_FEE_PER_BYTE, }; use dpp::state_transition::proof_result::StateTransitionProofResult; +use dpp::state_transition::StateTransition; use dpp::withdrawal::Pooling; -use crate::broadcast_outcome::{broadcast_definitely_failed, carries_consensus_rejection}; +use crate::broadcast_outcome::{broadcast_tracking_delivery, BroadcastDelivery}; use crate::error::{PlatformWalletError, SIGNER_KEY_UNAVAILABLE_PREFIX}; use crate::wallet::core::is_signable_funding_account; use crate::wallet::identity::network::{ @@ -288,14 +289,16 @@ impl PlatformWallet { /// - the identity is missing, or doesn't carry the expected /// `OWNER` / `TRANSFER` key for the wallet's hash160. /// - /// Outcomes after the transition leaves this wallet are kept distinct: - /// a definitive rejection (consensus error, or a transport verdict that - /// rules out delivery) is an ordinary error and may be retried; anything - /// ambiguous — broadcast accepted (or its ACK lost) and the result wait - /// failed — is [`PlatformWalletError::MasternodeWithdrawalUnconfirmed`] - /// and MUST NOT be retried until the claimable balance is re-read: the - /// identity nonce was already consumed for this attempt, so a blind retry - /// would submit a second withdrawal. + /// Outcomes of the broadcast are kept distinct. A transition that never + /// left this wallet (refused before anything was sent) is an ordinary + /// error and may be retried. Once a dispatch may have reached a node, + /// anything short of a proved result, an endpoint's refusal or a + /// consensus verdict included, is + /// [`PlatformWalletError::MasternodeWithdrawalUnconfirmed`] and MUST NOT + /// be retried until the claimable balance is re-read: no proof backs an + /// endpoint's answer, and the identity nonce was already taken for this + /// attempt, so a blind retry would submit a second withdrawal with the + /// next one. pub async fn masternode_withdraw( &self, request: MasternodeWithdrawalRequest, @@ -387,9 +390,11 @@ impl PlatformWallet { /// `expected_hash160`, sign with `signer` at `path` through /// [`DerivedKeyIdentitySigner`] (which re-checks the derived key against /// `expected_hash160` before emitting a signature), broadcast, and wait for -/// the proved balance. Error semantics: definitive rejections stay -/// retryable, ambiguous outcomes are -/// [`PlatformWalletError::MasternodeWithdrawalUnconfirmed`]. +/// the proved balance. Error semantics: a failure before the broadcast, or a +/// broadcast refused before anything was sent, stays retryable; every other +/// outcome short of a proved result is +/// [`PlatformWalletError::MasternodeWithdrawalUnconfirmed`] +/// ([`settle_masternode_withdrawal`]). #[allow(clippy::too_many_arguments)] pub(crate) async fn execute_masternode_withdrawal( sdk: &dash_sdk::Sdk, @@ -481,38 +486,94 @@ pub(crate) async fn execute_masternode_withdrawal .await .map_err(|e| definitive(dash_sdk::Error::Protocol(e)))?; - // Broadcast, then wait — split so an ambiguous outcome stays typed. - let unconfirmed = |reason: String| PlatformWalletError::MasternodeWithdrawalUnconfirmed { - identity_id, - amount_credits, - reason, - }; - match state_transition.broadcast(sdk, None).await { - Ok(()) => {} - Err(e) if broadcast_definitely_failed(&e) => return Err(definitive(e)), - Err(e) => { + broadcast_masternode_withdrawal(sdk, &state_transition, identity_id, amount_credits).await +} + +/// Broadcast the signed masternode withdrawal `state_transition`, keeping +/// what every dispatch proved ([`broadcast_tracking_delivery`]), then wait +/// for its result: split so an outcome that is not known stays typed +/// ([`settle_masternode_withdrawal`]). +async fn broadcast_masternode_withdrawal( + sdk: &dash_sdk::Sdk, + state_transition: &StateTransition, + identity_id: Identifier, + amount_credits: u64, +) -> Result { + let delivery = broadcast_tracking_delivery(sdk, state_transition).await; + settle_masternode_withdrawal(identity_id, amount_credits, delivery, || { + state_transition.wait_for_affected_state::(sdk, None) + }) + .await +} + +/// What a signed masternode withdrawal's broadcast (`delivery`) and result +/// wait (`wait`, run only when a dispatch may have reached a node) came to: +/// the identity's proved remaining balance, or the error the withdrawal +/// reports. +/// +/// Only a transition that never left the wallet +/// ([`BroadcastDelivery::NeverDelivered`]: refused before anything was sent) +/// is an ordinary, retryable error: nothing holds its bytes but the wallet, +/// so it can never execute. Once a dispatch may have reached a node, every +/// outcome short of the proved balance is +/// [`PlatformWalletError::MasternodeWithdrawalUnconfirmed`]: a wait that +/// fails, an endpoint's refusal of the broadcast and a consensus verdict on +/// either call included. The endpoint that sends a verdict is +/// unauthenticated, no proof or quorum signature backs it, and one that kept +/// or forwarded the signed transition can answer with any verdict, while a +/// retry signs a second withdrawal with the next identity nonce. +/// +/// The withdrawal keeps no record of its signed transition: what settles an +/// unconfirmed one is the identity's nonce (once the nonce it signed with +/// reads as used, it executed or can no longer execute) and the claimable +/// balance, which the host re-reads before it submits again. +async fn settle_masternode_withdrawal( + identity_id: Identifier, + amount_credits: u64, + delivery: BroadcastDelivery, + wait: W, +) -> Result +where + W: FnOnce() -> Fut, + Fut: std::future::Future>, +{ + // What the broadcast was answered with, kept for the reason an + // unconfirmed withdrawal reports. + let broadcast_answer = match delivery { + BroadcastDelivery::Admitted => None, + BroadcastDelivery::NeverDelivered(e) => { + return Err(PlatformWalletError::InvalidIdentityData(format!( + "masternode withdrawal failed: {e}" + ))); + } + BroadcastDelivery::MaybeDelivered(e) => { tracing::warn!( identity = %identity_id, error = %e, - "masternode withdrawal broadcast returned no verdict; the transition may \ - have been admitted — falling through to the result wait" + "masternode withdrawal broadcast may have been delivered; falling through \ + to the result wait" ); + Some(e.to_string()) } - } + }; + let unconfirmed = |waited: String| PlatformWalletError::MasternodeWithdrawalUnconfirmed { + identity_id, + amount_credits, + reason: match &broadcast_answer { + Some(answer) => format!("{waited}; its broadcast answered: {answer}"), + None => waited, + }, + }; - match state_transition - .wait_for_affected_state::(sdk, None) - .await - { + match wait().await { Ok(StateTransitionProofResult::VerifiedPartialIdentity(partial)) => partial .balance .ok_or_else(|| unconfirmed("the result proof carried no identity balance".to_string())), - // Proved, but not the shape a withdrawal produces — the transition - // landed; only the balance read-back is missing. + // Verified, but not the identity balance a withdrawal's result + // carries: the remaining balance is not proved. Ok(_) => Err(unconfirmed( "the result proof did not carry the identity's balance".to_string(), )), - Err(e) if carries_consensus_rejection(&e) => Err(definitive(e)), Err(e) => Err(unconfirmed(e.to_string())), } } @@ -724,11 +785,221 @@ where #[cfg(test)] mod tests { use super::*; + use crate::broadcast_outcome::test_sdks::{sdk_refusing_connections, sdk_without_addresses}; + use dash_sdk::dapi_client::transport::TransportError; + use dash_sdk::dapi_client::DapiClientError; + use dash_sdk::dapi_grpc::tonic::{Code, Status}; + use dash_sdk::error::StateTransitionBroadcastError; use dashcore::crypto::ecdsa::{EcdsaPublicKey as PublicKey, EcdsaSecretKey as SecretKey}; + use dpp::consensus::basic::{BasicError, UnsupportedFeatureError}; + use dpp::consensus::ConsensusError; use dpp::identity::identity_public_key::v0::IdentityPublicKeyV0; - use dpp::identity::{Purpose, SecurityLevel}; + use dpp::identity::{PartialIdentity, Purpose, SecurityLevel}; + use dpp::state_transition::identity_credit_withdrawal_transition::v1::IdentityCreditWithdrawalTransitionV1; + use std::cell::Cell; use std::str::FromStr; + /// The identity the withdrawal tests withdraw from, and the amount. + const WITHDRAWN_FROM: [u8; 32] = [0x31; 32]; + const WITHDRAWN: u64 = 1_000_000; + + /// A node's refusal of a dispatch, as an offline or overloaded endpoint + /// answers one. + fn unavailable() -> dash_sdk::Error { + dash_sdk::Error::DapiClientError(DapiClientError::Transport(TransportError::Grpc( + Status::new(Code::Unavailable, "test"), + ))) + } + + fn verdict() -> ConsensusError { + ConsensusError::BasicError(BasicError::UnsupportedFeatureError( + UnsupportedFeatureError::new("test".to_string(), 1), + )) + } + + /// A consensus verdict as a broadcast returns one. + fn broadcast_verdict() -> dash_sdk::Error { + dash_sdk::Error::Protocol(ProtocolError::ConsensusError(Box::new(verdict()))) + } + + /// A consensus verdict as the result wait returns one. + fn wait_verdict() -> dash_sdk::Error { + dash_sdk::Error::StateTransitionBroadcastError(StateTransitionBroadcastError { + code: 1, + message: "rejected".to_string(), + cause: Some(verdict()), + }) + } + + fn wait_timeout() -> dash_sdk::Error { + dash_sdk::Error::TimeoutReached(std::time::Duration::from_secs(80), "waiting".to_string()) + } + + fn proved_balance(balance: Option) -> StateTransitionProofResult { + StateTransitionProofResult::VerifiedPartialIdentity(PartialIdentity { + id: Identifier::from(WITHDRAWN_FROM), + loaded_public_keys: Default::default(), + balance, + revision: None, + not_found_public_keys: Default::default(), + }) + } + + fn is_unconfirmed(settled: &Result) -> bool { + matches!( + settled, + Err(PlatformWalletError::MasternodeWithdrawalUnconfirmed { + identity_id, + amount_credits: WITHDRAWN, + .. + }) if *identity_id == Identifier::from(WITHDRAWN_FROM) + ) + } + + /// Once a dispatch may have reached a node, a withdrawal whose result is + /// not proved is unconfirmed, never failed: a node's refusal of the + /// broadcast and a consensus verdict on either call included, as no proof + /// backs an endpoint's answer and a retry signs a second withdrawal. The + /// reason keeps what the wait and the broadcast were answered with. + #[tokio::test] + async fn should_report_a_masternode_withdrawal_unconfirmed_once_a_dispatch_may_have_reached_a_node( + ) { + let cases = [ + ( + "a node refused the broadcast, the wait timed out", + BroadcastDelivery::MaybeDelivered(unavailable()), + wait_timeout(), + ), + ( + "the broadcast was answered with a consensus verdict", + BroadcastDelivery::MaybeDelivered(broadcast_verdict()), + wait_timeout(), + ), + ( + "admitted, the wait was answered with a consensus verdict", + BroadcastDelivery::Admitted, + wait_verdict(), + ), + ( + "admitted, the wait timed out", + BroadcastDelivery::Admitted, + wait_timeout(), + ), + ]; + for (case, delivery, wait_error) in cases { + let broadcast_answer = match &delivery { + BroadcastDelivery::MaybeDelivered(e) => Some(e.to_string()), + _ => None, + }; + let waited = wait_error.to_string(); + let settled = settle_masternode_withdrawal( + Identifier::from(WITHDRAWN_FROM), + WITHDRAWN, + delivery, + || std::future::ready(Err(wait_error)), + ) + .await; + let Err(PlatformWalletError::MasternodeWithdrawalUnconfirmed { reason, .. }) = settled + else { + panic!("{case}: {settled:?}"); + }; + assert!(reason.contains(&waited), "{case}: {reason}"); + if let Some(answer) = broadcast_answer { + assert!(reason.contains(&answer), "{case}: {reason}"); + } + } + } + + /// Only a withdrawal whose transition never left the wallet is an + /// ordinary, retryable failure, and nothing waits for its result. + #[tokio::test] + async fn should_report_a_masternode_withdrawal_failed_only_when_nothing_was_sent() { + let waited = Cell::new(false); + let settled = settle_masternode_withdrawal( + Identifier::from(WITHDRAWN_FROM), + WITHDRAWN, + BroadcastDelivery::NeverDelivered(dash_sdk::Error::DapiClientError( + DapiClientError::NoAvailableAddresses, + )), + || { + waited.set(true); + std::future::ready(Err(wait_timeout())) + }, + ) + .await; + assert!( + matches!(settled, Err(PlatformWalletError::InvalidIdentityData(_))), + "{settled:?}" + ); + assert!(!waited.get(), "nothing was sent, so nothing is waited for"); + } + + /// A proved result returns the identity's remaining balance, whatever the + /// broadcast answered; a proof that carries no balance leaves the + /// withdrawal unconfirmed. + #[tokio::test] + async fn should_return_a_masternode_withdrawal_s_proved_balance() { + for delivery in [ + BroadcastDelivery::Admitted, + BroadcastDelivery::MaybeDelivered(unavailable()), + ] { + let settled = settle_masternode_withdrawal( + Identifier::from(WITHDRAWN_FROM), + WITHDRAWN, + delivery, + || std::future::ready(Ok(proved_balance(Some(7)))), + ) + .await; + assert!(matches!(settled, Ok(7)), "{settled:?}"); + } + let settled = settle_masternode_withdrawal( + Identifier::from(WITHDRAWN_FROM), + WITHDRAWN, + BroadcastDelivery::Admitted, + || std::future::ready(Ok(proved_balance(None))), + ) + .await; + assert!(is_unconfirmed(&settled), "{settled:?}"); + } + + /// The broadcast of a signed withdrawal through a network SDK: a node that + /// refuses the connection leaves it unconfirmed, as a transport failure + /// cannot show that nothing was sent, and an SDK with no address to send + /// to leaves it failed. + #[tokio::test] + async fn should_report_a_masternode_withdrawal_unconfirmed_when_its_node_refuses_the_connection( + ) { + let transition: StateTransition = IdentityCreditWithdrawalTransitionV1 { + identity_id: Identifier::from(WITHDRAWN_FROM), + amount: WITHDRAWN, + nonce: 4, + ..Default::default() + } + .into(); + + let (refusing, _port) = sdk_refusing_connections(); + let refused = broadcast_masternode_withdrawal( + &refusing, + &transition, + Identifier::from(WITHDRAWN_FROM), + WITHDRAWN, + ) + .await; + assert!(is_unconfirmed(&refused), "{refused:?}"); + + let never_sent = broadcast_masternode_withdrawal( + &sdk_without_addresses(), + &transition, + Identifier::from(WITHDRAWN_FROM), + WITHDRAWN, + ) + .await; + assert!( + matches!(never_sent, Err(PlatformWalletError::InvalidIdentityData(_))), + "{never_sent:?}" + ); + } + /// Test signer: one fixed secp256k1 key regardless of path. struct FixedKeySigner { secret: SecretKey, diff --git a/packages/rs-platform-wallet/src/wallet/platform_wallet.rs b/packages/rs-platform-wallet/src/wallet/platform_wallet.rs index 51b978a3dcf..3c3cdca430b 100644 --- a/packages/rs-platform-wallet/src/wallet/platform_wallet.rs +++ b/packages/rs-platform-wallet/src/wallet/platform_wallet.rs @@ -2351,6 +2351,11 @@ impl PlatformWallet { /// at `payment_account` doesn't exist, or /// `PlatformShieldCapacityExceeded` if the selected Platform-address set /// can't cover `amount` plus the fee reserve. + /// + /// Succeeds once the shield's verified result reads every input address + /// at or past the nonce the shield signed. The activity row stays pending + /// until the shielded scan observes the note on-chain: the result proves + /// the input addresses, not this shield or its note. #[cfg(feature = "shielded")] pub async fn shielded_shield_from_account( &self, diff --git a/packages/rs-platform-wallet/src/wallet/shielded/operations.rs b/packages/rs-platform-wallet/src/wallet/shielded/operations.rs index 9105c5dd007..c213918b251 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/operations.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/operations.rs @@ -33,7 +33,7 @@ use super::store::{ SubwalletId, }; use crate::broadcast_outcome::{ - broadcast_definitely_failed, broadcast_tracking_delivery, carries_consensus_rejection, + broadcast_tracking_delivery, broadcast_was_refused, carries_consensus_rejection, BroadcastDelivery, }; use crate::changeset::{PlatformWalletChangeSet, ShieldedChangeSet}; @@ -68,7 +68,7 @@ use dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV use dpp::identity::signer::Signer; use dpp::identity::{Identity, IdentityPublicKey}; use dpp::platform_value::BinaryData; -use dpp::prelude::{Identifier, IdentityNonce}; +use dpp::prelude::{AddressNonce, Identifier, IdentityNonce}; use dpp::shielded::builder::{ build_identity_create_from_shielded_pool_transition, build_identity_top_up_from_shielded_pool_transition, @@ -703,117 +703,240 @@ pub async fn shield_to, P: Orchar ) .await; - // Wait for proven execution (not just relay-ACK) so the host only - // sees success once Platform has actually included the transition. A - // DAPI-level ACK alone could otherwise mask a later Platform - // rejection. The proven result is discarded; we only need the - // confirmation. Staged like `broadcast_shielded_spend`: only a - // DEFINITIVE verdict (CheckTx rejection / refused admission) marks - // the activity row Failed — an ambiguous wait failure (timeout, - // result-proof fetch error) leaves it Pending, because the shield - // may still land and the scan's cmx-overlap confirmation will flip - // the row when its note appears on-chain. Shield takes no note - // reservation, so the ambiguous arm strands no local spend state; - // its inputs are transparent address claims guarded by on-chain - // nonces, and a host-level retry re-fetches those nonces. - let enrich = |e: &dash_sdk::Error| -> PlatformWalletError { - if let Some(rich) = addresses_not_enough_funds(e) { - let claimed = claimed_inputs - .iter() - .map(|(addr, (nonce, credits))| { - format!( - "{}=(nonce {nonce}, {credits} credits)", - addr.to_bech32m_string(network) - ) - }) - .collect::>() - .join(", "); - PlatformWalletError::ShieldedBroadcastFailed(format!( - "addresses not enough funds: required {} credits; \ - claimed inputs [{}]; platform sees [{}]", - rich.required_balance(), - claimed, - format_addresses_with_info(rich.addresses_with_info(), network), - )) - } else { - crate::error::promote_address_nonce_error(e) - .unwrap_or_else(|| PlatformWalletError::ShieldedBroadcastFailed(e.to_string())) - } + // What an endpoint's answer says, for the reason an unconfirmed shield + // reports. A funds shortfall names the claimed inputs beside what the + // endpoint says Platform sees. + let describe = |e: &dash_sdk::Error| -> String { + let Some(rich) = addresses_not_enough_funds(e) else { + return e.to_string(); + }; + let claimed = claimed_inputs + .iter() + .map(|(addr, (nonce, credits))| { + format!( + "{}=(nonce {nonce}, {credits} credits)", + addr.to_bech32m_string(network) + ) + }) + .collect::>() + .join(", "); + format!( + "addresses not enough funds: required {} credits; \ + claimed inputs [{}]; platform sees [{}]", + rich.required_balance(), + claimed, + format_addresses_with_info(rich.addresses_with_info(), network), + ) }; - match state_transition.broadcast(sdk, None).await { - Ok(()) => {} - Err(e) if broadcast_definitely_failed(&e) => { + // Broadcast keeping what every dispatch proved, then wait for the + // verified snapshot of the input addresses (not just a relay ACK). A + // shield's proof authenticates those addresses as of its block, not this + // exact shield's execution, so the host sees success only once the + // snapshot reads every input at or past the nonce this shield signed, and + // the activity row stays pending until the scan observes the note. + broadcast_shield( + sdk, + store, + persister, + wallet_id, + id, + &pending_entry, + &state_transition, + describe, + ) + .await?; + info!( + account, + credits = amount, + "Shield admitted with its input addresses proved spent; the activity row awaits \ + on-chain confirmation by the shielded scan" + ); + Ok(()) +} + +/// Broadcast the signed shield `state_transition`, keeping what every +/// dispatch proved ([`broadcast_tracking_delivery`]), wait for its result and +/// settle it ([`settle_shield`]). +#[allow(clippy::too_many_arguments)] +async fn broadcast_shield( + sdk: &Arc, + store: &Arc>, + persister: Option<&WalletPersister>, + wallet_id: WalletId, + id: SubwalletId, + pending_entry: &Option, + state_transition: &StateTransition, + describe: D, +) -> Result<(), PlatformWalletError> +where + S: ShieldedStore, + D: Fn(&dash_sdk::Error) -> String, +{ + let delivery = broadcast_tracking_delivery(sdk, state_transition).await; + // A transition that names no input is no shield from addresses: its + // result then shows nothing spent, and it settles as unconfirmed. + let no_inputs = BTreeMap::new(); + settle_shield( + store, + persister, + wallet_id, + id, + pending_entry, + state_transition.inputs().unwrap_or(&no_inputs), + delivery, + describe, + || state_transition.wait_for_affected_state::(sdk, None), + ) + .await +} + +/// What a signed shield's broadcast (`delivery`) and result wait (`wait`, run +/// only when a dispatch may have reached a node) came to, recording it on the +/// shield's activity row (`pending_entry`). `signed_inputs` are the shield's +/// input addresses with the nonces it signed, and `describe` renders an +/// endpoint's answer for the reason an unconfirmed shield reports. +/// +/// Only a transition that never left the wallet +/// ([`BroadcastDelivery::NeverDelivered`]: refused before anything was sent) +/// is a failed shield +/// ([`PlatformWalletError::ShieldedBroadcastFailed`]): nothing holds its bytes +/// but the wallet, so it can never execute, its row reads failed and the host +/// may retry. Once a dispatch may have reached a node, every outcome short +/// of a verified result that reads its inputs spent +/// ([`shield_inputs_read_spent`]) is +/// [`PlatformWalletError::ShieldedSpendUnconfirmed`]: a wait that fails, a +/// result that does not show the inputs spent, an endpoint's refusal of the +/// broadcast and a consensus verdict on either call included. The endpoint +/// that sends a verdict is unauthenticated, no proof or quorum signature +/// backs it, and one that kept or forwarded the signed transition can answer +/// with any verdict, while a retry signs a second shield with the inputs' +/// next nonces, and a shield's note can be a recipient's ([`shield_to`]). +/// +/// The row stays pending on every outcome but the failed one, a successful +/// one included, as a shield from an identity's does: the result proves the +/// input addresses as of its block, which does not bind this transition or +/// its note. The scan confirms the row when the note commitment appears on +/// chain. +/// +/// The shield takes no note reservation and keeps no record of its signed +/// transition. What shows that an unconfirmed one can no longer execute is +/// its inputs' nonces (one of them used by another transition), which +/// nothing reads for it: until then the row stays pending. +#[allow(clippy::too_many_arguments)] +async fn settle_shield( + store: &Arc>, + persister: Option<&WalletPersister>, + wallet_id: WalletId, + id: SubwalletId, + pending_entry: &Option, + signed_inputs: &BTreeMap, + delivery: BroadcastDelivery, + describe: D, + wait: W, +) -> Result<(), PlatformWalletError> +where + S: ShieldedStore, + D: Fn(&dash_sdk::Error) -> String, + W: FnOnce() -> Fut, + Fut: Future>, +{ + let broadcast_answer = match delivery { + BroadcastDelivery::Admitted => None, + BroadcastDelivery::NeverDelivered(e) => { record_activity_status( store, persister, wallet_id, id, - &pending_entry, + pending_entry, ShieldedActivityStatus::Failed, None, ) .await; - return Err(enrich(&e)); + return Err(definite_broadcast_failure(&e)); } - Err(e) => { + BroadcastDelivery::MaybeDelivered(e) => { warn!( - account, + account = id.account_index, error = %e, - "Shield broadcast returned no verdict; the transition may have been \ - admitted — falling through to the result wait" + "Shield broadcast may have been delivered; falling through to the result wait" ); + Some(describe(&e)) } - } + }; - // A shield proof authenticates the input addresses' post-state, not this - // exact shield's execution — accept the affected-state snapshot; a - // consensus rejection still surfaces as an error here. - if let Err(wait_err) = state_transition - .wait_for_affected_state::(sdk, None) - .await - { - if carries_consensus_rejection(&wait_err) { - // A consensus verdict in the result: the shield definitively - // did not execute. - record_activity_status( - store, - persister, - wallet_id, - id, - &pending_entry, - ShieldedActivityStatus::Failed, - None, - ) - .await; - return Err(enrich(&wait_err)); + // Whatever the wait comes to, the activity row stays Pending: the scan's + // confirmation pass flips it when the shielded note lands on-chain. + let unproved = match wait().await { + Ok(snapshot) => match shield_inputs_read_spent(signed_inputs, &snapshot) { + Ok(()) => return Ok(()), + Err(unproved) => { + warn!( + account = id.account_index, + reason = %unproved, + "Shield result does not read its inputs spent; leaving the activity row pending" + ); + unproved + } + }, + Err(wait_err) => { + warn!( + account = id.account_index, + error = %wait_err, + rejected = carries_consensus_rejection(&wait_err), + "Shield broadcast but its result is unconfirmed; leaving the activity row pending" + ); + describe(&wait_err) } - // Ambiguous: admitted but unconfirmed. Leave the activity row - // Pending — the scan's confirmation pass flips it when the - // shielded note lands on-chain. - warn!( - account, - error = %wait_err, - "Shield broadcast accepted but result confirmation failed; \ - leaving the activity row pending" - ); - return Err(PlatformWalletError::ShieldedSpendUnconfirmed { - operation: "shield", - reason: wait_err.to_string(), - }); - } + }; + Err(PlatformWalletError::ShieldedSpendUnconfirmed { + operation: "shield", + reason: match broadcast_answer { + Some(answer) => format!("{unproved}; its broadcast answered: {answer}"), + None => unproved, + }, + }) +} - record_activity_status( - store, - persister, - wallet_id, - id, - &pending_entry, - ShieldedActivityStatus::Confirmed, - None, - ) - .await; - info!(account, credits = amount, "Shield broadcast succeeded"); +/// Whether `snapshot`, the verified result a shield's wait returned, reads +/// every input address of the shield at or past the nonce the shield signed +/// it with (`signed_inputs`), or why it does not. +/// +/// The proof behind a shield's result authenticates its input addresses as +/// of the proof's block, not the shield's execution. An address's nonce +/// advances only when a transition signed with that nonce executes, so +/// inputs at or past their signed nonces show those nonces used. A result +/// that is no address snapshot shows nothing of the shield, and one that +/// leaves an input out, proves no state for it or reads it below its signed +/// nonce shows the shield had not executed at that block, whatever the +/// endpoint that returned it claims. +fn shield_inputs_read_spent( + signed_inputs: &BTreeMap, + snapshot: &StateTransitionProofResult, +) -> Result<(), String> { + let StateTransitionProofResult::VerifiedAddressInfos(proved) = snapshot else { + return Err(format!( + "a proof of its input addresses was expected, received {snapshot}" + )); + }; + if signed_inputs.is_empty() { + return Err("it names no input address".to_string()); + } + for (address, (signed, _)) in signed_inputs { + match proved.get(address) { + Some(Some((proved, _))) if proved >= signed => {} + Some(Some((proved, _))) => { + return Err(format!( + "its result proves an input address at nonce {proved}, below the nonce \ + {signed} it signed" + )); + } + Some(None) | None => { + return Err("its result proves no state for an input address".to_string()); + } + } + } Ok(()) } @@ -1049,7 +1172,7 @@ pub(in crate::wallet) async fn shield_from_identity_to< match state_transition.broadcast(sdk, None).await { Ok(()) => {} - Err(e) if broadcast_definitely_failed(&e) => { + Err(e) if broadcast_was_refused(&e) => { // Unauthenticated verdict: settle it against the proven nonce. return Err(settle_identity_shield_failure( sdk, @@ -1472,21 +1595,8 @@ pub(in crate::wallet) async fn identity_top_up_from_pool_with_metadata< }, ) .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(); - } - trace!("IdentityTopUpFromShieldedPool: state transition built, broadcasting..."); + trace!("IdentityTopUpFromShieldedPool: state transition built, saving and broadcasting..."); // Type 22 is classified affected state: its nullifiers are shared with any // competing spend of the same notes and the identity's balance is a // snapshot, so the strict wait would reject every success. The snapshot @@ -1501,6 +1611,7 @@ pub(in crate::wallet) async fn identity_top_up_from_pool_with_metadata< anchor_bytes, anchor_height, &selected_notes, + &mut reservation, &state_transition, "identity top up from shielded pool", SpendResultWait::AffectedState, @@ -1567,28 +1678,17 @@ pub(in crate::wallet) async fn identity_top_up_from_pool_with_metadata< }; Ok((proven_balance, metadata)) } - 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, - wallet_id, - id, - &pending_entry, - ShieldedActivityStatus::Failed, - None, - ) - .await; - reservation.cancel().await; - Err(e) - } + Err(failure) => Err(settle_failed_spend( + store, + persister, + wallet_id, + id, + &pending_entry, + reservation, + "identity top up from shielded pool", + failure, + ) + .await), } } @@ -2185,10 +2285,9 @@ pub async fn unshield( "Unshield" ); - // From here on every error path must release the reservation taken - // by `reserve_unspent_notes` — except the ambiguous - // `ShieldedSpendUnconfirmed` one, which intentionally leaves it in - // place (see the outer match below). + // From here on an error before the spend's record is saved releases the + // reservation taken by `reserve_unspent_notes`; once it is saved, only a + // transition that never left the wallet does (`settle_failed_spend`). // // `pending_entry` is recorded once the transition is built (so we // have its output cmxs) and flipped to Confirmed / Failed in the @@ -2246,21 +2345,8 @@ pub async fn unshield( }, ) .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(); - } - trace!("Unshield: state transition built, broadcasting..."); + trace!("Unshield: state transition built, saving and broadcasting..."); broadcast_shielded_spend_with_redrive( sdk, store, @@ -2269,6 +2355,7 @@ pub async fn unshield( anchor_bytes, anchor_height, &selected_notes, + &mut reservation, &state_transition, "unshield", SpendResultWait::ExecutionProved, @@ -2303,9 +2390,9 @@ pub async fn unshield( // scan runs the note is re-selected and a second spend is // built + proven, then rejected at broadcast with a // nullifier-already-used error — wasted ~30 s proof, never - // fund loss. (`pending_nullifiers` is in-memory only, so it - // does not protect across a process restart in this window; - // the on-chain set does.) + // fund loss. (A restart restores the reservation only while + // the spend's saved record is still there; the on-chain set + // protects either way.) if let Err(e) = finalize_pending(store, persister, wallet_id, id, &selected_notes).await { warn!( @@ -2318,43 +2405,25 @@ pub async fn unshield( info!(account, credits = amount, "Unshield broadcast succeeded"); Ok(()) } - // The broadcast was accepted but its execution result couldn't be - // confirmed: the spend may well have executed, so do NOT release - // the reservation. `pending_nullifiers` is in-memory only — if the - // spend landed, the next nullifier sync's `mark_spent` promotes the - // notes to spent (clearing the reservation); if it never landed, an - // app restart drops the reservation and frees the notes. Releasing - // them now would invite re-selecting notes whose nullifiers may - // already be consumed on chain. - // - // Activity entry: leave it `Pending`. Like the note reservation, - // 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 { .. }) => { - // 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( - store, - persister, - wallet_id, - id, - &pending_entry, - ShieldedActivityStatus::Failed, - None, - ) - .await; - reservation.cancel().await; - Err(e) - } + // A spend that may have reached a node keeps its reservation, its + // saved record and its `Pending` activity row: a later scan that + // finds the spend marks the notes spent and flips the row to + // Confirmed (its id matches), and the saved record restores the + // reservation after a restart. Releasing the notes would invite + // re-selecting notes whose nullifiers may already be consumed on + // chain. Only a spend that failed before its record was saved, or + // whose transition never left the wallet, releases them. + Err(failure) => Err(settle_failed_spend( + store, + persister, + wallet_id, + id, + &pending_entry, + reservation, + "unshield", + failure, + ) + .await), } } @@ -2448,21 +2517,8 @@ pub async fn transfer( }, ) .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(); - } - trace!("Shielded transfer: state transition built, broadcasting..."); + trace!("Shielded transfer: state transition built, saving and broadcasting..."); broadcast_shielded_spend_with_redrive( sdk, store, @@ -2471,6 +2527,7 @@ pub async fn transfer( anchor_bytes, anchor_height, &selected_notes, + &mut reservation, &state_transition, "transfer", SpendResultWait::ExecutionProved, @@ -2510,31 +2567,18 @@ pub async fn transfer( ); Ok(()) } - // 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 { .. }) => { - // 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, - wallet_id, - id, - &pending_entry, - ShieldedActivityStatus::Failed, - None, - ) - .await; - reservation.cancel().await; - Err(e) - } + // See `unshield`'s outer match. + Err(failure) => Err(settle_failed_spend( + store, + persister, + wallet_id, + id, + &pending_entry, + reservation, + "transfer", + failure, + ) + .await), } } @@ -2640,21 +2684,8 @@ pub async fn withdraw( }, ) .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(); - } - trace!("Shielded withdrawal: state transition built, broadcasting..."); + trace!("Shielded withdrawal: state transition built, saving and broadcasting..."); broadcast_shielded_spend_with_redrive( sdk, store, @@ -2663,6 +2694,7 @@ pub async fn withdraw( anchor_bytes, anchor_height, &selected_notes, + &mut reservation, &state_transition, "withdraw", SpendResultWait::ExecutionProved, @@ -2702,31 +2734,18 @@ pub async fn withdraw( ); Ok(()) } - // 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 { .. }) => { - // 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, - wallet_id, - id, - &pending_entry, - ShieldedActivityStatus::Failed, - None, - ) - .await; - reservation.cancel().await; - Err(e) - } + // See `unshield`'s outer match. + Err(failure) => Err(settle_failed_spend( + store, + persister, + wallet_id, + id, + &pending_entry, + reservation, + "withdraw", + failure, + ) + .await), } } @@ -2808,14 +2827,16 @@ where .map(|(key, _)| (key.id(), key.clone())) .collect(); - // From here on every error path must release the reservation taken above — except the - // ambiguous `ShieldedBroadcastUnconfirmed` one, which intentionally leaves it in place - // (see the outer match below). + // From here on an error before the create's record is saved releases the reservation taken + // above; once it is saved, only a transition that never left the wallet does + // (`settle_failed_spend`). // // `pending_entry` is recorded once the bundle is built (so we know the // identity id + output cmxs) and flipped to Confirmed / Failed in the - // outer match; it lives here so the flip can see it. + // outer match; it lives here so the flip can see it. So does + // `derived_identity_id`, which an unconfirmed create reports. let mut pending_entry = None; + let mut derived_identity_id = None; let result = async { let (spends, anchor, anchor_height) = extract_spends_and_recorded_anchor(sdk, store, &selected_notes).await?; @@ -2842,6 +2863,7 @@ where .map_err(|e| PlatformWalletError::ShieldedBuildError(e.to_string()))?; let identity_id = build.identity_id; + derived_identity_id = Some(identity_id); // Live activity: IdentityCreate carries the created identity id. // The display amount is the denomination LESS the metered fee (the @@ -2869,30 +2891,11 @@ where }, ) .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(); - } - trace!("IdentityCreateFromShieldedPool: built, broadcasting via SDK..."); - // Stage the broadcast and the result-wait SEPARATELY (instead of one `broadcast_and_wait`) - // so the two failure shapes can be told apart: - // - a broadcast-time rejection (relay/CheckTx refused the tx) means it never executed, so - // releasing the note reservations is correct; - // - a post-broadcast wait failure is AMBIGUOUS — the relay accepted the tx and it may well - // have executed; treating it as "unregistered" + releasing the notes is the - // orphaned-identity + double-spend hazard this split exists to avoid. // The transition is built once from the PoP-signed keys + bundle params (preserving the // per-key signatures); the binding signature already committed `identity_id`, the - // denomination, and the full key set. + // denomination, and the full key set. It is built before its record is saved, which + // keeps these exact bytes. let st = sdk .identity_create_from_shielded_pool_transition( build.public_keys, @@ -2902,147 +2905,21 @@ where ) .map_err(|e| PlatformWalletError::ShieldedBuildError(e.to_string()))?; - // Broadcast (relay-ACK only). Only a transition that never left the wallet - // (`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 - // 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())); - } - BroadcastDelivery::MaybeDelivered(e) => { - warn!( - derived_id = %identity_id, - error = %e, - "IdentityCreateFromShieldedPool: broadcast may have been delivered — \ - falling through to the result wait" - ); - } - } - - // 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. - let proof_result = match st - .wait_for_affected_state::(sdk, None) - .await - { - Ok(result) => result, - Err(wait_err) => { - warn!( - derived_id = %identity_id, - error = %wait_err, - "IdentityCreateFromShieldedPool: broadcast accepted but result confirmation \ - failed; falling back to fetching the identity by its derived id" - ); - // Fetch the identity directly. The id is committed in the sighash and derived - // deterministically from the spent nullifiers, so if the transition landed the row - // is queryable. A freshly-included identity can take a moment to index on the DAPI - // node we hit, so retry a few times with a short, fixed backoff before concluding - // it's truly absent — long enough to ride out routine indexing/replica lag, short - // enough not to wedge the caller's UI for minutes. - match fetch_identity_with_retries(sdk, identity_id).await { - Some(mut identity) => { - info!( - derived_id = %identity_id, - "IdentityCreateFromShieldedPool: result confirmation failed but the \ - identity was found on chain by its derived id; treating as success" - ); - // Same defensive empty-`public_keys` fill as the proven-result path below, - // so downstream auth-key checks see the committed key set immediately. - if identity.public_keys().is_empty() { - identity.set_public_keys(submitted_public_keys.clone()); - } - return Ok::<(Identifier, Identity), PlatformWalletError>(( - identity.id(), - identity, - )); - } - None => { - // Arm the persisted re-drive before surfacing the - // ambiguity: the sync-time pass re-checks the - // nullifiers, then re-broadcasts this - // byte-identical transition up to - // MAX_REDRIVE_ATTEMPTS times. - arm_redrive_record( - store, - id, - &pending_entry, - anchor_bytes, - anchor_height, - &selected_notes, - &st, - "identity_create", - ) - .await; - return Err(PlatformWalletError::ShieldedBroadcastUnconfirmed { - identity_id, - reason: wait_err.to_string(), - }); - } - } - } - }; - - // Pull the verified `Identity` out of the proof result. The expected variant is - // `VerifiedIdentityWithShieldedNullifiers`; if drive-abci ever returns a different one the - // broadcast still SUCCEEDED, so we don't turn it into an error — we synthesize the identity - // from the derived id + submitted keys (the binding signature committed both) and warn, so - // the local row is still created. - let identity = match proof_result { - StateTransitionProofResult::VerifiedIdentityWithShieldedNullifiers( - mut identity, - _nullifiers, - ) => { - // The proof-verified id is authoritative: it's recomputed from the proven nullifier - // set, while `identity_id` was derived pre-broadcast. They should match (the derived - // id is committed in the sighash), but trust the verified one. - if identity.id() != identity_id { - warn!( - derived_id = %identity_id, - verified_id = %identity.id(), - "IdentityCreateFromShieldedPool: derived id differs from proof-verified id; \ - using the proof-verified id" - ); - } - // Defensive: a proof result can hand back an identity whose `public_keys` map is - // empty. Fill it from the submitted set so downstream auth-key checks see the keys - // immediately without waiting for the next identity-fetch round (the transition - // committed exactly these keys, so id reproducibility is preserved). - if identity.public_keys().is_empty() { - identity.set_public_keys(submitted_public_keys); - } - identity - } - other => { - warn!( - derived_id = %identity_id, - result = %other, - "IdentityCreateFromShieldedPool: unexpected proof-result variant; synthesizing \ - the identity from the derived id + submitted keys so the local row still lands" - ); - Identity::new_with_id_and_keys( - identity_id, - submitted_public_keys, - sdk.version(), - ) - .map_err(|e| PlatformWalletError::ShieldedBuildError(e.to_string()))? - } - }; - - Ok::<(Identifier, Identity), PlatformWalletError>((identity.id(), identity)) + trace!("IdentityCreateFromShieldedPool: built, saving and broadcasting via SDK..."); + broadcast_identity_create_with_redrive( + sdk, + store, + id, + &pending_entry, + anchor_bytes, + anchor_height, + &selected_notes, + &mut reservation, + &st, + identity_id, + submitted_public_keys, + ) + .await } .await; @@ -3081,62 +2958,225 @@ 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 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, - // an app restart drops the in-memory reservation and frees them. Releasing them now would - // 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 { .. }) => { - // It may have executed: the reservation stays, released by sync. - reservation.hand_over(); - Err(e) + // A create that may have reached a node keeps its reservation, its saved record and its + // `Pending` activity row (see `unshield`'s outer match), and reports the derived id: the + // identity may already exist on chain, so the caller holds its registration slot. + Err(failure) => { + let error = settle_failed_spend( + store, + persister, + wallet_id, + id, + &pending_entry, + reservation, + IDENTITY_CREATE_OPERATION, + failure, + ) + .await; + Err(identity_create_failure(error, derived_identity_id)) } - 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( - store, - persister, - wallet_id, - id, - &pending_entry, - ShieldedActivityStatus::Failed, - None, - ) - .await; - reservation.cancel().await; - } else { - // It may have executed: the reservation stays, released by sync. - reservation.hand_over(); + } +} + +/// The error an identity create reports for `error`, what [`settle_failed_spend`] settled its +/// failure as. A create settled as unconfirmed carries the derived `identity_id` +/// ([`PlatformWalletError::ShieldedBroadcastUnconfirmed`]): the identity may already exist on +/// chain, so the caller must hold its registration slot rather than read a failed create. +fn identity_create_failure( + error: PlatformWalletError, + identity_id: Option, +) -> PlatformWalletError { + match (error, identity_id) { + (PlatformWalletError::ShieldedSpendUnconfirmed { reason, .. }, Some(identity_id)) => { + PlatformWalletError::ShieldedBroadcastUnconfirmed { + identity_id, + reason, } - Err(e) } + (error, _) => error, } } -/// Whether a failed identity-create should release the notes reserved for it. +/// Save the built identity create `st`'s recovery record durably, then +/// broadcast it and wait for its result ([`save_spend_then_broadcast`], +/// [`broadcast_identity_create`]): the identity create's counterpart of +/// [`broadcast_shielded_spend_with_redrive_with_metadata`]. +#[allow(clippy::too_many_arguments)] +async fn broadcast_identity_create_with_redrive( + sdk: &Arc, + store: &Arc>, + id: SubwalletId, + pending_entry: &Option, + anchor: [u8; 32], + anchor_height: u64, + notes: &[ShieldedNote], + reservation: &mut NoteReservation, + st: &StateTransition, + identity_id: Identifier, + submitted_public_keys: BTreeMap, +) -> Result<(Identifier, Identity), SpendFailure> { + save_spend_then_broadcast( + store, + id, + pending_entry, + anchor, + anchor_height, + notes, + reservation, + st, + IDENTITY_CREATE_OPERATION, + || broadcast_identity_create(sdk, st, identity_id, submitted_public_keys), + ) + .await +} + +/// What an identity create is named in the errors its record's save and its +/// settlement report. +const IDENTITY_CREATE_OPERATION: &str = "identity create from shielded pool"; + +/// Broadcast the built identity create `st`, whose record is already saved +/// ([`save_spend_then_broadcast`]), and wait for its result. /// -/// `false` ONLY for [`PlatformWalletError::ShieldedBroadcastUnconfirmed`]: the broadcast was -/// accepted and the transition may have executed, so the reservation must be retained. Releasing it -/// now would invite double-spend attempts against notes that may already be consumed on chain — the -/// very hazard that variant exists to prevent. `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, an app restart drops the -/// in-memory reservation and frees them. +/// The broadcast and the result wait are staged separately so the two failure shapes can be +/// told apart: +/// - a transition that never left the wallet ([`BroadcastDelivery::NeverDelivered`]: refused +/// before anything was sent) can never execute, so its record is retired and its notes are +/// released ([`SpendFailure::never_delivered`]); +/// - every other failure may have delivered it. It falls through to the result wait, and a +/// wait that fails falls back to fetching the identity by its pre-derived id before the +/// create is reported unconfirmed ([`PlatformWalletError::ShieldedBroadcastUnconfirmed`]), +/// its notes and record kept. 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. /// -/// Everything else is a definitive pre-execution / build / rejection failure: the spend never -/// happened, so the reservation must be released. -fn error_releases_note_reservation(e: &PlatformWalletError) -> bool { - !matches!(e, PlatformWalletError::ShieldedBroadcastUnconfirmed { .. }) +/// Nothing here reports a failed create once a dispatch may have reached a node: a create +/// reported failed while it may still execute is the orphaned-identity hazard this staging +/// exists to avoid. +async fn broadcast_identity_create( + sdk: &Arc, + st: &StateTransition, + identity_id: Identifier, + submitted_public_keys: BTreeMap, +) -> Result<(Identifier, Identity), SpendFailure> { + let unconfirmed = |reason: String| { + SpendFailure::from(PlatformWalletError::ShieldedBroadcastUnconfirmed { + identity_id, + reason, + }) + }; + + match broadcast_tracking_delivery(sdk, st).await { + BroadcastDelivery::Admitted => {} + BroadcastDelivery::NeverDelivered(e) => { + return Err(SpendFailure { + error: definite_broadcast_failure(&e), + never_delivered: true, + }); + } + BroadcastDelivery::MaybeDelivered(e) => { + warn!( + derived_id = %identity_id, + error = %e, + "IdentityCreateFromShieldedPool: broadcast may have been delivered; falling \ + through to the result wait" + ); + } + } + + // 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. + let proof_result = match st + .wait_for_affected_state::(sdk, None) + .await + { + Ok(result) => result, + Err(wait_err) => { + warn!( + derived_id = %identity_id, + error = %wait_err, + "IdentityCreateFromShieldedPool: broadcast accepted but result confirmation \ + failed; falling back to fetching the identity by its derived id" + ); + // Fetch the identity directly. The id is committed in the sighash and derived + // deterministically from the spent nullifiers, so if the transition landed the row + // is queryable. A freshly-included identity can take a moment to index on the DAPI + // node we hit, so retry a few times with a short, fixed backoff before concluding + // it's truly absent: long enough to ride out routine indexing/replica lag, short + // enough not to wedge the caller's UI for minutes. + return match fetch_identity_with_retries(sdk, identity_id).await { + Some(mut identity) => { + info!( + derived_id = %identity_id, + "IdentityCreateFromShieldedPool: result confirmation failed but the \ + identity was found on chain by its derived id; treating as success" + ); + // Same defensive empty-`public_keys` fill as the proven-result path below, + // so downstream auth-key checks see the committed key set immediately. + if identity.public_keys().is_empty() { + identity.set_public_keys(submitted_public_keys); + } + Ok((identity.id(), identity)) + } + // The saved record resolves it: the sync-time pass re-checks the nullifiers, + // then re-broadcasts this byte-identical transition up to + // MAX_REDRIVE_ATTEMPTS times. + None => Err(unconfirmed(wait_err.to_string())), + }; + } + }; + + // Pull the verified `Identity` out of the proof result. The expected variant is + // `VerifiedIdentityWithShieldedNullifiers`; if drive-abci ever returns a different one the + // broadcast still SUCCEEDED, so we don't turn it into a failed create: we synthesize the + // identity from the derived id + submitted keys (the binding signature committed both) and + // warn, so the local row is still created. + let identity = match proof_result { + StateTransitionProofResult::VerifiedIdentityWithShieldedNullifiers( + mut identity, + _nullifiers, + ) => { + // The proof-verified id is authoritative: it's recomputed from the proven nullifier + // set, while `identity_id` was derived pre-broadcast. They should match (the derived + // id is committed in the sighash), but trust the verified one. + if identity.id() != identity_id { + warn!( + derived_id = %identity_id, + verified_id = %identity.id(), + "IdentityCreateFromShieldedPool: derived id differs from proof-verified id; \ + using the proof-verified id" + ); + } + // Defensive: a proof result can hand back an identity whose `public_keys` map is + // empty. Fill it from the submitted set so downstream auth-key checks see the keys + // immediately without waiting for the next identity-fetch round (the transition + // committed exactly these keys, so id reproducibility is preserved). + if identity.public_keys().is_empty() { + identity.set_public_keys(submitted_public_keys); + } + identity + } + other => { + warn!( + derived_id = %identity_id, + result = %other, + "IdentityCreateFromShieldedPool: unexpected proof-result variant; synthesizing \ + the identity from the derived id + submitted keys so the local row still lands" + ); + // The create executed. An identity that cannot be synthesized leaves it + // unconfirmed, its notes and record kept for the next sync, never failed. + Identity::new_with_id_and_keys(identity_id, submitted_public_keys, sdk.version()) + .map_err(|e| { + unconfirmed(format!( + "its result was proved, but the identity could not be read from it: {e}" + )) + })? + } + }; + + Ok((identity.id(), identity)) } /// Number of times [`identity_create_from_shielded_pool`] re-fetches the new identity by its @@ -3456,12 +3496,12 @@ async fn mark_notes_spent( /// and proceed to build a duplicate proof that's only rejected /// ~30 s later at broadcast time. /// -/// 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 [`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 reservation is held in memory until the spend saves its record +/// ([`save_spend_then_broadcast`]), which restores it after a restart: see +/// [`ShieldedStore::mark_pending`]. Callers must pair this with +/// [`finalize_pending`] (on proven execution) or [`settle_failed_spend`] +/// (on failure); until the notes are handed over +/// ([`NoteReservation::hand_over`]), the returned guard releases them when /// the spend is dropped. async fn reserve_unspent_notes( sdk: &Arc, @@ -3749,19 +3789,14 @@ async fn arm_pending_release( Ok(true) } -/// Maximum sync-time re-broadcast attempts for a -/// broadcast-accepted-but-unconfirmed spend before the re-drive stops -/// and the anchor-prune release backstop owns the reservation. +/// Maximum sync-time re-broadcast attempts for a spend whose outcome is +/// not known before the re-drive stops and the anchor-prune release +/// backstop owns the reservation. pub(super) const MAX_REDRIVE_ATTEMPTS: u32 = 3; -/// Broadcast a built shielded spend and, on the AMBIGUOUS outcome only -/// (`ShieldedSpendUnconfirmed` — accepted broadcast, failed result -/// wait), persist a [`PendingRedrive`] so the sync-time re-drive can -/// resolve the ambiguity actively: the next scan detects a landing via -/// the nullifiers; otherwise the byte-identical transition is -/// re-broadcast up to [`MAX_REDRIVE_ATTEMPTS`] times (fund-safe — -/// identical nullifiers cannot double-spend); only if every attempt -/// stays silent does the anchor-prune release backstop take over. +/// Save a built shielded spend's recovery record, broadcast it and wait for +/// its result ([`broadcast_shielded_spend_with_redrive_with_metadata`]), +/// returning the proven result without its metadata. #[allow(clippy::too_many_arguments)] async fn broadcast_shielded_spend_with_redrive( sdk: &Arc, @@ -3771,10 +3806,11 @@ async fn broadcast_shielded_spend_with_redrive( anchor: [u8; 32], anchor_height: u64, notes: &[ShieldedNote], + reservation: &mut NoteReservation, state_transition: &StateTransition, operation: &'static str, wait: SpendResultWait, -) -> Result { +) -> Result { broadcast_shielded_spend_with_redrive_with_metadata( sdk, store, @@ -3783,6 +3819,7 @@ async fn broadcast_shielded_spend_with_redrive( anchor, anchor_height, notes, + reservation, state_transition, operation, wait, @@ -3791,6 +3828,17 @@ async fn broadcast_shielded_spend_with_redrive( .map(|(result, _)| result) } +/// Save a built shielded spend's recovery record ([`PendingRedrive`]) +/// durably, then broadcast it and wait for its result +/// ([`save_spend_then_broadcast`], [`broadcast_shielded_spend_classified`]). +/// +/// The record is what resolves a spend whose outcome is not known: the next +/// scan detects a landing via the nullifiers; otherwise the byte-identical +/// transition is re-broadcast up to [`MAX_REDRIVE_ATTEMPTS`] times +/// (identical nullifiers cannot be spent twice); only if every attempt stays +/// silent does the anchor-prune release backstop take over. Saved before the +/// first dispatch, it also covers a process that exits during the dispatches +/// or the result wait: a restart restores the notes' reservation from it. #[allow(clippy::too_many_arguments)] async fn broadcast_shielded_spend_with_redrive_with_metadata( sdk: &Arc, @@ -3800,87 +3848,237 @@ async fn broadcast_shielded_spend_with_redrive_with_metadata( anchor: [u8; 32], anchor_height: u64, notes: &[ShieldedNote], + reservation: &mut NoteReservation, state_transition: &StateTransition, operation: &'static str, wait: SpendResultWait, -) -> Result<(StateTransitionProofResult, ResponseMetadata), PlatformWalletError> { - let result = broadcast_shielded_spend(sdk, state_transition, operation, wait).await; - if matches!( - &result, - Err(PlatformWalletError::ShieldedSpendUnconfirmed { .. }) - ) { - arm_redrive_record( - store, - id, - pending_entry, - anchor, - anchor_height, - notes, - state_transition, - operation, - ) - .await; - } - result +) -> Result<(StateTransitionProofResult, ResponseMetadata), SpendFailure> { + save_spend_then_broadcast( + store, + id, + pending_entry, + anchor, + anchor_height, + notes, + reservation, + state_transition, + operation, + || broadcast_shielded_spend_classified(sdk, state_transition, operation, wait), + ) + .await } -/// 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`, and -/// the private contact request the spend pays for when it does. A spend -/// 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. -/// `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. +/// Save the recovery record of the built note-consuming spend +/// `state_transition` durably, hand its notes over, and only then run +/// `broadcast`, which sends it and waits for its result: the production flows +/// pass their classified broadcast, a test intercepts it. +/// +/// Each of `notes`' reservations first carries the spend's `anchor` +/// ([`arm_pending_release`]), so a pruned anchor releases it; the record is +/// then saved, keyed by `pending_entry` ([`arm_redrive_before_broadcast`]). A +/// spend with no entry to key its record by, or whose record cannot be saved, +/// is refused with nothing broadcast, and `reservation` still owns the notes: +/// the caller releases them ([`settle_failed_spend`]), and so does a spend +/// dropped while a save waits for the store. +/// +/// Once the record is saved the notes are handed over, with nothing awaited +/// in between: from then on they belong to the spend's resolution, whatever +/// happens to this call. An exit or a drop during the dispatches or the +/// result wait leaves the reservation and the record, which a restart +/// restores and the redrive pass resolves. #[allow(clippy::too_many_arguments)] -pub(crate) async fn arm_redrive_before_broadcast( +async fn save_spend_then_broadcast( store: &Arc>, id: SubwalletId, - activity_id: Option<[u8; 32]>, + pending_entry: &Option, anchor: [u8; 32], - nullifiers: Vec<[u8; 32]>, + anchor_height: u64, + notes: &[ShieldedNote], + reservation: &mut NoteReservation, state_transition: &StateTransition, operation: &'static str, - private_contact: Option, - payment_started: Option<&PaymentStarted>, - anchor_recorded_at: u64, -) -> 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 journal = match private_contact { - Some(payment) => Some(( - payment, - private_document_entries(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, + broadcast: B, +) -> Result +where + S: ShieldedStore, + B: FnOnce() -> Fut, + Fut: Future>, +{ + arm_pending_release(store, id, anchor, anchor_height, pending_entry, notes).await?; + arm_redrive_before_broadcast( + store, + id, + pending_entry.as_ref().map(|entry| entry.id), + anchor, + notes.iter().map(|note| note.nullifier).collect(), + state_transition, + operation, + None, + None, + anchor_height, + ) + .await?; + // Saved: the notes are the spend's resolution's from here on. + reservation.hand_over(); + broadcast().await +} + +/// Settle the notes, saved record and activity row of a note-consuming spend +/// (transfer, unshield, withdrawal, identity top-up, identity create) that +/// ended with `failure`, and return the error it reports. +/// +/// - Its transition never left the wallet ([`SpendFailure::never_delivered`]: +/// refused before anything was sent), so nothing can execute it: its record +/// is retired and its notes are released under one store guard +/// ([`NoteReservation::retire_undelivered`]), and the activity row reads +/// failed. When the record cannot be retired (a redrive pass claimed a +/// re-broadcast of it meanwhile, or the write failed), the transition may +/// leave the wallet after all: everything stays and the spend reads as +/// unconfirmed. +/// - Its record was saved (`reservation` is handed over, +/// [`save_spend_then_broadcast`]) and a dispatch may have reached a node: +/// it may execute. The notes stay reserved and the record stays, for the +/// scan and the redrive pass to resolve, and the activity row stays +/// pending. It reads as unconfirmed, whatever error ended it. +/// - It failed before its record was saved: nothing was broadcast. The notes +/// are released and the activity row reads failed. +#[allow(clippy::too_many_arguments)] +async fn settle_failed_spend( + store: &Arc>, + persister: Option<&WalletPersister>, + wallet_id: WalletId, + id: SubwalletId, + pending_entry: &Option, + mut reservation: NoteReservation, + operation: &'static str, + failure: SpendFailure, +) -> PlatformWalletError { + let SpendFailure { + error, + never_delivered, + } = failure; + if never_delivered { + let retired = match pending_entry.as_ref().map(|entry| entry.id) { + Some(activity_id) => reservation.retire_undelivered(&activity_id, &[]).await, + None => { + reservation.hand_over(); + false + } + }; + if !retired { + return PlatformWalletError::ShieldedSpendUnconfirmed { + operation, + 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; + return error; + } + if reservation.handed_over { + return match error { + unconfirmed @ (PlatformWalletError::ShieldedSpendUnconfirmed { .. } + | PlatformWalletError::ShieldedBroadcastUnconfirmed { .. }) => unconfirmed, + other => { + // The reported error keeps the cause's text alone: its type + // goes to the log. + warn!( + account = id.account_index, + operation, + cause = ?other, + "Shielded spend ended with its record saved; reporting it unconfirmed" + ); + PlatformWalletError::ShieldedSpendUnconfirmed { + operation, + reason: other.to_string(), + } + } + }; + } + // The guard owns the notes until they are released, so a drop on the + // way still releases them. + record_activity_status( + store, + persister, + wallet_id, + id, + pending_entry, + ShieldedActivityStatus::Failed, + None, + ) + .await; + reservation.cancel().await; + error +} + +/// 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`, and +/// the private contact request the spend pays for when it does. A spend +/// 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. +/// `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>, + id: SubwalletId, + activity_id: Option<[u8; 32]>, + anchor: [u8; 32], + nullifiers: Vec<[u8; 32]>, + state_transition: &StateTransition, + operation: &'static str, + private_contact: Option, + payment_started: Option<&PaymentStarted>, + anchor_recorded_at: u64, +) -> 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 journal = match private_contact { + Some(payment) => Some(( + payment, + private_document_entries(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, nullifiers, st_bytes, @@ -3995,58 +4193,6 @@ pub(crate) fn private_document_commitments( .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 -/// 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, -) { - use dpp::serialization::PlatformSerializable; - - let Some(entry) = pending_entry else { - return; - }; - let st_bytes = match state_transition.serialize_to_bytes() { - Ok(b) => b, - Err(e) => { - warn!( - operation, - error = %e, - "failed to serialize the unconfirmed transition; re-drive disabled for this \ - spend (prune backstop still applies)" - ); - return; - } - }; - let redrive = PendingRedrive { - activity_id: entry.id, - anchor, - nullifiers: notes.iter().map(|n| n.nullifier).collect(), - st_bytes, - 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!( - operation, - error = %e, - "failed to persist the redrive record; re-drive disabled for this spend (prune \ - backstop still applies)" - ); - } -} - /// Whether an SDK error is Platform's `NullifierAlreadySpentError` — /// on a RE-broadcast of our own byte-identical transition this means /// the ORIGINAL broadcast executed: the nullifiers are consumed by the @@ -4387,36 +4533,8 @@ pub(super) async fn redrive_pending_spends( } } -/// 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 -/// spend DEFINITIVELY did not happen: -/// -/// - a definitive `broadcast()` failure ([`broadcast_definitely_failed`]: -/// a consensus-verdict CheckTx rejection, or a transport failure that -/// proves nothing was delivered), or a wait-stage consensus rejection -/// (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 [`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 -/// classifies the residual ambiguity; -/// - any other wait failure (transport error, timeout, result-proof -/// fetch/verify error, …) is AMBIGUOUS — the relay accepted (or may -/// have accepted) the tx and it may well have executed → -/// [`PlatformWalletError::ShieldedSpendUnconfirmed`], and the caller -/// must leave the reservations in place (each spend flow's outer -/// match has a dedicated arm for this). -/// -/// Mirrors the staging in [`identity_create_from_shielded_pool`], minus -/// its fetch-by-derived-id fallback: a spend leaves no artifact as -/// cheaply queryable as an identity row, so ambiguity is surfaced -/// directly and reconciled by the next nullifier sync. The proven -/// result is discarded; only the confirmation matters. -/// How [`broadcast_shielded_spend`] waits for an already-broadcast spend's -/// result, mirroring the verifier's classification of the family. +/// How [`broadcast_shielded_spend_classified`] waits for an already-broadcast +/// spend's result, mirroring the verifier's classification of the family. #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum SpendResultWait { /// The family is classified execution-proving (transfer, unshield, @@ -4429,19 +4547,10 @@ enum SpendResultWait { AffectedState, } -async fn broadcast_shielded_spend( - sdk: &Arc, - state_transition: &StateTransition, - operation: &'static str, - wait: SpendResultWait, -) -> Result<(StateTransitionProofResult, ResponseMetadata), PlatformWalletError> { - 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. +/// A failed note-consuming spend: the error it reports, and whether its +/// transition never left the wallet. Every failure before the broadcast, and +/// every one after a dispatch that may have reached a node, is built from its +/// error alone ([`From`]), with `never_delivered` false. struct SpendFailure { error: PlatformWalletError, /// The broadcast proved nothing was delivered @@ -4459,8 +4568,25 @@ impl From for SpendFailure { } } -/// [`broadcast_shielded_spend`], telling a failure whose transition never -/// left the wallet from one that may have reached the network. +/// Broadcast a built shielded spend transition and wait for its proven +/// result, telling a failure whose transition never left the wallet from one +/// that may have reached the network: +/// +/// - refused before anything was sent ([`BroadcastDelivery::NeverDelivered`]): +/// [`PlatformWalletError::ShieldedBroadcastFailed`], with +/// [`SpendFailure::never_delivered`] set, so the caller retires the saved +/// record and releases the notes; +/// - any dispatch that may have reached a node falls through to the result +/// wait, which either proves the outcome or leaves it unknown; +/// - any wait failure, a consensus verdict included +/// ([`classify_spend_wait_failure`]): +/// [`PlatformWalletError::ShieldedSpendUnconfirmed`], and the caller leaves +/// the reservations and the saved record in place. +/// +/// A spend leaves no artifact as cheaply queryable as the identity row +/// [`broadcast_identity_create`] falls back to, so an unknown outcome is +/// surfaced directly and resolved by the next nullifier sync and the redrive +/// pass. async fn broadcast_shielded_spend_classified( sdk: &Arc, state_transition: &StateTransition, @@ -4516,7 +4642,7 @@ fn definite_broadcast_failure(e: &dash_sdk::Error) -> PlatformWalletError { } /// Classify a `wait_for_response` failure for an already-broadcast -/// shielded spend (see [`broadcast_shielded_spend`]): unconfirmed, whatever +/// shielded spend (see [`broadcast_shielded_spend_classified`]): unconfirmed, whatever /// it is, the notes staying reserved. /// /// A consensus verdict on the wait ([`carries_consensus_rejection`]) is no @@ -4695,6 +4821,7 @@ mod shield_recipient_tests { #[cfg(test)] mod redrive_tests { use super::*; + use crate::broadcast_outcome::test_sdks::{sdk_refusing_connections, sdk_without_addresses}; use crate::wallet::identity::network::PaymentStarted; use crate::wallet::shielded::activity::ShieldedActivityEntry; use crate::wallet::shielded::store::InMemoryShieldedStore; @@ -4714,6 +4841,9 @@ mod redrive_tests { 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::state_transition::shield_transition::v0::ShieldTransitionV0; + use dpp::state_transition::shielded_transfer_transition::v0::ShieldedTransferTransitionV0; + use dpp::state_transition::state_transitions::shielded::identity_create_from_shielded_pool_transition::v0::IdentityCreateFromShieldedPoolTransitionV0; use dpp::version::PlatformVersion; use futures::{poll, FutureExt}; use grovedb_commitment_tree::ProvingKey; @@ -6232,95 +6362,1199 @@ mod redrive_tests { assert_eq!(left[0].activity_id, [1; 32]); assert_eq!(left[0].attempts, 1, "claimed once, before its broadcast"); } -} -#[cfg(test)] -mod classify_spend_wait_failure_tests { - use super::*; - use dash_sdk::error::StateTransitionBroadcastError; - use dpp::consensus::basic::decode::ProtocolVersionParsingError; - use dpp::consensus::basic::BasicError; - use dpp::consensus::ConsensusError; + // ── Note-consuming spends: saved before the first dispatch ───────── - fn broadcast_err(cause: Option) -> dash_sdk::Error { - dash_sdk::Error::StateTransitionBroadcastError(StateTransitionBroadcastError { - code: 13, - message: "context deadline exceeded".to_string(), - cause, - }) + /// The anchor the tests' spends are built on, and the proved height of + /// the anchor set it was read from. + const SPEND_ANCHOR: [u8; 32] = [0x6E; 32]; + const SPEND_ANCHOR_HEIGHT: u64 = 5; + + /// The identity the tests' identity create derives. + const CREATED_IDENTITY: [u8; 32] = [0x33; 32]; + + fn spend_action() -> SerializedAction { + SerializedAction { + nullifier: [1; 32], + rk: [2; 32], + cmx: [3; 32], + encrypted_note: vec![4; 216], + cv_net: [5; 32], + spend_auth_sig: [6; 64], + } } - /// 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 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::ShieldedSpendUnconfirmed { .. }), - "{err:?}" - ); + /// The note the tests' spends consume. + fn spend_note() -> ShieldedNote { + ShieldedNote { + position: 0, + cmx: [1; 32], + nullifier: [0x5C; 32], + block_height: 1, + is_spent: false, + value: 1_000_000_000_000, + note_data: vec![], + } } - #[test] - fn causeless_broadcast_error_is_ambiguous() { - // DAPI maps its own wait failures (e.g. `DapiError::Timeout`) to a - // `StateTransitionBroadcastError` with EMPTY consensus data, which the - // SDK decodes as `cause: None`. The spend may still execute after the - // wait gave up, so this must NOT release the note reservations. - let err = classify_spend_wait_failure("unshield", &broadcast_err(None)); - assert!( - matches!( - err, - PlatformWalletError::ShieldedSpendUnconfirmed { - operation: "unshield", - .. + /// The note-consuming spends, by the code that saves, broadcasts and + /// waits for them. + #[derive(Clone, Copy, Debug)] + enum SpendPath { + /// Transfer, unshield, withdrawal and identity top-up + /// ([`broadcast_shielded_spend_with_redrive_with_metadata`]): here a + /// transfer. + Shared, + /// Identity create ([`broadcast_identity_create_with_redrive`]). + IdentityCreate, + } + + impl SpendPath { + /// A signed transition of the path, as it saves and broadcasts one. + fn transition(self) -> StateTransition { + match self { + Self::Shared => ShieldedTransferTransitionV0 { + actions: vec![spend_action(), spend_action()], + value_balance: 1_000, + anchor: SPEND_ANCHOR, + proof: vec![8; 64], + binding_signature: [9; 64], } - ), - "got {err:?}" - ); - } + .into(), + Self::IdentityCreate => IdentityCreateFromShieldedPoolTransitionV0 { + public_keys: vec![], + denomination: 1_000_000, + actions: vec![spend_action(), spend_action()], + anchor: SPEND_ANCHOR, + proof: vec![8; 64], + binding_signature: [9; 64], + send_to_address_on_creation_failure: PlatformAddress::P2pkh([7; 20]), + identity_id: Identifier::from(CREATED_IDENTITY), + } + .into(), + } + } - #[test] - fn transport_errors_are_ambiguous() { - let err = classify_spend_wait_failure( - "withdraw", - &dash_sdk::Error::TimeoutReached( - std::time::Duration::from_secs(80), - "waiting for response".to_string(), - ), - ); - assert!(matches!( - err, - PlatformWalletError::ShieldedSpendUnconfirmed { - operation: "withdraw", - .. + /// Save, broadcast and wait for the path's transition on `sdk`, as + /// its production flow does once the transition is built. + async fn broadcast( + self, + sdk: &Arc, + spend: &mut ReservedSpend, + id: SubwalletId, + ) -> Result<(), SpendFailure> { + let transition = self.transition(); + match self { + Self::Shared => broadcast_shielded_spend_with_redrive_with_metadata( + sdk, + &spend.store, + id, + &spend.entry, + SPEND_ANCHOR, + SPEND_ANCHOR_HEIGHT, + &spend.notes, + &mut spend.reservation, + &transition, + "transfer", + SpendResultWait::ExecutionProved, + ) + .await + .map(|_| ()), + Self::IdentityCreate => broadcast_identity_create_with_redrive( + sdk, + &spend.store, + id, + &spend.entry, + SPEND_ANCHOR, + SPEND_ANCHOR_HEIGHT, + &spend.notes, + &mut spend.reservation, + &transition, + Identifier::from(CREATED_IDENTITY), + BTreeMap::new(), + ) + .await + .map(|_| ()), } - )); - } + } - /// The shape a CheckTx rejection takes when it reaches the SDK from - /// `broadcast()`: DAPI re-attaches the serialized consensus error as - /// gRPC metadata and the dapi-client decodes it into - /// `Error::Protocol(ConsensusError)`. - fn consensus_metadata_rejection() -> dash_sdk::Error { + /// Settle the path's failed spend, as its production flow does, and + /// return the error it reports. + async fn settle( + self, + spend: ReservedSpend, + wallet_id: WalletId, + id: SubwalletId, + failure: SpendFailure, + ) -> PlatformWalletError { + let ReservedSpend { + store, + reservation, + entry, + .. + } = spend; + match self { + Self::Shared => { + settle_failed_spend( + &store, + None, + wallet_id, + id, + &entry, + reservation, + "transfer", + failure, + ) + .await + } + Self::IdentityCreate => identity_create_failure( + settle_failed_spend( + &store, + None, + wallet_id, + id, + &entry, + reservation, + IDENTITY_CREATE_OPERATION, + failure, + ) + .await, + Some(Identifier::from(CREATED_IDENTITY)), + ), + } + } + + /// Whether `error` is the path's report of a spend whose outcome is + /// not known. + fn reads_unconfirmed(self, error: &PlatformWalletError) -> bool { + match self { + Self::Shared => matches!( + error, + PlatformWalletError::ShieldedSpendUnconfirmed { + operation: "transfer", + .. + } + ), + Self::IdentityCreate => matches!( + error, + PlatformWalletError::ShieldedBroadcastUnconfirmed { identity_id, .. } + if *identity_id == Identifier::from(CREATED_IDENTITY) + ), + } + } + } + + /// A spend right before it saves its record: its note reserved in a + /// file-backed store, and its pending activity entry recorded. + struct ReservedSpend { + store: Arc>, + notes: Vec, + reservation: NoteReservation, + entry: Option, + } + + impl ReservedSpend { + async fn at(path: &std::path::Path, id: SubwalletId, activity_id: [u8; 32]) -> Self { + let sdk = Arc::new(dash_sdk::Sdk::new_mock()); + let store = Arc::new(RwLock::new( + FileBackedShieldedStore::open_path(path, 100).expect("the store"), + )); + store + .write() + .await + .save_note(id, &spend_note()) + .expect("a note"); + let (notes, _, _, reservation) = + reserve_notes_covering(&sdk, &store, id, 1, 2, ShieldedFeeKind::Base) + .await + .expect("reserved"); + let entry = pending_entry(activity_id); + store + .write() + .await + .save_activity(id, &entry) + .expect("the pending row"); + Self { + store, + notes, + reservation, + entry: Some(entry), + } + } + } + + /// Make the store at `path` refuse to save a spend's record. + fn refuse_record_saves(path: &std::path::Path) { + 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"); + } + + /// The store at `path` as a restart opens it, the wallet's note loaded + /// into it again. + fn restarted_store(path: &std::path::Path, id: SubwalletId) -> FileBackedShieldedStore { + let mut store = FileBackedShieldedStore::open_path(path, 100).expect("reopened"); + store + .save_note(id, &spend_note()) + .expect("the note, loaded again"); + store + } + + /// How many notes of `id` a spend can select, and the status of the + /// activity row `activity_id`. + async fn spendable_and_status( + store: &Arc>, + id: SubwalletId, + activity_id: &[u8; 32], + ) -> (usize, ShieldedActivityStatus) { + let store = store.read().await; + ( + store.get_unspent_notes(id).expect("notes").len(), + store + .get_activity_by_entry_id(id, activity_id) + .expect("the row reads") + .expect("the row") + .status, + ) + } + + /// A note-consuming spend saves its record durably before its first + /// dispatch, and hands its notes over only then. So a process that exits + /// between the broadcast and the result (here the spend is dropped while + /// its result wait is pending, and nothing settles it) leaves the record: + /// the store a restart opens holds the notes' reservation and the exact + /// signed transition, the redrive pass re-broadcasts it, and the scan + /// that sees its nullifier spent resolves it. + #[tokio::test] + async fn should_restore_a_spend_that_exited_between_its_broadcast_and_its_result() { + use dash_sdk::platform::transition::broadcast_request::BroadcastRequestForStateTransition; + use std::task::{Context, Waker}; + + let mut sdk = dash_sdk::SdkBuilder::new_mock().build().expect("mock sdk"); + let sdk_handle = Arc::new(sdk.clone()); + let id = SubwalletId::new([0x71; 32], 0); + let activity_id = [0x7A; 32]; + let dir = tempfile::tempdir().expect("a directory"); + let path = dir.path().join("tree.sqlite"); + let ReservedSpend { + store, + notes, + mut reservation, + entry, + } = ReservedSpend::at(&path, id, activity_id).await; + let transition = SpendPath::Shared.transition(); + let signed = transition.serialize_to_bytes().expect("serializes"); + + let mut cx = Context::from_waker(Waker::noop()); + let mut spend = Box::pin(save_spend_then_broadcast( + &store, + id, + &entry, + SPEND_ANCHOR, + SPEND_ANCHOR_HEIGHT, + ¬es, + &mut reservation, + &transition, + "transfer", + || { + // Nothing is dispatched before the record is on the file. + let durable = + FileBackedShieldedStore::open_path(&path, 100).expect("a second handle"); + let saved = durable.pending_redrives(id).expect("the records"); + assert_eq!(saved.len(), 1, "saved before the first dispatch"); + assert_eq!(saved[0].st_bytes, signed); + // Dispatched; its result never comes. + std::future::pending::>() + }, + )); + assert!( + spend.as_mut().poll(&mut cx).is_pending(), + "broadcast, waiting for its result" + ); + // The process exits here: the spend never returns, nothing settles it. + drop(spend); + drop(reservation); + assert!( + store + .read() + .await + .get_unspent_notes(id) + .expect("notes") + .is_empty(), + "the dropped spend's notes stay reserved" + ); + drop(store); + + let restarted = Arc::new(RwLock::new(restarted_store(&path, id))); + { + let store = restarted.read().await; + assert!( + store.get_unspent_notes(id).expect("notes").is_empty(), + "the reservation is restored from the record" + ); + let saved = store.pending_redrives(id).expect("the records"); + assert_eq!(saved.len(), 1); + assert_eq!(saved[0].st_bytes, signed, "the exact signed transition"); + assert_eq!(saved[0].nullifiers, vec![spend_note().nullifier]); + assert_eq!(saved[0].anchor, SPEND_ANCHOR); + assert_eq!(saved[0].anchor_height, Some(SPEND_ANCHOR_HEIGHT)); + assert_eq!(saved[0].attempts, 0); + } + + let recorded = [SPEND_ANCHOR].into_iter().collect(); + redrive_pending_spends( + &sdk_handle, + &restarted, + id, + &recorded, + &CancellationToken::new(), + ) + .await; + let request = transition + .broadcast_request_for_state_transition() + .expect("serializes"); + assert!( + sdk.mock().request_count(&request).await > 0, + "the redrive pass re-broadcasts the saved transition" + ); + assert_eq!( + restarted + .read() + .await + .pending_redrives(id) + .expect("records")[0] + .attempts, + 1 + ); + + // The next scan sees the spend's nullifier on chain. + assert!(restarted + .write() + .await + .mark_spent(id, &spend_note().nullifier) + .expect("marked")); + drop(restarted); + assert!( + FileBackedShieldedStore::open_path(&path, 100) + .expect("reopened") + .pending_redrives(id) + .expect("records") + .is_empty(), + "resolved for good" + ); + } + + /// A failed spend is settled by what is known about its transition. One + /// that never left the wallet has its record retired and its notes + /// released together, unless a redrive pass claimed a re-broadcast of the + /// record meanwhile: it may then leave the wallet after all, and is kept + /// as unconfirmed. One whose record was saved and that may have reached a + /// node keeps its notes and its record whatever error ended it, and reads + /// as unconfirmed. One whose record could not be saved was never + /// dispatched, and releases its notes. + #[tokio::test] + async fn should_release_a_failed_spend_s_notes_only_when_its_transition_cannot_execute() { + use std::sync::atomic::{AtomicBool, Ordering}; + + let wallet_id = [0x72; 32]; + let id = SubwalletId::new(wallet_id, 0); + let activity_id = [0x7B; 32]; + let transition = SpendPath::Shared.transition(); + let never_delivered = || SpendFailure { + error: PlatformWalletError::ShieldedBroadcastFailed("no address".to_string()), + never_delivered: true, + }; + for outcome in [ + "never delivered", + "never delivered, re-broadcast claimed", + "unconfirmed", + "failed after its record was saved", + "its record could not be saved", + ] { + let dir = tempfile::tempdir().expect("a directory"); + let path = dir.path().join("tree.sqlite"); + let mut spend = ReservedSpend::at(&path, id, activity_id).await; + if outcome == "its record could not be saved" { + refuse_record_saves(&path); + } + let dispatched = AtomicBool::new(false); + let store = Arc::clone(&spend.store); + let result: Result<(), SpendFailure> = save_spend_then_broadcast( + &spend.store, + id, + &spend.entry, + SPEND_ANCHOR, + SPEND_ANCHOR_HEIGHT, + &spend.notes, + &mut spend.reservation, + &transition, + "transfer", + || async { + dispatched.store(true, Ordering::SeqCst); + Err(match outcome { + "never delivered" => never_delivered(), + "never delivered, re-broadcast claimed" => { + // A redrive pass re-broadcast the saved record + // while the spend's own broadcast ran. + store + .write() + .await + .bump_redrive_attempts(id, &activity_id) + .expect("claimed"); + never_delivered() + } + "unconfirmed" => PlatformWalletError::ShieldedSpendUnconfirmed { + operation: "transfer", + reason: "the result wait timed out".to_string(), + } + .into(), + _ => PlatformWalletError::ShieldedBuildError( + "its proved result did not read".to_string(), + ) + .into(), + }) + }, + ) + .await; + let failure = result.expect_err("the spend failed"); + let store = Arc::clone(&spend.store); + let settled = SpendPath::Shared + .settle(spend, wallet_id, id, failure) + .await; + let (spendable, status) = spendable_and_status(&store, id, &activity_id).await; + drop(store); + let saved = FileBackedShieldedStore::open_path(&path, 100) + .expect("reopened") + .pending_redrives(id) + .expect("records"); + match outcome { + "never delivered" => { + assert!( + matches!(settled, PlatformWalletError::ShieldedBroadcastFailed(_)), + "{outcome}: {settled:?}" + ); + assert_eq!(spendable, 1, "{outcome}: the notes are released"); + assert!(saved.is_empty(), "{outcome}: the record is retired"); + assert_eq!(status, ShieldedActivityStatus::Failed, "{outcome}"); + } + "its record could not be saved" => { + assert!( + matches!(settled, PlatformWalletError::ShieldedStoreError(_)), + "{outcome}: {settled:?}" + ); + assert!(!dispatched.load(Ordering::SeqCst), "never dispatched"); + assert_eq!(spendable, 1, "{outcome}: the notes are released"); + assert!(saved.is_empty(), "{outcome}"); + assert_eq!(status, ShieldedActivityStatus::Failed, "{outcome}"); + } + _ => { + assert!( + SpendPath::Shared.reads_unconfirmed(&settled), + "{outcome}: {settled:?}" + ); + assert_eq!(spendable, 0, "{outcome}: the notes stay reserved"); + assert_eq!(saved.len(), 1, "{outcome}: the record stays"); + assert_eq!(status, ShieldedActivityStatus::Pending, "{outcome}"); + } + } + } + } + + /// Each note-consuming spend path, on an SDK whose node answers every + /// request with an error: a dispatch may have reached the node, so the + /// spend is unconfirmed, never failed. Its notes stay reserved and its + /// record stays, across a restart too, where the redrive pass + /// re-broadcasts the exact signed transition and the scan that sees its + /// nullifier spent resolves it. + #[tokio::test(start_paused = true)] + async fn should_keep_a_spend_that_may_have_reached_a_node_until_it_resolves() { + use dash_sdk::platform::transition::broadcast_request::BroadcastRequestForStateTransition; + + let wallet_id = [0x73; 32]; + let id = SubwalletId::new(wallet_id, 0); + let activity_id = [0x7C; 32]; + for spend_path in [SpendPath::Shared, SpendPath::IdentityCreate] { + let mut sdk = dash_sdk::SdkBuilder::new_mock().build().expect("mock sdk"); + let sdk_handle = Arc::new(sdk.clone()); + let dir = tempfile::tempdir().expect("a directory"); + let path = dir.path().join("tree.sqlite"); + let mut spend = ReservedSpend::at(&path, id, activity_id).await; + let transition = spend_path.transition(); + let request = transition + .broadcast_request_for_state_transition() + .expect("serializes"); + + let failure = spend_path + .broadcast(&sdk_handle, &mut spend, id) + .await + .expect_err("its result is not proved"); + assert!(!failure.never_delivered, "{spend_path:?}"); + let dispatches = sdk.mock().request_count(&request).await; + assert!(dispatches > 0, "{spend_path:?}: it was dispatched"); + let store = Arc::clone(&spend.store); + let settled = spend_path.settle(spend, wallet_id, id, failure).await; + assert!( + spend_path.reads_unconfirmed(&settled), + "{spend_path:?}: {settled:?}" + ); + assert_eq!( + spendable_and_status(&store, id, &activity_id).await, + (0, ShieldedActivityStatus::Pending), + "{spend_path:?}: the notes stay reserved, the row pending" + ); + drop(store); + + let restarted = Arc::new(RwLock::new(restarted_store(&path, id))); + { + let store = restarted.read().await; + assert!( + store.get_unspent_notes(id).expect("notes").is_empty(), + "{spend_path:?}: the reservation is restored from the record" + ); + let saved = store.pending_redrives(id).expect("the records"); + assert_eq!(saved.len(), 1, "{spend_path:?}"); + assert_eq!( + saved[0].st_bytes, + transition.serialize_to_bytes().expect("serializes"), + "{spend_path:?}: the exact signed transition" + ); + } + let recorded = [SPEND_ANCHOR].into_iter().collect(); + redrive_pending_spends( + &sdk_handle, + &restarted, + id, + &recorded, + &CancellationToken::new(), + ) + .await; + assert!( + sdk.mock().request_count(&request).await > dispatches, + "{spend_path:?}: the redrive pass re-broadcasts it" + ); + assert!(restarted + .write() + .await + .mark_spent(id, &spend_note().nullifier) + .expect("marked")); + assert!( + restarted + .read() + .await + .pending_redrives(id) + .expect("records") + .is_empty(), + "{spend_path:?}: resolved once its nullifier is seen spent" + ); + } + } + + /// Each note-consuming spend path, on an SDK with no address to send to: + /// the transition never left the wallet. Its record was saved before the + /// dispatch that was refused; the settlement retires it and frees the + /// notes, and the spend reads as failed. + #[tokio::test] + async fn should_retire_the_record_of_a_spend_that_never_left_the_wallet() { + let wallet_id = [0x74; 32]; + let id = SubwalletId::new(wallet_id, 0); + let activity_id = [0x7D; 32]; + for spend_path in [SpendPath::Shared, SpendPath::IdentityCreate] { + let sdk = Arc::new(sdk_without_addresses()); + let dir = tempfile::tempdir().expect("a directory"); + let path = dir.path().join("tree.sqlite"); + let mut spend = ReservedSpend::at(&path, id, activity_id).await; + + let failure = spend_path + .broadcast(&sdk, &mut spend, id) + .await + .expect_err("nothing to send it to"); + assert!( + failure.never_delivered, + "{spend_path:?}: {:?}", + failure.error + ); + let store = Arc::clone(&spend.store); + assert_eq!( + store + .read() + .await + .pending_redrives(id) + .expect("records") + .len(), + 1, + "{spend_path:?}: its record was saved before its dispatch" + ); + let settled = spend_path.settle(spend, wallet_id, id, failure).await; + assert!( + matches!(settled, PlatformWalletError::ShieldedBroadcastFailed(_)), + "{spend_path:?}: {settled:?}" + ); + assert_eq!( + spendable_and_status(&store, id, &activity_id).await, + (1, ShieldedActivityStatus::Failed), + "{spend_path:?}: the notes are free, the row failed" + ); + drop(store); + assert!( + FileBackedShieldedStore::open_path(&path, 100) + .expect("reopened") + .pending_redrives(id) + .expect("records") + .is_empty(), + "{spend_path:?}: no record is left" + ); + } + } + + /// Each note-consuming spend path whose record cannot be saved dispatches + /// nothing: the spend is refused, and its notes are released. + #[tokio::test] + async fn should_not_dispatch_a_spend_whose_record_cannot_be_saved() { + use dash_sdk::platform::transition::broadcast_request::BroadcastRequestForStateTransition; + + let wallet_id = [0x75; 32]; + let id = SubwalletId::new(wallet_id, 0); + let activity_id = [0x7E; 32]; + for spend_path in [SpendPath::Shared, SpendPath::IdentityCreate] { + let mut sdk = dash_sdk::SdkBuilder::new_mock().build().expect("mock sdk"); + let sdk_handle = Arc::new(sdk.clone()); + let dir = tempfile::tempdir().expect("a directory"); + let path = dir.path().join("tree.sqlite"); + let mut spend = ReservedSpend::at(&path, id, activity_id).await; + refuse_record_saves(&path); + + let failure = spend_path + .broadcast(&sdk_handle, &mut spend, id) + .await + .expect_err("its record is not saved"); + assert!( + matches!(failure.error, PlatformWalletError::ShieldedStoreError(_)), + "{spend_path:?}: {:?}", + failure.error + ); + let request = spend_path + .transition() + .broadcast_request_for_state_transition() + .expect("serializes"); + assert_eq!( + sdk.mock().request_count(&request).await, + 0, + "{spend_path:?}: nothing was dispatched" + ); + let store = Arc::clone(&spend.store); + let settled = spend_path.settle(spend, wallet_id, id, failure).await; + assert!( + matches!(settled, PlatformWalletError::ShieldedStoreError(_)), + "{spend_path:?}: {settled:?}" + ); + assert_eq!( + spendable_and_status(&store, id, &activity_id).await, + (1, ShieldedActivityStatus::Failed), + "{spend_path:?}: the notes are free, the row failed" + ); + } + } + + // ── Shields from Platform addresses ──────────────────────────────── + + /// A signed shield from a Platform address (type 15). + fn shield_transition() -> StateTransition { + ShieldTransitionV0 { + inputs: BTreeMap::from([(SHIELD_INPUT, (SHIELD_NONCE, 2_000_000))]), + actions: vec![spend_action(), spend_action()], + amount: 1_000_000, + anchor: SPEND_ANCHOR, + proof: vec![8; 64], + binding_signature: [9; 64], + fee_strategy: vec![AddressFundsFeeStrategyStep::DeductFromInput(0)], + user_fee_increase: 0, + input_witnesses: vec![], + } + .into() + } + + /// A node's refusal of a dispatch, as an offline or overloaded endpoint + /// answers one. + fn unavailable() -> dash_sdk::Error { + use dash_sdk::dapi_client::transport::TransportError; + use dash_sdk::dapi_client::DapiClientError; + use dash_sdk::dapi_grpc::tonic::{Code, Status}; + + dash_sdk::Error::DapiClientError(DapiClientError::Transport(TransportError::Grpc( + Status::new(Code::Unavailable, "test"), + ))) + } + + /// A stale-nonce verdict on the shield's input, as a broadcast or as the + /// result wait returns one. + fn nonce_verdict(on_the_wait: bool) -> dash_sdk::Error { + use dpp::consensus::state::address_funds::AddressInvalidNonceError; + + let verdict = ConsensusError::StateError(StateError::AddressInvalidNonceError( + AddressInvalidNonceError::new(PlatformAddress::P2pkh([7; 20]), 1, 2), + )); + if on_the_wait { + dash_sdk::Error::StateTransitionBroadcastError(StateTransitionBroadcastError { + code: 1, + message: "rejected".to_string(), + cause: Some(verdict), + }) + } else { + dash_sdk::Error::Protocol(dpp::ProtocolError::ConsensusError(Box::new(verdict))) + } + } + + fn wait_timeout() -> dash_sdk::Error { + dash_sdk::Error::TimeoutReached(std::time::Duration::from_secs(80), "waiting".to_string()) + } + + /// The input address of [`shield_transition`] and the nonce it signed. + const SHIELD_INPUT: PlatformAddress = PlatformAddress::P2pkh([7; 20]); + const SHIELD_NONCE: AddressNonce = 1; + + /// What [`shield_transition`] signed: its input addresses and nonces. + fn shield_inputs() -> BTreeMap { + shield_transition() + .inputs() + .expect("a shield spends from addresses") + .clone() + } + + /// A verified result reading the shield's input address at `nonce`, or + /// with no state when `None`. + fn input_snapshot(nonce: Option) -> StateTransitionProofResult { + StateTransitionProofResult::VerifiedAddressInfos(BTreeMap::from([( + SHIELD_INPUT, + nonce.map(|nonce| (nonce, 1_000_000)), + )])) + } + + /// An in-memory store holding the pending activity row `activity_id` of a + /// shield of `id`, and the row's entry. + async fn recorded_shield( + id: SubwalletId, + activity_id: [u8; 32], + ) -> ( + Arc>, + Option, + ) { + let store = Arc::new(RwLock::new(InMemoryShieldedStore::new())); + let entry = pending_entry(activity_id); + store + .write() + .await + .save_activity(id, &entry) + .expect("the pending row"); + (store, Some(entry)) + } + + async fn shield_status( + store: &Arc>, + id: SubwalletId, + activity_id: &[u8; 32], + ) -> ShieldedActivityStatus { + store + .read() + .await + .get_activity_by_entry_id(id, activity_id) + .expect("the row reads") + .expect("the row") + .status + } + + /// Once a dispatch may have reached a node, a shield whose result is not + /// proved is unconfirmed and its row stays pending, never failed: a node's + /// refusal of the broadcast and a consensus verdict on either call + /// included (here a stale nonce, which used to read as a retryable nonce + /// mismatch), as no proof backs an endpoint's answer and a retry signs a + /// second shield. The reason keeps what the broadcast was answered with. + #[tokio::test] + async fn should_report_a_shield_unconfirmed_once_a_dispatch_may_have_reached_a_node() { + let wallet_id = [0x76; 32]; + let id = SubwalletId::new(wallet_id, 0); + let activity_id = [0x7F; 32]; + let cases = [ + ( + "a node refused the broadcast, the wait timed out", + BroadcastDelivery::MaybeDelivered(unavailable()), + wait_timeout(), + ), + ( + "the broadcast was answered with a consensus verdict", + BroadcastDelivery::MaybeDelivered(nonce_verdict(false)), + wait_timeout(), + ), + ( + "admitted, the wait was answered with a consensus verdict", + BroadcastDelivery::Admitted, + nonce_verdict(true), + ), + ( + "admitted, the wait timed out", + BroadcastDelivery::Admitted, + wait_timeout(), + ), + ]; + for (case, delivery, wait_error) in cases { + let (store, entry) = recorded_shield(id, activity_id).await; + let broadcast_answer = match &delivery { + BroadcastDelivery::MaybeDelivered(e) => Some(e.to_string()), + _ => None, + }; + let settled = settle_shield( + &store, + None, + wallet_id, + id, + &entry, + &shield_inputs(), + delivery, + |e: &dash_sdk::Error| e.to_string(), + || std::future::ready(Err(wait_error)), + ) + .await; + let Err(PlatformWalletError::ShieldedSpendUnconfirmed { + operation: "shield", + reason, + }) = settled + else { + panic!("{case}: {settled:?}"); + }; + if let Some(answer) = broadcast_answer { + assert!(reason.contains(&answer), "{case}: {reason}"); + } + assert_eq!( + shield_status(&store, id, &activity_id).await, + ShieldedActivityStatus::Pending, + "{case}" + ); + } + } + + /// Only a shield whose transition never left the wallet is failed, its + /// row with it, and nothing waits for its result. + #[tokio::test] + async fn should_report_a_shield_failed_only_when_nothing_was_sent() { + use dash_sdk::dapi_client::DapiClientError; + use std::cell::Cell; + + let wallet_id = [0x77; 32]; + let id = SubwalletId::new(wallet_id, 0); + let activity_id = [0x80; 32]; + let (store, entry) = recorded_shield(id, activity_id).await; + let waited = Cell::new(false); + let settled = settle_shield( + &store, + None, + wallet_id, + id, + &entry, + &shield_inputs(), + BroadcastDelivery::NeverDelivered(dash_sdk::Error::DapiClientError( + DapiClientError::NoAvailableAddresses, + )), + |e: &dash_sdk::Error| e.to_string(), + || { + waited.set(true); + std::future::ready(Err(wait_timeout())) + }, + ) + .await; + assert!( + matches!( + settled, + Err(PlatformWalletError::ShieldedBroadcastFailed(_)) + ), + "{settled:?}" + ); + assert!(!waited.get(), "nothing was sent, so nothing is waited for"); + assert_eq!( + shield_status(&store, id, &activity_id).await, + ShieldedActivityStatus::Failed + ); + } + + /// A shield succeeds once its verified result reads every input address + /// at or past the nonce it signed, whatever its broadcast was answered + /// with. Its row stays pending: the result proves the input addresses, + /// not this shield or its note, which the scan confirms. + #[tokio::test] + async fn should_accept_a_shield_whose_inputs_read_spent_and_leave_its_row_to_the_scan() { + let wallet_id = [0x78; 32]; + let id = SubwalletId::new(wallet_id, 0); + let activity_id = [0x81; 32]; + for (delivery, proved_nonce) in [ + (BroadcastDelivery::Admitted, SHIELD_NONCE), + (BroadcastDelivery::Admitted, SHIELD_NONCE + 1), + ( + BroadcastDelivery::MaybeDelivered(unavailable()), + SHIELD_NONCE, + ), + ( + BroadcastDelivery::MaybeDelivered(nonce_verdict(false)), + SHIELD_NONCE, + ), + ] { + let (store, entry) = recorded_shield(id, activity_id).await; + settle_shield( + &store, + None, + wallet_id, + id, + &entry, + &shield_inputs(), + delivery, + |e: &dash_sdk::Error| e.to_string(), + || std::future::ready(Ok(input_snapshot(Some(proved_nonce)))), + ) + .await + .expect("its inputs read spent"); + assert_eq!( + shield_status(&store, id, &activity_id).await, + ShieldedActivityStatus::Pending, + "nonce {proved_nonce}" + ); + } + } + + /// A verified result that does not read the shield's inputs spent shows + /// the shield had not executed at its block, whatever the endpoint that + /// returned it claims: a result of another kind, one that leaves an input + /// out or proves no state for it, and an input still below the nonce the + /// shield signed leave it unconfirmed, its row pending. + #[tokio::test] + async fn should_report_a_shield_unconfirmed_when_its_result_does_not_read_its_inputs_spent() { + let wallet_id = [0x7A; 32]; + let id = SubwalletId::new(wallet_id, 0); + let activity_id = [0x83; 32]; + let cases = [ + ( + "a result of another kind", + StateTransitionProofResult::VerifiedTokenBalanceAbsence(Identifier::from([1; 32])), + ), + ( + "an input left out", + StateTransitionProofResult::VerifiedAddressInfos(BTreeMap::from([( + PlatformAddress::P2pkh([8; 20]), + Some((SHIELD_NONCE, 1_000_000)), + )])), + ), + ("an input with no state", input_snapshot(None)), + ( + "an input below its signed nonce", + input_snapshot(Some(SHIELD_NONCE - 1)), + ), + ]; + for (case, snapshot) in cases { + let (store, entry) = recorded_shield(id, activity_id).await; + let settled = settle_shield( + &store, + None, + wallet_id, + id, + &entry, + &shield_inputs(), + BroadcastDelivery::Admitted, + |e: &dash_sdk::Error| e.to_string(), + || std::future::ready(Ok(snapshot)), + ) + .await; + assert!( + matches!( + settled, + Err(PlatformWalletError::ShieldedSpendUnconfirmed { + operation: "shield", + .. + }) + ), + "{case}: {settled:?}" + ); + assert_eq!( + shield_status(&store, id, &activity_id).await, + ShieldedActivityStatus::Pending, + "{case}" + ); + } + + // A transition that names no input shows nothing spent. + let (store, entry) = recorded_shield(id, activity_id).await; + let settled = settle_shield( + &store, + None, + wallet_id, + id, + &entry, + &BTreeMap::new(), + BroadcastDelivery::Admitted, + |e: &dash_sdk::Error| e.to_string(), + || std::future::ready(Ok(input_snapshot(Some(SHIELD_NONCE)))), + ) + .await; + assert!( + matches!( + settled, + Err(PlatformWalletError::ShieldedSpendUnconfirmed { .. }) + ), + "no input: {settled:?}" + ); + } + + /// The broadcast of a signed shield through a network SDK: a node that + /// refuses the connection leaves it unconfirmed and its row pending, as a + /// transport failure cannot show that nothing was sent, and an SDK with + /// no address to send to leaves it failed. + #[tokio::test] + async fn should_report_a_shield_unconfirmed_when_its_node_refuses_the_connection() { + let wallet_id = [0x79; 32]; + let id = SubwalletId::new(wallet_id, 0); + let activity_id = [0x82; 32]; + let transition = shield_transition(); + + let (store, entry) = recorded_shield(id, activity_id).await; + let (refusing, _port) = sdk_refusing_connections(); + let refused = broadcast_shield( + &Arc::new(refusing), + &store, + None, + wallet_id, + id, + &entry, + &transition, + |e: &dash_sdk::Error| e.to_string(), + ) + .await; + assert!( + matches!( + refused, + Err(PlatformWalletError::ShieldedSpendUnconfirmed { + operation: "shield", + .. + }) + ), + "{refused:?}" + ); + assert_eq!( + shield_status(&store, id, &activity_id).await, + ShieldedActivityStatus::Pending + ); + + let (store, entry) = recorded_shield(id, activity_id).await; + let never_sent = broadcast_shield( + &Arc::new(sdk_without_addresses()), + &store, + None, + wallet_id, + id, + &entry, + &transition, + |e: &dash_sdk::Error| e.to_string(), + ) + .await; + assert!( + matches!( + never_sent, + Err(PlatformWalletError::ShieldedBroadcastFailed(_)) + ), + "{never_sent:?}" + ); + assert_eq!( + shield_status(&store, id, &activity_id).await, + ShieldedActivityStatus::Failed + ); + } +} + +#[cfg(test)] +mod classify_spend_wait_failure_tests { + use super::*; + use dash_sdk::error::StateTransitionBroadcastError; + use dpp::consensus::basic::decode::ProtocolVersionParsingError; + use dpp::consensus::basic::BasicError; + use dpp::consensus::ConsensusError; + + fn broadcast_err(cause: Option) -> dash_sdk::Error { + dash_sdk::Error::StateTransitionBroadcastError(StateTransitionBroadcastError { + code: 13, + message: "context deadline exceeded".to_string(), + cause, + }) + } + + /// 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 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::ShieldedSpendUnconfirmed { .. }), + "{err:?}" + ); + } + + #[test] + fn causeless_broadcast_error_is_ambiguous() { + // DAPI maps its own wait failures (e.g. `DapiError::Timeout`) to a + // `StateTransitionBroadcastError` with EMPTY consensus data, which the + // SDK decodes as `cause: None`. The spend may still execute after the + // wait gave up, so this must NOT release the note reservations. + let err = classify_spend_wait_failure("unshield", &broadcast_err(None)); + assert!( + matches!( + err, + PlatformWalletError::ShieldedSpendUnconfirmed { + operation: "unshield", + .. + } + ), + "got {err:?}" + ); + } + + #[test] + fn transport_errors_are_ambiguous() { + let err = classify_spend_wait_failure( + "withdraw", + &dash_sdk::Error::TimeoutReached( + std::time::Duration::from_secs(80), + "waiting for response".to_string(), + ), + ); + assert!(matches!( + err, + PlatformWalletError::ShieldedSpendUnconfirmed { + operation: "withdraw", + .. + } + )); + } + + /// The shape a CheckTx rejection takes when it reaches the SDK from + /// `broadcast()`: DAPI re-attaches the serialized consensus error as + /// gRPC metadata and the dapi-client decodes it into + /// `Error::Protocol(ConsensusError)`. + fn consensus_metadata_rejection() -> dash_sdk::Error { let cause = ConsensusError::BasicError(BasicError::ProtocolVersionParsingError( ProtocolVersionParsingError::new("bad version".to_string()), )); dash_sdk::Error::Protocol(dpp::ProtocolError::ConsensusError(Box::new(cause))) } - /// 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: + /// A consensus rejection surfacing from `broadcast()` reads as a refusal + /// (`broadcast_was_refused`), which proves nothing: the one flow that + /// reads it, the shield from an identity, reports unconfirmed either + /// way. On a note spend's wait the same verdict reads as unconfirmed: /// no proof backs it. #[test] - fn should_read_a_consensus_rejection_as_definitive_only_for_shields_into_the_pool() { - assert!(broadcast_definitely_failed(&consensus_metadata_rejection())); + fn should_read_a_consensus_rejection_as_a_refusal_and_never_as_a_failed_spend() { + assert!(broadcast_was_refused(&consensus_metadata_rejection())); let err = classify_spend_wait_failure("transfer", &consensus_metadata_rejection()); assert!( matches!(err, PlatformWalletError::ShieldedSpendUnconfirmed { .. }), @@ -6337,49 +7571,38 @@ mod classify_spend_wait_failure_tests { } /// Connection-establishment failures and non-consensus server - /// rejections of `broadcast()` ARE definitive: nothing was admitted to - /// a mempool. The offline case (connect refused → `Unavailable`) in - /// particular must release the notes immediately rather than strand - /// them until restart. + /// rejections of `broadcast()` read as refusals: the shield from an + /// identity then skips its result wait for a proven nonce read. None of + /// them proves the transition was left out of every mempool. #[test] - fn broadcast_connection_failures_and_rejections_are_definitive() { + fn should_read_connection_failures_and_rejections_of_a_broadcast_as_refusals() { use dash_sdk::dapi_grpc::tonic::Code; - assert!(broadcast_definitely_failed(&grpc_err(Code::Unavailable))); + assert!(broadcast_was_refused(&grpc_err(Code::Unavailable))); // Mempool full / malformed request: server verdicts refusing admission. - assert!(broadcast_definitely_failed(&grpc_err( - Code::ResourceExhausted - ))); - assert!(broadcast_definitely_failed(&grpc_err( - Code::InvalidArgument - ))); + assert!(broadcast_was_refused(&grpc_err(Code::ResourceExhausted))); + assert!(broadcast_was_refused(&grpc_err(Code::InvalidArgument))); } - /// No-response shapes of the `broadcast()` call itself are NOT - /// definitive: the dapi-client retries across addresses, so the request + /// No-response shapes of the `broadcast()` call itself are not + /// refusals: the dapi-client retries across addresses, so the request /// may have been delivered to a node whose ACK was lost. The caller - /// falls through to the result wait instead of releasing the - /// reservations (which would let a retry select other unreserved notes - /// and double-send if the original broadcast landed). + /// falls through to the result wait. #[test] fn broadcast_no_response_shapes_are_inconclusive() { use dash_sdk::dapi_grpc::tonic::Code; - assert!(!broadcast_definitely_failed(&grpc_err( - Code::DeadlineExceeded - ))); + assert!(!broadcast_was_refused(&grpc_err(Code::DeadlineExceeded))); // DAPI maps its own tenderdash-side failures (which can postdate // delivery) to Internal. - assert!(!broadcast_definitely_failed(&grpc_err(Code::Internal))); - assert!(!broadcast_definitely_failed( - &dash_sdk::Error::TimeoutReached( - std::time::Duration::from_secs(30), - "broadcast".to_string(), - ) - )); - assert!(!broadcast_definitely_failed(&dash_sdk::Error::Generic( + assert!(!broadcast_was_refused(&grpc_err(Code::Internal))); + assert!(!broadcast_was_refused(&dash_sdk::Error::TimeoutReached( + std::time::Duration::from_secs(30), + "broadcast".to_string(), + ))); + assert!(!broadcast_was_refused(&dash_sdk::Error::Generic( "transport error: connection reset".to_string() ))); // …including as the terminal error of an exhausted retry loop. - assert!(!broadcast_definitely_failed( + assert!(!broadcast_was_refused( &dash_sdk::Error::NoAvailableAddressesToRetry(Box::new(grpc_err( Code::DeadlineExceeded ))) @@ -6392,9 +7615,9 @@ mod classify_spend_wait_failure_tests { /// — the spend is in flight and the result wait will confirm it. #[test] fn broadcast_already_exists_is_in_flight() { - assert!(!broadcast_definitely_failed( - &dash_sdk::Error::AlreadyExists("state transition already in mempool".to_string()) - )); + assert!(!broadcast_was_refused(&dash_sdk::Error::AlreadyExists( + "state transition already in mempool".to_string() + ))); } /// A consensus verdict wrapped in the dapi-client's `NoAvailableAddressesToRetry` @@ -6545,44 +7768,6 @@ mod reserve_shield_fee_tests { } } -#[cfg(test)] -mod note_reservation_release_tests { - use super::*; - - /// `ShieldedBroadcastUnconfirmed` is the one failure that must NOT release the reservation: the - /// broadcast was accepted and the transition may have executed, so freeing the notes invites a - /// double-spend against notes that may already be consumed on chain. The next nullifier sync - /// reconciles them. - #[test] - fn unconfirmed_broadcast_retains_reservation() { - let e = PlatformWalletError::ShieldedBroadcastUnconfirmed { - identity_id: Identifier::from([7u8; 32]), - reason: "result proof unavailable".to_string(), - }; - assert!( - !error_releases_note_reservation(&e), - "ShieldedBroadcastUnconfirmed must retain the note reservation" - ); - } - - /// Every other failure is a definitive pre-execution / build / rejection failure — the spend - /// never happened, so the reservation must be released. - #[test] - fn definitive_failures_release_reservation() { - let releasing: Vec = vec![ - PlatformWalletError::ShieldedBroadcastFailed("rejected on merits".to_string()), - PlatformWalletError::ShieldedBuildError("note selection failed".to_string()), - PlatformWalletError::ShieldedStoreError("store write failed".to_string()), - ]; - for e in &releasing { - assert!( - error_releases_note_reservation(e), - "{e:?} must release the note reservation" - ); - } - } -} - #[cfg(test)] mod record_activity_status_tests { use super::*; diff --git a/packages/rs-platform-wallet/src/wallet/shielded/store.rs b/packages/rs-platform-wallet/src/wallet/shielded/store.rs index 78c87b307ad..b2672e56d00 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/store.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/store.rs @@ -143,12 +143,15 @@ pub type StalePendingSpend = ([u8; 32], [u8; 32], Option<[u8; 32]>, Option) /// /// Identity-funded shields arm this record before broadcast and have no input /// nullifiers. Their guard survives even after rebroadcasting stops. Note spends -/// arm it only on the ambiguous outcome (`ShieldedSpendUnconfirmed`): -/// the broadcast was accepted but the result wait failed, so the spend -/// may or may not have executed. Re-broadcasting the byte-identical -/// transition is fund-safe — identical nullifiers cannot double-spend — -/// and converts silence into either a confirmation (next scan sees the -/// nullifiers spent) or a definitive consensus verdict. +/// arm it before their first dispatch too, and keep it unless the transition +/// never left the wallet ([`ShieldedStore::retire_undelivered_spend`]): a +/// spend that may have reached a node may or may not have executed, and a +/// process that exits during its broadcast or its result wait leaves the +/// record to restore its notes' reservation from. Re-broadcasting the +/// byte-identical transition is fund-safe (identical nullifiers cannot be +/// spent twice) and converts silence into a confirmation (next scan sees the +/// nullifiers spent); a spend that never lands keeps its notes held until its +/// anchor is proven pruned. #[derive(Debug, Clone, PartialEq, Eq)] pub struct PendingRedrive { /// Activity-entry id of the spend (sha256 of visible output cmxs); @@ -507,12 +510,13 @@ pub trait ShieldedStore: Send + Sync + 'static { /// Returns `true` if the nullifier was newly added to the /// pending set, `false` if it was already pending. /// - /// Pending state is **in-memory only** — it does not survive - /// a process restart. The crash-during-broadcast case is - /// reconciled by the next note-scan pass after the transition - /// lands (scan-based spend detection marks the spent note via - /// its nullifier), or, on rejection, leaves the notes - /// observable as unspent again on the next launch. + /// The reservation itself is held in memory. A spend saves a redrive + /// record before its first dispatch ([`Self::arm_redrive`]), which + /// carries its nullifiers: a store that persists records restores the + /// reservation from it on reopen, so a process that exits during a + /// broadcast keeps the notes reserved. A reservation with no saved + /// record (its spend exited before saving one, so before anything was + /// dispatched) does not survive a restart. /// /// `unspent_notes` skips notes whose nullifier is in the /// pending set, so a successful `mark_pending` immediately @@ -605,8 +609,8 @@ pub trait ShieldedStore: Send + Sync + 'static { // ── Re-drivable unconfirmed spends (per-subwallet) ───────────────── - /// Persist a re-drivable record for a broadcast-accepted spend whose - /// result wait failed ambiguously. Keyed by `redrive.activity_id`; + /// Persist the re-drivable record of a spend, saved before its first + /// dispatch. Keyed by `redrive.activity_id`; /// re-arming the same id overwrites. Unlike the bare `mark_pending` /// reservations, redrive records survive a restart where the backend /// persists them (the file store does): on reopen both the record @@ -941,8 +945,8 @@ pub trait ShieldedStore: Send + Sync + 'static { /// stranded (broadcast-accepted-but-never-landed) spend: once that anchor /// is pruned from Platform's recorded set the spend can never execute, and /// with the nullifier still unspent the reservation is provably dead and -/// its note can be freed. All in-memory only — a restart drops every -/// reservation regardless. +/// its note can be freed. Held in memory: a restart keeps the reservations +/// a saved [`PendingRedrive`] restores, and drops the others. #[derive(Debug, Clone, Default)] pub(super) struct PendingSpend { /// The recorded anchor the spend was built against, once known. @@ -981,11 +985,12 @@ pub(super) struct SubwalletState { /// transition, mapped to the [`PendingSpend`] bookkeeping the /// sync reconcile needs. Excluded from `unspent_notes()` so /// concurrent callers can't double-select. Held in memory; a - /// pre-broadcast reservation dies with the process, but a - /// reservation belonging to an armed [`PendingRedrive`] is - /// rehydrated on file-store open (the redrive row carries its - /// nullifiers), so an unconfirmed broadcast keeps its notes - /// reserved across restarts. + /// reservation whose spend has saved no record yet dies with the + /// process, but a reservation belonging to an armed + /// [`PendingRedrive`], which a spend saves before its first + /// dispatch, is rehydrated on file-store open (the redrive row + /// carries its nullifiers), so a spend that may have been broadcast + /// keeps its notes reserved across restarts. pub pending_nullifiers: BTreeMap<[u8; 32], PendingSpend>, /// Armed re-drivable unconfirmed spends, keyed by activity id. /// See [`PendingRedrive`]. Kept consistent with diff --git a/packages/rs-sdk/src/platform/transition/broadcast.rs b/packages/rs-sdk/src/platform/transition/broadcast.rs index 3949a7476d5..c1edd3ffae5 100644 --- a/packages/rs-sdk/src/platform/transition/broadcast.rs +++ b/packages/rs-sdk/src/platform/transition/broadcast.rs @@ -400,7 +400,13 @@ where .await } -fn is_address_nonce_ahead(transition: &StateTransition, error: &Error) -> bool { +/// Whether `error` is a node refusing `transition` because one of its input +/// addresses' nonces is ahead of what that node has: the answer of a node +/// that is behind the one the nonce was read from. A broadcast answers it by +/// sending the same signed bytes to another node, within its retry budget. +/// A caller that broadcasts in single dispatches (`retries: Some(0)`) gets +/// no such failover from the SDK and reads this to keep dispatching. +pub fn is_address_nonce_ahead(transition: &StateTransition, error: &Error) -> bool { let Error::Protocol(ProtocolError::ConsensusError(consensus)) = error else { return false; }; diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerMasternodes.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerMasternodes.swift index e0a072611b5..fc148865ad5 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerMasternodes.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerMasternodes.swift @@ -286,11 +286,12 @@ extension PlatformWalletManager { /// `ownerKeyIndexHint`: as on `masternodeWithdrawalKeys` — verified, not /// trusted. /// - /// Outcomes: a definitive rejection throws an ordinary error and may be - /// retried. An AMBIGUOUS outcome — broadcast accepted (or its ACK lost) - /// and the result wait failed — throws + /// Outcomes: only a failure before anything was sent throws an ordinary + /// error and may be retried. Once the withdrawal may have reached a node, + /// any outcome short of the proved balance (a failed result wait, a + /// node's refusal or a consensus verdict) throws /// `PlatformWalletError.masternodeWithdrawalUnconfirmed`: the claim may - /// have executed and the identity nonce was consumed, so callers must + /// have executed and a retry signs a second withdrawal, so callers must /// NOT retry until they have re-read the claimable balance. /// /// Pure bridge — the whole orchestration (masternode lookup, identity diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerShieldedSync.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerShieldedSync.swift index 617c00d1160..a6feb022840 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerShieldedSync.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerShieldedSync.swift @@ -2,13 +2,14 @@ import Foundation import DashSDKFFI /// Thrown by `shieldedIdentityCreateFromPool` when the Type-20 -/// transition was broadcast and ACCEPTED by the relay, but the SDK -/// could not confirm its execution result and a direct fetch of the -/// derived id also came back empty (the Rust side already retried). +/// transition may have reached a node (a broadcast a node accepted, a +/// send attempt that may have delivered it, a node's refusal or verdict), +/// its result is not proved, and a direct fetch of the derived id also +/// came back empty (the Rust side already retried). /// /// This is NOT a registration failure: the identity may already exist -/// on chain (the broadcast landed; only the result-proof confirmation -/// failed — e.g. a transient DAPI/proof error). The caller MUST hold +/// on chain or may still be created (its result is not proved, e.g. after +/// a transient DAPI/proof error or a node's refusal). The caller MUST hold /// the slot against re-submission and surface the pending identity /// rather than treating it as unregistered — re-registering the same /// keys while the identity is live would fail the registered-key-hash @@ -676,11 +677,12 @@ extension PlatformWalletManager { /// stays resident between spends (the launch-time bind is /// viewing-grade only). /// - /// Throws `PlatformWalletError.shieldedSpendUnconfirmed` when the - /// broadcast was accepted but its execution result couldn't be - /// confirmed — the spend may already be on chain, so the caller - /// must NOT retry (the spent notes stay reserved Rust-side; the - /// next shielded sync reconciles them). + /// Throws `PlatformWalletError.shieldedSpendUnconfirmed` once the + /// spend may have reached a node and its result is not proved (a + /// failed result wait, a node's refusal or consensus verdict + /// included): it may already be on chain or may still execute, so the + /// caller must NOT retry (the spent notes stay reserved Rust-side + /// until a later shielded sync resolves them). public func shieldedTransfer( walletId: Data, resolver: MnemonicResolver, @@ -848,12 +850,12 @@ extension PlatformWalletManager { /// Heavy CPU work (Halo 2 proof + per-input signing) runs on a /// detached task so the caller's actor isn't blocked. /// - /// Throws `PlatformWalletError.shieldedSpendUnconfirmed` when the - /// broadcast was accepted but its execution result couldn't be - /// confirmed — the shield may already be on chain, so the caller - /// must NOT retry (a retry would rebuild the bundle and could - /// double-shield; the next sync reconciles the outcome). A shield - /// spends no notes, so nothing is reserved wallet-side. + /// Throws `PlatformWalletError.shieldedSpendUnconfirmed` once the + /// shield may have reached a node and its result is not proved (a + /// node's refusal or consensus verdict, stale nonce included): it may + /// already be on chain, so the caller must NOT retry (a retry signs a + /// second transition). Its activity row stays pending until the + /// shielded scan sees its note; nothing is reserved wallet-side. public func shieldedShield( walletId: Data, shieldedAccount: UInt32 = 0, @@ -918,10 +920,11 @@ extension PlatformWalletManager { /// Heavy CPU work (Halo 2 proof + the identity signature) runs on a /// detached task so the caller's actor isn't blocked. /// - /// Throws `PlatformWalletError.shieldedSpendUnconfirmed` when the - /// broadcast was accepted but its execution result couldn't be - /// confirmed: the shield may already be on chain, so the caller must - /// NOT retry (a retry would rebuild the bundle and could double-shield; + /// Throws `PlatformWalletError.shieldedSpendUnconfirmed` once the + /// shield may have reached a node and its result is not proved (a + /// node's refusal or consensus verdict included): it may already be on + /// chain or may still execute, so the caller must + /// NOT retry (a retry signs a second transition; /// the next sync reconciles the outcome). Like every shield it spends /// no notes. An unresolved debit blocks a fresh shield from that identity /// with `.shieldedIdentityDebitPending`. Inspect durable records with @@ -1006,12 +1009,12 @@ extension PlatformWalletManager { /// or Rust rejects it. The 36-byte on-chain encoding is done on the /// Rust side. /// - /// Throws `PlatformWalletError.shieldedSpendUnconfirmed` when the - /// broadcast was accepted but its execution result couldn't be - /// confirmed — the shield may already be on chain, so the caller - /// must NOT retry (a retry would rebuild the bundle and could - /// double-pay; the next sync reconciles the outcome). A shield - /// spends no notes, so nothing is reserved wallet-side. + /// Throws `PlatformWalletError.shieldedSpendUnconfirmed` once the + /// shield may have reached a node and its result is not proved (a + /// node's refusal or consensus verdict, stale nonce included): it may + /// already be on chain, so the caller must NOT retry (a retry signs a + /// second transition). Its activity row stays pending until the + /// shielded scan sees its note; nothing is reserved wallet-side. public func shieldedShieldToRecipient( walletId: Data, shieldedAccount: UInt32 = 0, @@ -1086,11 +1089,12 @@ extension PlatformWalletManager { /// `resolver` supplies the per-operation Orchard spend authority /// (see [`shieldedTransfer`]). /// - /// Throws `PlatformWalletError.shieldedSpendUnconfirmed` when the - /// broadcast was accepted but its execution result couldn't be - /// confirmed — the spend may already be on chain, so the caller - /// must NOT retry (the spent notes stay reserved Rust-side; the - /// next shielded sync reconciles them). + /// Throws `PlatformWalletError.shieldedSpendUnconfirmed` once the + /// spend may have reached a node and its result is not proved (a + /// failed result wait, a node's refusal or consensus verdict + /// included): it may already be on chain or may still execute, so the + /// caller must NOT retry (the spent notes stay reserved Rust-side + /// until a later shielded sync resolves them). public func shieldedUnshield( walletId: Data, resolver: MnemonicResolver, @@ -1152,11 +1156,12 @@ extension PlatformWalletManager { /// supplies per operation (see [`shieldedTransfer`]), exactly as for /// [`shieldedUnshield`]. /// - /// Throws `PlatformWalletError.shieldedSpendUnconfirmed` when the - /// broadcast was accepted but its execution result couldn't be - /// confirmed: the spend may already be on chain, so the caller must - /// NOT retry (the spent notes stay reserved Rust-side; the next - /// shielded sync reconciles them). + /// Throws `PlatformWalletError.shieldedSpendUnconfirmed` once the + /// spend may have reached a node and its result is not proved (a + /// failed result wait, a node's refusal or consensus verdict + /// included): it may already be on chain or may still execute, so the + /// caller must NOT retry (the spent notes stay reserved Rust-side + /// until a later shielded sync resolves them). public func shieldedIdentityTopUpFromPool( walletId: Data, resolver: MnemonicResolver, @@ -1220,11 +1225,12 @@ extension PlatformWalletManager { /// `resolver` supplies the per-operation Orchard spend authority /// (see [`shieldedTransfer`]). /// - /// Throws `PlatformWalletError.shieldedSpendUnconfirmed` when the - /// broadcast was accepted but its execution result couldn't be - /// confirmed — the spend may already be on chain, so the caller - /// must NOT retry (the spent notes stay reserved Rust-side; the - /// next shielded sync reconciles them). + /// Throws `PlatformWalletError.shieldedSpendUnconfirmed` once the + /// spend may have reached a node and its result is not proved (a + /// failed result wait, a node's refusal or consensus verdict + /// included): it may already be on chain or may still execute, so the + /// caller must NOT retry (the spent notes stay reserved Rust-side + /// until a later shielded sync resolves them). public func shieldedWithdraw( walletId: Data, resolver: MnemonicResolver, @@ -1412,9 +1418,9 @@ extension PlatformWalletManager { // would construct a second wrapper over the same struct and // double-free). Inspect the typed code directly: // - success: return the derived id. - // - unconfirmed: the broadcast landed but its result couldn't be - // confirmed; Rust filled `outIdentityId` with the derived id on - // THIS code (and only this code). Throw the typed + // - unconfirmed: the transition may have reached a node and its + // result is not proved; Rust filled `outIdentityId` with the + // derived id on THIS code (and only this code). Throw the typed // `ShieldedIdentityCreateUnconfirmedError` so the caller holds // the slot instead of treating it as failed. // - any other non-success: throw the regular typed error. diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift index 15f618e0bee..ec846fb11ba 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift @@ -21,23 +21,27 @@ public enum PlatformWalletResultCode: Int32, Sendable { case errorArithmeticOverflow = 13 case errorNoSelectableInputs = 14 case errorWalletAlreadyExists = 15 - /// Definitive shielded-broadcast failure: the shielded transition - /// (identity-create, unshield, transfer, or withdrawal) was not executed - /// (relay/CheckTx rejection or a platform-reported execution error), the - /// spent notes were released, and the caller may retry. + /// Definitive shielded-broadcast failure: the shielded transition never + /// left the wallet (refused before anything was sent: no node address, or + /// the SDK's own refusal), so it can never execute. Any reserved notes + /// were released and the caller may retry. A node's refusal or consensus + /// verdict is unconfirmed instead (18, or 17 for identity-create). case errorShieldedBroadcastFailed = 16 - /// Shielded broadcast accepted but its execution result could not be - /// confirmed; the identity may already exist on chain. The FFI also fills - /// the derived id into `outIdentityId` on this code, so the caller can - /// hold the slot rather than treat the registration as failed. + /// An identity create from the pool may have reached a node (accepted, a + /// send attempt that may have delivered it, a node's refusal or verdict), + /// its result is not proved and a direct fetch of the derived id came + /// back empty. The wallet keeps the notes reserved and the saved + /// transition. The identity may already exist on chain: do not treat it + /// as unregistered or re-submit. The FFI fills the derived id into + /// `outIdentityId` on this code, so the caller can hold the slot. case errorShieldedBroadcastUnconfirmed = 17 - /// A shielded operation (shield / unshield / transfer / withdrawal) was - /// accepted by the relay but its execution result could not be - /// confirmed. It may have executed on chain; for the spend-based - /// operations the wallet keeps the notes reserved (a shield reserves - /// nothing) until the next sync (or app restart) reconciles the - /// outcome. Do NOT auto-retry — a retry would rebuild the bundle and - /// could double-execute if the original landed. + /// A shielded operation (shield from Platform addresses, unshield / + /// transfer / withdrawal) may have reached a node and its result is not + /// proved: a failed result wait, a node's refusal or a consensus verdict + /// (a stale address nonce included). It may have executed. Spends keep + /// their notes reserved and the saved transition for the wallet to + /// resolve; a shield's activity row stays pending until the scan sees its + /// note. Do NOT auto-retry: a retry signs a second transition. case errorShieldedSpendUnconfirmed = 18 /// A shielded spend could not be built against a Platform-recorded anchor: /// the wallet's commitment tree isn't synced to a checkpoint Platform has @@ -53,12 +57,12 @@ public enum PlatformWalletResultCode: Int32, Sendable { /// of double-spending; the reservation TTL or a sync reconciles the /// outcome. Do NOT auto-retry. case errorTransactionBroadcastUnconfirmed = 20 - /// A masternode (evonode) identity credit withdrawal was broadcast and - /// accepted, but its execution result could not be confirmed — it may - /// already have executed, and the identity nonce was consumed for it, so - /// a blind retry could submit a SECOND withdrawal. Do NOT retry; re-read - /// the claimable balance and reconcile first. Definitive rejections keep - /// their ordinary codes and stay retryable. + /// A masternode (evonode) identity credit withdrawal may have reached a + /// node and its remaining balance is not proved (a failed result wait, a + /// node's refusal or a consensus verdict included). It may have executed; + /// a retry signs a second withdrawal with the next identity nonce. Do NOT + /// retry; re-read the claimable balance first. Only a withdrawal that + /// failed before anything was sent keeps an ordinary, retryable code. case errorMasternodeWithdrawalUnconfirmed = 42 /// The deterministic masternode list isn't available yet (SPV not /// running or masternode sync incomplete), so a list-backed query such @@ -66,13 +70,13 @@ public enum PlatformWalletResultCode: Int32, Sendable { /// `spvProgress.masternodes` reports the list synced. /// (46 — 43/44/45 are held by the shielded-invite error trio, #4313.) case errorMasternodeListUnavailable = 46 - /// Definitively-failed address-nonce race: Platform rejected an - /// address-funds transition (shield, or identity top-up-from-addresses) - /// because the submitted address nonce raced Platform's expected value - /// (a lagging DAPI replica read). The transition did NOT execute and any - /// notes were released (a shield reserves none) — safe to retry; the retry - /// re-fetches the nonce and self-heals. The submitted/expected nonce values - /// travel in the message string, not as structured fields. + /// Address-nonce race: Platform rejected an address-funds transition (a + /// transfer or withdrawal from Platform addresses, or an identity + /// registration or top-up from them) because the submitted address nonce + /// raced Platform's expected value (a lagging DAPI replica read). Safe to + /// retry; the retry re-fetches the nonce and self-heals. A shield does not + /// return it (see `errorShieldedSpendUnconfirmed`). The submitted/expected + /// nonce values travel in the message string, not as structured fields. case errorAddressNonceMismatch = 21 /// Atomic Core selection found no or insufficient unreserved UTXOs. case errorCoreInsufficientFunds = 22 @@ -601,22 +605,25 @@ public enum PlatformWalletError: LocalizedError { /// recipient was recorded carry none and accept the caller's. case assetLockRecipientMismatch(String) case walletAlreadyExists(String) - /// Definitive shielded-broadcast failure: the shielded transition - /// (identity-create or a spend — unshield / transfer / withdrawal) was - /// not executed and the spent notes were released; safe to retry. + /// Definitive shielded-broadcast failure: the shielded transition never + /// left the wallet (refused before anything was sent), so it cannot + /// execute; any reserved notes were released and a retry is safe. case shieldedBroadcastFailed(String) - /// Shielded broadcast accepted but its execution result could not be - /// confirmed; the identity may already exist on chain. Callers that need - /// the derived id should special-case the + /// An identity create from the pool may have reached a node (a node's + /// refusal or verdict included), its result is not proved and a direct + /// fetch of the derived id came back empty. The wallet keeps the notes + /// reserved and the saved transition; the identity may already exist on + /// chain, so do not treat it as unregistered or re-submit. Callers that + /// need the derived id should special-case the /// `.errorShieldedBroadcastUnconfirmed` result code (which carries - /// `outIdentityId`) before falling back to this error — see + /// `outIdentityId`) before falling back to this error: see /// `ShieldedIdentityCreateUnconfirmedError`. case shieldedBroadcastUnconfirmed(String) - /// A shielded operation (shield / unshield / transfer / withdrawal) - /// was accepted by the relay but its execution result could not be - /// confirmed. It may have executed; spend-based operations keep their - /// notes reserved wallet-side (a shield reserves nothing) until the - /// next sync reconciles the outcome. Do NOT auto-retry. + /// A shielded operation (shield from Platform addresses, unshield / + /// transfer / withdrawal) may have reached a node and its result is not + /// proved (a node's refusal or consensus verdict included). It may have + /// executed; spends keep their notes reserved, a shield stays pending + /// until the scan sees its note. Do NOT auto-retry. case shieldedSpendUnconfirmed(String) /// A shielded spend could not be built against a Platform-recorded anchor — /// the wallet's commitment tree isn't synced to a checkpoint Platform has @@ -636,10 +643,10 @@ public enum PlatformWalletError: LocalizedError { /// reservation TTL or a later sync reconciles the outcome. Do NOT /// auto-retry. Core sibling of `shieldedSpendUnconfirmed`. case transactionBroadcastUnconfirmed(String) - /// A masternode (evonode) credit withdrawal was broadcast and accepted - /// but its result could not be confirmed. It may already have executed - /// and the identity nonce was consumed — do NOT retry; re-read the - /// claimable balance first (`.errorMasternodeWithdrawalUnconfirmed`). + /// A masternode (evonode) credit withdrawal may have reached a node and + /// its result is not proved (a node's refusal or verdict included). It may + /// have executed and a retry signs a second withdrawal: do NOT retry; + /// re-read the claimable balance first (`.errorMasternodeWithdrawalUnconfirmed`). case masternodeWithdrawalUnconfirmed(String) /// The masternode list hasn't synced yet, so there is nothing to look a /// masternode up in (`.errorMasternodeListUnavailable`). Retry after @@ -648,12 +655,12 @@ public enum PlatformWalletError: LocalizedError { /// Core definitively rejected the transaction and its input reservation /// was released. Unlike `transactionBroadcastUnconfirmed`, retry is safe. case transactionBroadcastRejected(String) - /// Definitively-failed address-nonce race (shield, or identity - /// top-up-from-addresses): Platform rejected the transition because the - /// submitted address nonce raced its expected value. The transition did - /// NOT execute and any notes were released (a shield reserves none) — safe - /// to retry, and the retry re-fetches the address nonce so the mismatch - /// self-heals. The submitted/expected nonce values are in the message. + /// Address-nonce race (transfer or withdrawal from Platform addresses, or + /// identity registration / top-up from them): Platform rejected the + /// transition because the submitted address nonce raced its expected + /// value. Safe to retry: the retry re-fetches the address nonce so the + /// mismatch self-heals. A shield does not return it. The submitted and + /// expected nonce values are in the message. case addressNonceMismatch(String) /// A quiesce/drain barrier (Clear / reset / sync-stop) timed out with a /// sync pass still in flight. The operation failed closed — retry once diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/ViewModels/SendViewModel.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/ViewModels/SendViewModel.swift index d86c0f13e05..8304a48e2d1 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/ViewModels/SendViewModel.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/ViewModels/SendViewModel.swift @@ -831,11 +831,11 @@ class SendViewModel: ObservableObject { } catch PlatformWalletError.shieldedSpendUnconfirmed { // The shielded operation (shield / unshield / transfer / withdraw) - // was broadcast and accepted, but its execution result couldn't be - // confirmed — it may already be on chain. Rust intentionally KEEPS - // any spent notes' reservations, so this must NOT be presented as a - // retryable failure: retrying would rebuild the bundle and could - // double-execute if the original landed. Surface it through the + // may have reached a node and its result is not proved (a node's + // refusal or verdict included), so it may already be on chain. Rust + // intentionally KEEPS any spent notes' reservations, so this must + // NOT be presented as a retryable failure: a retry signs a second + // transition. Surface it through the // non-error (success) path so the UI doesn't invite a retry; the // next shielded sync reconciles the outcome. successMessage = "Transaction may have gone through — waiting for " diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/MasternodeDetailView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/MasternodeDetailView.swift index e1cf8cdbf17..7cfc03cc01c 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/MasternodeDetailView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/Views/MasternodeDetailView.swift @@ -433,13 +433,13 @@ struct MasternodeDetailView: View { // Reflect the new (reduced) balance. await fetchClaimableBalance() } catch PlatformWalletError.masternodeWithdrawalUnconfirmed(let detail) { - // Ambiguous: the claim may have executed and the nonce was - // consumed — never re-submit blindly. Confirm stays disabled + // Ambiguous: the claim may have executed and its nonce may have + // been taken, so never re-submit blindly. Confirm stays disabled // until the balance is refreshed. claiming = false claimOutcomeUnconfirmed = true - claimError = "The withdrawal was submitted but its result couldn't be confirmed — " - + "it may have gone through. Refresh the balance before trying again. (\(detail))" + claimError = "The withdrawal may have reached the network, but its result couldn't be " + + "confirmed: it may have gone through. Refresh the balance before trying again. (\(detail))" } catch { claiming = false claimError = error.localizedDescription diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Services/IdentityRegistrationController.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Services/IdentityRegistrationController.swift index 86ee5b1898a..414be98a59d 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Services/IdentityRegistrationController.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Services/IdentityRegistrationController.swift @@ -53,11 +53,11 @@ final class IdentityRegistrationController: ObservableObject { /// the coordinator's map until the user dismisses it /// manually. case failed(String) - /// Broadcast-succeeded-but-confirmation-failed terminal state - /// (shielded Type-20 only). The transition was relayed and - /// accepted, but its execution-result proof couldn't be - /// confirmed and a direct fetch of the derived id also came - /// back empty (Rust already retried). The identity at + /// Unconfirmed terminal state (shielded Type-20 only). The + /// transition may have reached a node (a node's refusal or + /// verdict included), its result is not proved, and a direct + /// fetch of the derived id also came back empty (Rust already + /// retried). The identity at /// `identityId` MAY already be live on chain. /// /// This is NOT a red failure: treating it as unregistered is @@ -85,7 +85,7 @@ final class IdentityRegistrationController: ObservableObject { /// which the identity-slot anti-join already covers. /// `.failed` is NOT active either: the user is expected /// to retry, so the lock should resurface for selection. - /// `.unconfirmed` IS active: the identity is probably live on + /// `.unconfirmed` IS active: the identity may be live on /// chain, so the slot must stay held to block a re-submission /// that would burn funds against the registered-key-hash check. var isActive: Bool { @@ -102,10 +102,11 @@ final class IdentityRegistrationController: ObservableObject { /// `RegistrationProgressView` can attribute the red marker to the /// right step. Only meaningful for `fundingKind == .shieldedPool`. /// - /// Deliberately only ONE case: `.broadcastRejected` is the single + /// Deliberately only ONE case: `.broadcastNotSent` is the single /// failure shape we can attribute with confidence (a typed - /// `PlatformWalletError.shieldedBroadcastFailed`). We cannot honestly - /// claim a "before broadcast" stage by elimination — Rust build/proof + /// `PlatformWalletError.shieldedBroadcastFailed`: the transition was + /// refused before anything was sent). We cannot honestly claim a + /// "before broadcast" stage by elimination: Rust build/proof /// errors arrive as a generic `.walletOperation`, indistinguishable at /// this layer from an invalid handle, a marshalling failure, or any other /// non-broadcast error. So `failureStage` is left `nil` for everything @@ -113,10 +114,10 @@ final class IdentityRegistrationController: ObservableObject { /// Extensible: add a case here only when a new typed error actually lets /// us attribute the stage with certainty. enum FailureStage { - /// Platform definitively rejected the broadcast transition (a - /// `PlatformWalletError.shieldedBroadcastFailed`). Attributed to - /// the "Broadcasting transition" step. - case broadcastRejected + /// The transition was refused before anything was sent (a + /// `PlatformWalletError.shieldedBroadcastFailed`), so it never left + /// the wallet. Attributed to the "Broadcasting transition" step. + case broadcastNotSent } /// Current phase. Updates flow: @@ -126,7 +127,7 @@ final class IdentityRegistrationController: ObservableObject { /// Stage attribution for a shielded `.failed` phase. `nil` means /// "unattributed" — either the phase isn't a shielded failure, or the - /// failure wasn't a confidently-attributable broadcast rejection. On a + /// failure wasn't a confidently-attributable refusal before sending. On a /// `nil` shielded failure the progress view falls back to its /// elapsed-time heuristic (note-selection vs Halo 2 proof). Reset at the /// start of every `submit` so a retry doesn't inherit the previous @@ -192,7 +193,7 @@ final class IdentityRegistrationController: ObservableObject { /// - `.completed`: re-submitting after success would flip the /// UI from "Done" back to a spinner before failing on the /// consumed lock. - /// - `.unconfirmed`: the identity is probably already live on + /// - `.unconfirmed`: the identity may already be live on /// chain; re-submitting the same keys would fail the /// registered-key-hash stateful check and burn the funds. /// `.idle`, `.preparingKeys`, and `.failed` are allowed — the @@ -204,10 +205,11 @@ final class IdentityRegistrationController: ObservableObject { /// background priority and reports the identity id on success /// or rethrows on failure. The controller flips `phase` to /// `.completed` / `.unconfirmed` / `.failed` accordingly. On a shielded - /// `.failed` it records `failureStage = .broadcastRejected` ONLY for a - /// `PlatformWalletError.shieldedBroadcastFailed`; every other error leaves - /// `failureStage` nil (unattributed — the progress view uses its - /// elapsed-time heuristic) rather than guessing a stage by elimination. + /// `.failed` it records `failureStage = .broadcastNotSent` ONLY for a + /// `PlatformWalletError.shieldedBroadcastFailed` (refused before anything + /// was sent); every other error leaves `failureStage` nil (unattributed: + /// the progress view uses its elapsed-time heuristic) rather than + /// guessing a stage by elimination. func submit(body: @escaping () async throws -> Data) { switch phase { case .idle, .preparingKeys, .failed: @@ -229,9 +231,9 @@ final class IdentityRegistrationController: ObservableObject { self?.phase = .completed(identityId: identityId) } } catch let unconfirmed as ShieldedIdentityCreateUnconfirmedError { - // Broadcast landed but its result couldn't be confirmed and - // Rust's direct fetch came back empty. Hold the slot; the - // identity probably exists and will surface on the next sync. + // The transition may have reached a node; its result is not + // proved and Rust's direct fetch came back empty. Hold the slot; + // the identity may exist and would surface on a later sync. await MainActor.run { self?.terminalAt = Date() self?.phase = .unconfirmed( @@ -240,15 +242,15 @@ final class IdentityRegistrationController: ObservableObject { ) } } catch { - // Only a `shieldedBroadcastFailed` is a confidently-attributable - // broadcast rejection → mark the broadcast step. Everything else - // (Rust build / Halo 2 proof errors, which surface as a generic + // Only a `shieldedBroadcastFailed` (refused before anything was + // sent) is confidently attributable: mark the broadcast step. + // Everything else (Rust build / Halo 2 proof errors, which surface as a generic // `.walletOperation`, plus invalid-handle / marshalling failures) // is indistinguishable at this layer, so leave `failureStage` nil // and let the progress view's elapsed-time heuristic place it. let stage: FailureStage? if case PlatformWalletError.shieldedBroadcastFailed = error { - stage = .broadcastRejected + stage = .broadcastNotSent } else { stage = nil } diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/CreateIdentityView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/CreateIdentityView.swift index d47274cd161..5c5d8974477 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/CreateIdentityView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/CreateIdentityView.swift @@ -480,7 +480,7 @@ struct CreateIdentityView: View { Section { VStack(alignment: .leading, spacing: 8) { Label( - "Broadcast succeeded — confirmation pending", + "Result not confirmed", systemImage: "exclamationmark.triangle.fill" ) .foregroundColor(.orange) @@ -494,10 +494,10 @@ struct CreateIdentityView: View { .foregroundColor(.primary) .textSelection(.enabled) Text( - "The transition was broadcast and accepted, but its " - + "execution-result proof couldn't be confirmed. The " - + "identity will appear in the Identities tab after the " - + "next sync. Do NOT re-register these keys — the slot " + "The transition may have reached the network, but its " + + "result couldn't be confirmed. If it went through, the " + + "identity will appear in the Identities tab after a " + + "later sync. Do NOT re-register these keys: the slot " + "is held to prevent burning funds." ) .font(.caption) @@ -1541,8 +1541,8 @@ struct CreateIdentityView: View { // the inline failure state. return case .unconfirmed: - // Broadcast landed but its result couldn't be confirmed; - // the identity is probably already live on chain. Mark the + // The transition may have reached a node; its result is not + // proved, and the identity may already be live on chain. Mark the // slot used (same as `.completed`) — `usedIdentityIndices` // unions the persisted `isUsed` reservation with the // `PersistentIdentity` rows, so this is the reservation @@ -2134,8 +2134,8 @@ struct CreateIdentityView: View { /// identity-registration account. /// /// The union matters for `.unconfirmed` Type-20 registrations: the - /// broadcast landed but no `PersistentIdentity` row exists until the - /// next sync confirms the identity, so `markIdentitySlotUsed`'s + /// transition may have reached a node, yet no `PersistentIdentity` row + /// exists until the next sync confirms it, so `markIdentitySlotUsed`'s /// persisted `isUsed` flag is the ONLY artifact holding the slot once /// the in-memory controller is gone. Without it the same index would /// be offered again and a duplicate submission would burn funds diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/RegistrationProgressView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/RegistrationProgressView.swift index ca36b281edb..6a9d6e05e6c 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/RegistrationProgressView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/RegistrationProgressView.swift @@ -39,7 +39,7 @@ import SwiftDashSDK /// Step 4 ("Waiting for platform confirmation") exists so a /// post-broadcast confirmation failure is attributed there — NOT to the /// Halo 2 proof step — and so the `.unconfirmed` terminal state (the -/// broadcast landed but its result couldn't be confirmed) has a step to +/// transition may have reached a node but its result is not proved) has a step to /// render an orange warning on while steps 1–3 stay done and step 5 /// stays pending. /// @@ -253,7 +253,7 @@ struct RegistrationProgressSection: View { /// On `.completed` return 6 (one past the last step) so all rows /// render `.done`. On `.unconfirmed` return 4 ("Waiting for platform /// confirmation") so that step carries the warning. On `.failed` - /// attribute the step: a `.broadcastRejected` failure marks step 3 + /// attribute the step: a `.broadcastNotSent` failure marks step 3 /// ("Broadcasting transition"); an UNATTRIBUTED failure /// (`failureStage == nil` — build / Halo 2 proof errors, or any other /// error we can't confidently place) keeps the note-selection vs Halo 2 @@ -269,21 +269,21 @@ struct RegistrationProgressSection: View { case .completed: return 6 case .unconfirmed: - // Broadcast landed; only the result confirmation failed. + // The transition may have reached a node; its result is not proved. // Attribute to the "Waiting for platform confirmation" step. return 4 case .inFlight: return shieldedStep(elapsedTo: now) case .failed: - // A definitive broadcast rejection (`failureStage == - // .broadcastRejected`) is attributed to the broadcast step (3). + // A refusal before anything was sent (`failureStage == + // .broadcastNotSent`) is attributed to the broadcast step (3). // For an unattributed failure (`failureStage == nil` — build / // Halo 2 proof errors, or any other error we can't confidently // place), keep the elapsed-time heuristic (note-selection vs // proof) — frozen at the failure instant; fall back to `now` only // if the terminal timestamp is missing (pre-submit failure shapes // never set it). - if controller.failureStage == .broadcastRejected { + if controller.failureStage == .broadcastNotSent { return 3 } return shieldedStep(elapsedTo: controller.terminalAt ?? now) @@ -386,10 +386,10 @@ struct RegistrationProgressSection: View { /// fallback — visually distinguishable so users don't think /// the step "didn't happen yet" once we've moved past it. /// `.warning` (orange triangle) marks the "Waiting for platform - /// confirmation" step when the broadcast landed but its result - /// couldn't be confirmed (the shielded `.unconfirmed` terminal + /// confirmation" step when the transition may have reached a node but + /// its result is not proved (the shielded `.unconfirmed` terminal /// state) — distinct from `.failed` because nothing is actually - /// wrong; the identity is probably live on chain. + /// wrong; the identity may be live on chain. enum StepState { case done, active, pending, skipped, failed, warning } private func stepState( @@ -483,7 +483,7 @@ struct RegistrationProgressSection: View { .foregroundColor(.red) .font(.title3) case .warning: - // Broadcast landed but its result couldn't be confirmed — + // The transition may have reached a node; its result is not proved: // not an error, so an orange triangle, not the red octagon. Image(systemName: "exclamationmark.triangle.fill") .foregroundColor(.orange) @@ -607,14 +607,14 @@ struct RegistrationProgressView: View { Section { VStack(alignment: .leading, spacing: 8) { Label( - "Broadcast succeeded — confirmation pending", + "Result not confirmed", systemImage: "exclamationmark.triangle.fill" ) .foregroundColor(.orange) .font(.headline) // Same base58 styling as the completed case — the id is // the derived identity id Rust handed back; the identity - // is probably already live on chain. + // may already be live on chain. Text(identityId.toBase58String()) .font(.system(.caption, design: .monospaced)) .foregroundColor(.secondary) @@ -624,12 +624,11 @@ struct RegistrationProgressView: View { .foregroundColor(.primary) .textSelection(.enabled) Text( - "The transition was broadcast and accepted, but its " - + "execution-result proof couldn't be confirmed. The " - + "identity above will appear in the Identities tab " - + "after the next sync. Do NOT re-submit — the slot is " - + "held to prevent burning funds against a duplicate " - + "registration." + "The transition may have reached the network, but its " + + "result couldn't be confirmed. If it went through, the " + + "identity above will appear in the Identities tab after " + + "a later sync. Do NOT re-submit: the slot is held to " + + "prevent burning funds against a duplicate registration." ) .font(.caption) .foregroundColor(.secondary) diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/CreateIdentityResumableTests.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/CreateIdentityResumableTests.swift index 5514b577eb6..3dc6674d50a 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/CreateIdentityResumableTests.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/CreateIdentityResumableTests.swift @@ -419,7 +419,7 @@ final class CreateIdentityResumableTests: XCTestCase { identityIndex: 1, fundingKind: .shieldedPool ) { - throw PlatformWalletError.shieldedBroadcastFailed("rejected on merits") + throw PlatformWalletError.shieldedBroadcastFailed("no node address to send to") } for _ in 0..<200 { if case .failed = failing.phase { break } From 8eb1e3e1a4a65e4e15d1ffe8e9dbad700a6309b1 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Tue, 10 Nov 2026 19:38:02 +0700 Subject: [PATCH 243/269] feat(platform-wallet-ffi): hand marked scan state records back as retained, with a wallet list slot The wallet now expects a backend to keep a scan state position record whose unresolved-payment marker is set through the removal of its wallet, and to return it in ClientStartState::retained_private_contact_scan_states. The FFI load built that map from Default, so it was always empty. - PersistenceCallbacksExtension gains two trailing slots under the same version, gated by struct_size: - on_load_private_contact_scan_state_wallets_fn lists the ids of every wallet the host keeps scan state for, as an array of 32-byte ids; - on_load_private_contact_scan_state_wallets_free_fn releases it. PRIVATE_CONTACT_REQUEST_SCAN_STATE is now attested only with all five scan state slots. - The load gives the wallet every marked record that no restored identity takes: - those of a returned wallet whose identity it did not restore; - those of each listed wallet it did not return, read through the existing per-wallet slot. A host holding no wallet still hands its kept records back. - The pair-hold and restore-failure exports document code 7 for an identity the wallet does not hold. Their test pins it. - Tests: - should_hand_back_the_marked_records_no_restored_identity_takes - should_hold_an_identity_a_kept_marked_record_names_when_its_wallet_is_registered_again - the extension layout and the size gate of the two new slots. Co-Authored-By: Claude Opus 5.5 --- .../rs-platform-wallet-ffi/src/manager.rs | 46 +- .../rs-platform-wallet-ffi/src/persistence.rs | 622 +++++++++++++++++- .../src/private_contact_request.rs | 29 +- .../src/test_fixtures.rs | 8 +- 4 files changed, 676 insertions(+), 29 deletions(-) diff --git a/packages/rs-platform-wallet-ffi/src/manager.rs b/packages/rs-platform-wallet-ffi/src/manager.rs index 2dc8381c607..20748b92714 100644 --- a/packages/rs-platform-wallet-ffi/src/manager.rs +++ b/packages/rs-platform-wallet-ffi/src/manager.rs @@ -8,9 +8,10 @@ use crate::event_handler::{ }; use crate::handle::*; use crate::persistence::{ - FFIPersister, FreePrivateContactScanStateFn, FreeTrackedMasternodesFn, - LoadAssetLockOperationIdFn, LoadAssetLockShieldedRecipientFn, LoadIdentityBalanceBlockTimeFn, - LoadPrivateContactScanStateFn, LoadTrackedMasternodesFn, PersistAssetLockOperationIdFn, + FFIPersister, FreePrivateContactScanStateFn, FreePrivateContactScanStateWalletsFn, + FreeTrackedMasternodesFn, LoadAssetLockOperationIdFn, LoadAssetLockShieldedRecipientFn, + LoadIdentityBalanceBlockTimeFn, LoadPrivateContactScanStateFn, + LoadPrivateContactScanStateWalletsFn, LoadTrackedMasternodesFn, PersistAssetLockOperationIdFn, PersistAssetLockShieldedRecipientFn, PersistDpnsNameStatesFn, PersistIdentityBalanceBlockTimeFn, PersistPrivateContactScanStateFn, PersistTrackedMasternodesFn, PersistWalletChangesetChainLockHeightFn, @@ -340,6 +341,14 @@ unsafe fn persistence_extension_callbacks( on_load_private_contact_scan_state_free_fn, FreePrivateContactScanStateFn ), + load_private_contact_scan_state_wallets: slot!( + on_load_private_contact_scan_state_wallets_fn, + LoadPrivateContactScanStateWalletsFn + ), + load_private_contact_scan_state_wallets_free: slot!( + on_load_private_contact_scan_state_wallets_free_fn, + FreePrivateContactScanStateWalletsFn + ), } } @@ -1498,16 +1507,47 @@ mod tests { _: usize, ) { } + unsafe extern "C" fn list(_: *mut c_void, _: *mut *const [u8; 32], _: *mut usize) -> i32 { + 0 + } + unsafe extern "C" fn free_list(_: *mut c_void, _: *const [u8; 32], _: usize) {} let mut ext = PersistenceCallbacksExtension { on_persist_private_contact_scan_state_fn: Some(persist), on_load_private_contact_scan_state_fn: Some(load), on_load_private_contact_scan_state_free_fn: Some(free), + on_load_private_contact_scan_state_wallets_fn: Some(list), + on_load_private_contact_scan_state_wallets_free_fn: Some(free_list), ..Default::default() }; let parsed = unsafe { persistence_extension_callbacks(&ext) }; assert!(parsed.persist_private_contact_scan_state.is_some()); assert!(parsed.load_private_contact_scan_state.is_some()); assert!(parsed.load_private_contact_scan_state_free.is_some()); + assert!(parsed.load_private_contact_scan_state_wallets.is_some()); + assert!(parsed + .load_private_contact_scan_state_wallets_free + .is_some()); + // An extension sized before the wallet list leaves both list slots + // unread. + ext.struct_size = std::mem::offset_of!( + PersistenceCallbacksExtension, + on_load_private_contact_scan_state_wallets_fn + ); + let parsed = unsafe { persistence_extension_callbacks(&ext) }; + assert!(parsed.load_private_contact_scan_state_free.is_some()); + assert!(parsed.load_private_contact_scan_state_wallets.is_none()); + assert!(parsed + .load_private_contact_scan_state_wallets_free + .is_none()); + ext.struct_size = std::mem::offset_of!( + PersistenceCallbacksExtension, + on_load_private_contact_scan_state_wallets_free_fn + ); + let parsed = unsafe { persistence_extension_callbacks(&ext) }; + assert!(parsed.load_private_contact_scan_state_wallets.is_some()); + assert!(parsed + .load_private_contact_scan_state_wallets_free + .is_none()); ext.struct_size = std::mem::offset_of!( PersistenceCallbacksExtension, on_load_private_contact_scan_state_free_fn diff --git a/packages/rs-platform-wallet-ffi/src/persistence.rs b/packages/rs-platform-wallet-ffi/src/persistence.rs index ccca7077baa..37bcecc9f82 100644 --- a/packages/rs-platform-wallet-ffi/src/persistence.rs +++ b/packages/rs-platform-wallet-ffi/src/persistence.rs @@ -172,12 +172,25 @@ pub const PLATFORM_WALLET_PERSISTENCE_CAPABILITY_ASSET_LOCK_OPERATION_IDS: u64 = pub const PLATFORM_WALLET_PERSISTENCE_CAPABILITY_ASSET_LOCK_SHIELDED_RECIPIENTS: u64 = 1 << 14; /// The private contact request sync's scan state (each identity's read /// position and its private contacts' shielded addresses) is persisted AND -/// restored across restarts. Requires the extension trio +/// restored across restarts. Requires the extension slots /// `on_persist_private_contact_scan_state_fn` + /// `on_load_private_contact_scan_state_fn` + -/// `on_load_private_contact_scan_state_free_fn`, the wallet-list restore -/// callbacks, `on_persist_contacts_fn` (a read position vouches for the -/// contact requests read before it), and the host declaring the bit. The +/// `on_load_private_contact_scan_state_free_fn` + +/// `on_load_private_contact_scan_state_wallets_fn` + +/// `on_load_private_contact_scan_state_wallets_free_fn`, the wallet-list +/// restore callbacks, `on_persist_contacts_fn` (a read position vouches for +/// the contact requests read before it), and the host declaring the bit. +/// +/// A host declaring it keeps a position record whose +/// `private_payments_unresolved` is set through the removal of its wallet +/// and any purge of that wallet's rows, until a record stored cleared for +/// the same wallet and owner replaces it, and lists that wallet's id from +/// `on_load_private_contact_scan_state_wallets_fn`. The marker stands for a +/// private payment that may still execute: the load hands such records +/// (those of a wallet it does not return, or of an identity it does not +/// restore) to the wallet as `ClientStartState::retained_private_contact_scan_states`, +/// and a wallet registered or loaded under the id holds the identity they +/// name. The /// wallet keeps the state only together with /// [`PLATFORM_WALLET_PERSISTENCE_CAPABILITY_ATOMIC_CHANGESETS`] /// (`PersistenceCapabilities::PRIVATE_CONTACT_SCAN_STATE_RESTART`), which @@ -420,6 +433,29 @@ pub type FreePrivateContactScanStateFn = unsafe extern "C" fn( addresses_count: usize, ); +/// Return the ids of every wallet the host keeps private contact request +/// scan state for, those of wallets it no longer holds included: a removed +/// wallet whose position records it kept marked (see +/// [`PLATFORM_WALLET_PERSISTENCE_CAPABILITY_PRIVATE_CONTACT_REQUEST_SCAN_STATE`]). +/// Rust reads each id the wallet-list load does not return through +/// [`LoadPrivateContactScanStateFn`]. The host allocates the array and keeps +/// it valid until Rust hands it back through +/// [`FreePrivateContactScanStateWalletsFn`]; a null array with a zero count is +/// an empty one. An unreadable store must return an error, not an empty +/// array. +pub type LoadPrivateContactScanStateWalletsFn = unsafe extern "C" fn( + context: *mut c_void, + out_wallet_ids: *mut *const [u8; 32], + out_wallet_ids_count: *mut usize, +) -> i32; + +/// Release the array a [`LoadPrivateContactScanStateWalletsFn`] returned. +pub type FreePrivateContactScanStateWalletsFn = unsafe extern "C" fn( + context: *mut c_void, + wallet_ids: *const [u8; 32], + wallet_ids_count: usize, +); + /// Size- and version-tagged additive persistence callbacks. /// /// `context` is the context in the accompanying [`PersistenceCallbacks`] @@ -612,8 +648,9 @@ pub struct PersistenceCallbacksExtension { /// The private contact request scan state a round writes (see /// [`PersistPrivateContactScanStateFn`]). Appended under the same /// version for the same reason as the slots above. Wired together with - /// the load + free pair below: `PRIVATE_CONTACT_REQUEST_SCAN_STATE` is - /// attested only when all three are present (and declared). + /// the load + free pair and the wallet-list pair below: + /// `PRIVATE_CONTACT_REQUEST_SCAN_STATE` is attested only when all five + /// are present (and declared). #[allow(clippy::type_complexity)] pub on_persist_private_contact_scan_state_fn: Option< unsafe extern "C" fn( @@ -651,6 +688,27 @@ pub struct PersistenceCallbacksExtension { addresses_count: usize, ), >, + /// The ids of every wallet the host keeps scan state for (see + /// [`LoadPrivateContactScanStateWalletsFn`]). Appended under the same + /// version; wired with the free slot below and the three slots above, + /// `PRIVATE_CONTACT_REQUEST_SCAN_STATE` is attested only when all five + /// are present (and declared). + pub on_load_private_contact_scan_state_wallets_fn: Option< + unsafe extern "C" fn( + context: *mut c_void, + out_wallet_ids: *mut *const [u8; 32], + out_wallet_ids_count: *mut usize, + ) -> i32, + >, + /// Release the array `on_load_private_contact_scan_state_wallets_fn` + /// returned. + pub on_load_private_contact_scan_state_wallets_free_fn: Option< + unsafe extern "C" fn( + context: *mut c_void, + wallet_ids: *const [u8; 32], + wallet_ids_count: usize, + ), + >, } impl Default for PersistenceCallbacksExtension { @@ -676,6 +734,8 @@ impl Default for PersistenceCallbacksExtension { on_persist_private_contact_scan_state_fn: None, on_load_private_contact_scan_state_fn: None, on_load_private_contact_scan_state_free_fn: None, + on_load_private_contact_scan_state_wallets_fn: None, + on_load_private_contact_scan_state_wallets_free_fn: None, } } } @@ -702,6 +762,8 @@ pub struct PersistenceExtensionCallbacks { pub persist_private_contact_scan_state: Option, pub load_private_contact_scan_state: Option, pub load_private_contact_scan_state_free: Option, + pub load_private_contact_scan_state_wallets: Option, + pub load_private_contact_scan_state_wallets_free: Option, } /// Return value by which a persistence callback reports a **retryable** @@ -1872,7 +1934,7 @@ impl FFIPersister { capabilities = capabilities.union(PersistenceCapabilities::UNSIGNED_TOKEN_STORAGE); } // The scan state is restored per wallet during the wallet-list load, - // so that load is required alongside the extension trio. So is the + // so that load is required alongside the extension slots. So is the // contacts callback: a read position vouches for the contact // requests read before it, and without that callback `store` skips // them, so a restored position would skip entries whose requests @@ -1891,6 +1953,16 @@ impl FFIPersister { .tracked_masternodes_callbacks .load_private_contact_scan_state_free .is_some() + // A host that keeps marked records past their wallet's removal + // must also hand them back. + && self + .tracked_masternodes_callbacks + .load_private_contact_scan_state_wallets + .is_some() + && self + .tracked_masternodes_callbacks + .load_private_contact_scan_state_wallets_free + .is_some() { capabilities = capabilities.union(PersistenceCapabilities::PRIVATE_CONTACT_REQUEST_SCAN_STATE); @@ -1954,6 +2026,53 @@ impl FFIPersister { } } +impl FFIPersister { + /// Hand the wallet the marked private contact request scan state records + /// no restored identity took: `retained` holds those of the wallets the + /// load returned, and the records of every other wallet the host keeps + /// scan state for are read here + /// (`ClientStartState::retained_private_contact_scan_states`). Read only + /// for a host whose capabilities meet the wallet's rule for keeping the + /// state; another host's records reach nothing. + fn load_retained_private_contact_scan_states( + &self, + out: &mut ClientStartState, + mut retained: BTreeMap>, + ) -> Result<(), PersistenceError> { + let callbacks = &self.tracked_masternodes_callbacks; + if let (Some(list), Some(list_free), Some(load), Some(free)) = ( + callbacks.load_private_contact_scan_state_wallets, + callbacks.load_private_contact_scan_state_wallets_free, + callbacks.load_private_contact_scan_state, + callbacks.load_private_contact_scan_state_free, + ) { + if self + .persistence_capabilities() + .contains(PersistenceCapabilities::PRIVATE_CONTACT_SCAN_STATE_RESTART) + { + let context = self.callbacks.context; + for wallet_id in load_private_contact_scan_state_wallets(list, list_free, context)? + { + if out.wallets.contains_key(&wallet_id) { + continue; + } + // A wallet the host no longer holds restores no + // identity: every marked record it kept is retained. + let mut none = IdentityManagerStartState::default(); + let unapplied = load_private_contact_scan_state( + load, free, context, &wallet_id, &mut none, + )?; + if !unapplied.is_empty() { + retained.insert(wallet_id, unapplied); + } + } + } + } + out.retained_private_contact_scan_states = retained; + Ok(()) + } +} + impl PlatformWalletPersistence for FFIPersister { /// A host attests `ATOMIC_CHANGESETS` for a round it commits or rolls back /// whole; nothing of a rolled-back round survives on this side either, so @@ -3720,7 +3839,11 @@ impl PlatformWalletPersistence for FFIPersister { "wallet restore: missing row buffer", )); } + // The marked scan state records no restored identity takes, by wallet: + // a wallet the host no longer holds keeps them too. + let mut retained = BTreeMap::new(); if count == 0 { + self.load_retained_private_contact_scan_states(&mut out, retained)?; return Ok(out); } @@ -3772,13 +3895,16 @@ impl PlatformWalletPersistence for FFIPersister { .persistence_capabilities() .contains(PersistenceCapabilities::PRIVATE_CONTACT_SCAN_STATE_RESTART) { - load_private_contact_scan_state( + let unapplied = load_private_contact_scan_state( load, free, self.callbacks.context, &entry.wallet_id, &mut wallet_state.identity_manager, )?; + if !unapplied.is_empty() { + retained.insert(entry.wallet_id, unapplied); + } } } if let Some(cb) = self.load_identity_balance_block_time_callback { @@ -3815,6 +3941,7 @@ impl PlatformWalletPersistence for FFIPersister { .insert(entry.wallet_id, platform_address_state); } } + self.load_retained_private_contact_scan_states(&mut out, retained)?; // Restore shielded sub-wallet state if the host has wired // up the optional callbacks. Notes and per-subwallet sync @@ -6685,7 +6812,7 @@ fn load_private_contact_scan_state( context: *mut c_void, wallet_id: &WalletId, identity_manager: &mut IdentityManagerStartState, -) -> Result<(), PersistenceError> { +) -> Result, PersistenceError> { struct Guard { free: FreePrivateContactScanStateFn, context: *mut c_void, @@ -6759,13 +6886,22 @@ fn load_private_contact_scan_state( .flat_map(BTreeMap::values_mut) .map(|managed| (managed.id(), managed)) .collect(); + // A marked record of an identity not restored here still stands for a + // private payment that may execute: it is returned, so the wallet holds + // the identity when it is added. An unmarked one has nothing to hold. + let mut unapplied = BTreeMap::new(); for row in positions { - if let Some(managed) = identities.get_mut(&Identifier::new(row.identity_id)) { - managed.apply_private_contact_requests_position(PrivateContactRequestsPosition { - position: row.position, - key_fingerprint: row.key_fingerprint, - private_payments_unresolved: row.private_payments_unresolved, - }); + let record = PrivateContactRequestsPosition { + position: row.position, + key_fingerprint: row.key_fingerprint, + private_payments_unresolved: row.private_payments_unresolved, + }; + match identities.get_mut(&Identifier::new(row.identity_id)) { + Some(managed) => managed.apply_private_contact_requests_position(record), + None if record.private_payments_unresolved => { + unapplied.insert(Identifier::new(row.identity_id), record); + } + None => {} } } for row in addresses { @@ -6773,7 +6909,55 @@ fn load_private_contact_scan_state( managed.apply_private_shielded_address(Identifier::new(row.contact_id), row.address); } } - Ok(()) + Ok(unapplied) +} + +/// The ids of every wallet the host keeps private contact request scan state +/// for ([`LoadPrivateContactScanStateWalletsFn`]), the host's array released +/// before this returns. +fn load_private_contact_scan_state_wallets( + load: LoadPrivateContactScanStateWalletsFn, + free: FreePrivateContactScanStateWalletsFn, + context: *mut c_void, +) -> Result, PersistenceError> { + struct Guard { + free: FreePrivateContactScanStateWalletsFn, + context: *mut c_void, + wallet_ids: *const [u8; 32], + count: usize, + } + impl Drop for Guard { + fn drop(&mut self) { + if !self.wallet_ids.is_null() { + unsafe { (self.free)(self.context, self.wallet_ids, self.count) }; + } + } + } + + let mut guard = Guard { + free, + context, + wallet_ids: std::ptr::null(), + count: 0, + }; + let rc = unsafe { load(context, &mut guard.wallet_ids, &mut guard.count) }; + if rc != 0 { + return Err(persist_callback_error( + rc, + format!("on_load_private_contact_scan_state_wallets_fn returned error code {rc}"), + )); + } + if guard.wallet_ids.is_null() && guard.count != 0 { + return Err(PersistenceError::backend( + "private contact request scan state wallets: missing row buffer", + )); + } + if guard.count == 0 { + return Ok(Vec::new()); + } + // SAFETY: the host keeps `count` contiguous ids valid until `guard` + // hands them back. + Ok(unsafe { slice::from_raw_parts(guard.wallet_ids, guard.count) }.to_vec()) } /// Rebuild tracked locks from the host's frozen `AssetLockEntryFFI` rows. @@ -8224,6 +8408,37 @@ mod tests { load_mode: u8, loads: std::sync::atomic::AtomicUsize, frees: std::sync::atomic::AtomicUsize, + /// What `load_scan_state_wallets` lists. + wallet_ids: std::sync::Mutex>, + } + + unsafe extern "C" fn load_scan_state_wallets( + ctx: *mut c_void, + out_wallet_ids: *mut *const [u8; 32], + out_count: *mut usize, + ) -> i32 { + let host = &*(ctx as *const ScanStateHost); + let ids = host.wallet_ids.lock().unwrap().clone(); + *out_count = ids.len(); + *out_wallet_ids = if ids.is_empty() { + std::ptr::null() + } else { + Box::into_raw(ids.into_boxed_slice()).cast() + }; + 0 + } + + unsafe extern "C" fn free_scan_state_wallets( + _: *mut c_void, + wallet_ids: *const [u8; 32], + count: usize, + ) { + if !wallet_ids.is_null() { + drop(Box::from_raw(std::ptr::slice_from_raw_parts_mut( + wallet_ids.cast_mut(), + count, + ))); + } } #[allow(clippy::too_many_arguments)] @@ -8407,6 +8622,8 @@ mod tests { persist_private_contact_scan_state: Some(persist_scan_state), load_private_contact_scan_state: Some(load_scan_state), load_private_contact_scan_state_free: Some(free_scan_state), + load_private_contact_scan_state_wallets: Some(load_scan_state_wallets), + load_private_contact_scan_state_wallets_free: Some(free_scan_state_wallets), ..Default::default() } } @@ -8651,7 +8868,7 @@ mod tests { } /// A host that does not declare the capability, wires only part of the - /// trio, keeps no contact requests, or has no atomic changesets (no + /// slots (the wallet list included), keeps no contact requests, or has no atomic changesets (no /// begin and end callbacks, or no declaration) does not meet the /// wallet's rule for keeping the scan state, has nothing read at load, /// and still stores and loads everything else. A host without the slots @@ -8679,6 +8896,17 @@ mod tests { ..Default::default() }, ); + // A host that cannot list the wallets it keeps marked records for + // could not hand back a removed wallet's. + let without_wallet_list = scan_state_persister( + &host, + PersistenceCapabilities::PRIVATE_CONTACT_SCAN_STATE_RESTART, + PersistenceExtensionCallbacks { + load_private_contact_scan_state_wallets: None, + load_private_contact_scan_state_wallets_free: None, + ..scan_state_trio() + }, + ); let unwired = scan_state_persister( &host, PersistenceCapabilities::PRIVATE_CONTACT_REQUEST_SCAN_STATE, @@ -8693,7 +8921,7 @@ mod tests { false, ); // The state is kept only with atomic changesets: a host declaring - // both, with the trio, but no changeset begin and end callbacks, and + // both, with every slot, but no changeset begin and end callbacks, and // a host bracketing its rounds but not declaring them atomic. let without_brackets = scan_state_persister_with( &host, @@ -8710,6 +8938,7 @@ mod tests { for persister in [ &undeclared, &half_wired, + &without_wallet_list, &unwired, &without_contacts, &without_brackets, @@ -8754,6 +8983,343 @@ mod tests { .expect("a host without the slots stores the round"); } + /// A host keeping marked scan state records: the per-wallet rows of + /// several wallets, the wallets listed, the wallet-list load returning + /// some of them. + #[derive(Default)] + struct RetainedHost { + rows: std::sync::Mutex>>, + wallets_returned: Vec<([u8; 32], [u8; 32])>, + list_fails: bool, + list_frees: std::sync::atomic::AtomicUsize, + } + + unsafe extern "C" fn retained_load_rows( + ctx: *mut c_void, + wallet: *const u8, + out_positions: *mut *const PrivateContactRequestsPositionFFI, + out_positions_count: *mut usize, + out_addresses: *mut *const PrivateShieldedAddressFFI, + out_addresses_count: *mut usize, + ) -> i32 { + let host = &*(ctx as *const RetainedHost); + let wallet: [u8; 32] = std::slice::from_raw_parts(wallet, 32).try_into().unwrap(); + let rows = host + .rows + .lock() + .unwrap() + .get(&wallet) + .cloned() + .unwrap_or_default(); + *out_positions_count = rows.len(); + *out_positions = if rows.is_empty() { + std::ptr::null() + } else { + Box::into_raw(rows.into_boxed_slice()).cast() + }; + *out_addresses = std::ptr::null(); + *out_addresses_count = 0; + 0 + } + + /// Stores nothing: these tests load what the host kept. + #[allow(clippy::too_many_arguments)] + unsafe extern "C" fn retained_persist_rows( + _: *mut c_void, + _: *const u8, + _: *const PrivateContactRequestsPositionFFI, + _: usize, + _: *const PrivateShieldedAddressFFI, + _: usize, + _: *const ContactRequestRemovalFFI, + _: usize, + _: *const [u8; 32], + _: usize, + ) -> i32 { + 0 + } + + unsafe extern "C" fn retained_free_rows( + _: *mut c_void, + positions: *const PrivateContactRequestsPositionFFI, + positions_count: usize, + _: *const PrivateShieldedAddressFFI, + _: usize, + ) { + if !positions.is_null() { + drop(Box::from_raw(std::ptr::slice_from_raw_parts_mut( + positions.cast_mut(), + positions_count, + ))); + } + } + + unsafe extern "C" fn retained_list_wallets( + ctx: *mut c_void, + out_wallet_ids: *mut *const [u8; 32], + out_count: *mut usize, + ) -> i32 { + let host = &*(ctx as *const RetainedHost); + if host.list_fails { + return -1; + } + let ids: Vec<[u8; 32]> = host.rows.lock().unwrap().keys().copied().collect(); + *out_count = ids.len(); + *out_wallet_ids = if ids.is_empty() { + std::ptr::null() + } else { + Box::into_raw(ids.into_boxed_slice()).cast() + }; + 0 + } + + unsafe extern "C" fn retained_free_wallets( + ctx: *mut c_void, + wallet_ids: *const [u8; 32], + count: usize, + ) { + let host = &*(ctx as *const RetainedHost); + host.list_frees + .fetch_add(1, std::sync::atomic::Ordering::SeqCst); + if !wallet_ids.is_null() { + drop(Box::from_raw(std::ptr::slice_from_raw_parts_mut( + wallet_ids.cast_mut(), + count, + ))); + } + } + + unsafe extern "C" fn retained_load_wallets( + ctx: *mut c_void, + entries: *mut *const WalletRestoreEntryFFI, + count: *mut usize, + ) -> i32 { + let host = &*(ctx as *const RetainedHost); + let rows: Vec = host + .wallets_returned + .iter() + .map(|(wallet_id, identity_id)| { + let mut identity: IdentityRestoreEntryFFI = std::mem::zeroed(); + identity.identity_id = *identity_id; + WalletRestoreEntryFFI { + wallet_id: *wallet_id, + identities: Box::into_raw(Box::new(identity)), + identities_count: 1, + ..Default::default() + } + }) + .collect(); + *count = rows.len(); + *entries = if rows.is_empty() { + std::ptr::null() + } else { + Box::into_raw(rows.into_boxed_slice()).cast() + }; + 0 + } + + unsafe extern "C" fn retained_free_loaded_wallets( + _: *mut c_void, + entries: *const WalletRestoreEntryFFI, + count: usize, + ) { + if entries.is_null() { + return; + } + let rows = Box::from_raw(std::ptr::slice_from_raw_parts_mut( + entries.cast_mut(), + count, + )); + for row in rows.iter() { + drop(Box::from_raw(row.identities.cast_mut())); + } + } + + fn retained_persister(host: &RetainedHost) -> FFIPersister { + FFIPersister::new_with_persistence_capabilities_and_extensions( + PersistenceCallbacks { + context: (host as *const RetainedHost).cast_mut().cast(), + on_changeset_begin_fn: Some(noop_begin), + on_changeset_end_fn: Some(noop_end), + on_load_wallet_list_fn: Some(retained_load_wallets), + on_load_wallet_list_free_fn: Some(retained_free_loaded_wallets), + on_persist_contacts_fn: Some(keep_scan_state_contacts), + ..Default::default() + }, + PersistenceCapabilities::PRIVATE_CONTACT_SCAN_STATE_RESTART, + PersistenceExtensionCallbacks { + persist_private_contact_scan_state: Some(retained_persist_rows), + load_private_contact_scan_state: Some(retained_load_rows), + load_private_contact_scan_state_free: Some(retained_free_rows), + load_private_contact_scan_state_wallets: Some(retained_list_wallets), + load_private_contact_scan_state_wallets_free: Some(retained_free_wallets), + ..Default::default() + }, + ) + } + + fn position_row( + identity: u8, + position: u64, + marked: bool, + ) -> PrivateContactRequestsPositionFFI { + PrivateContactRequestsPositionFFI { + identity_id: [identity; 32], + position, + key_fingerprint: [identity; 32], + private_payments_unresolved: marked, + } + } + + /// A load hands the wallet every marked record no restored identity + /// takes, as `retained_private_contact_scan_states`: a removed wallet's, + /// which the host lists but no longer returns, and a returned wallet's + /// for an identity it did not restore. A record a restored identity takes + /// is applied to it instead, an unmarked record of an absent identity is + /// passed over, and a host holding no wallet still hands back what it + /// kept. A host that cannot list its wallets fails the load. + #[test] + fn should_hand_back_the_marked_records_no_restored_identity_takes() { + let host = RetainedHost { + wallets_returned: vec![([42; 32], [7; 32])], + ..Default::default() + }; + host.rows.lock().unwrap().extend([ + ( + [42; 32], + vec![ + position_row(7, 12, true), + position_row(9, 13, true), + position_row(10, 14, false), + ], + ), + ( + [43; 32], + vec![position_row(11, 15, true), position_row(12, 16, false)], + ), + ]); + let restored = retained_persister(&host).load().expect("the load"); + let managed = &restored.wallets[&[42; 32]] + .identity_manager + .wallet_identities[&[42; 32]][&0]; + assert!( + managed.dashpay().private_payments_unresolved(), + "a restored identity takes its own record" + ); + let retained = &restored.retained_private_contact_scan_states; + assert_eq!( + retained.keys().copied().collect::>(), + vec![[42; 32], [43; 32]] + ); + assert_eq!( + retained[&[42; 32]].keys().copied().collect::>(), + vec![Identifier::from([9; 32])], + "only the marked record of an identity the load did not restore" + ); + assert_eq!( + retained[&[43; 32]], + BTreeMap::from([( + Identifier::from([11; 32]), + PrivateContactRequestsPosition { + position: 15, + key_fingerprint: [11; 32], + private_payments_unresolved: true, + }, + )]), + "a removed wallet's marked record, as stored" + ); + assert_eq!( + host.list_frees.load(std::sync::atomic::Ordering::SeqCst), + 1, + "the listed ids are handed back" + ); + + let no_wallets = RetainedHost::default(); + no_wallets + .rows + .lock() + .unwrap() + .insert([43; 32], vec![position_row(11, 15, true)]); + let restored = retained_persister(&no_wallets).load().expect("the load"); + assert!(restored.wallets.is_empty()); + assert!(restored.retained_private_contact_scan_states[&[43; 32]] + .contains_key(&Identifier::from([11; 32]))); + + let failing = RetainedHost { + list_fails: true, + ..Default::default() + }; + assert!( + retained_persister(&failing).load().is_err(), + "a host that cannot list its wallets fails the load" + ); + } + + /// A wallet registered under an id whose record the host kept marked + /// after the wallet was removed holds the identity the record names as it + /// is added, and no other. + #[test] + fn should_hold_an_identity_a_kept_marked_record_names_when_its_wallet_is_registered_again() { + use crate::runtime::runtime; + use crate::test_fixtures::{mock_manager, mock_manager_with}; + use dpp::identity::{Identity, IdentityV0}; + use key_wallet::wallet::initialization::WalletAccountCreationOptions; + + let seed = [21u8; 64]; + let create = |manager: &platform_wallet::PlatformWalletManager| { + runtime() + .block_on(manager.create_wallet_from_seed_bytes( + key_wallet::Network::Testnet, + &seed, + WalletAccountCreationOptions::Default, + Some(0), + )) + .expect("a wallet") + }; + let wallet_id = create(&mock_manager()).wallet_id(); + + let marked = Identifier::from([31; 32]); + let other = Identifier::from([32; 32]); + let host = RetainedHost::default(); + host.rows + .lock() + .unwrap() + .insert(wallet_id, vec![position_row(31, 5, true)]); + let manager = mock_manager_with(retained_persister(&host)); + let wallet = create(&manager); + assert_eq!(wallet.wallet_id(), wallet_id); + + let held = |id: Identifier| { + runtime().block_on(async { + let mut wm = wallet.wallet_manager().write().await; + let identities = &mut wm + .get_wallet_info_mut(&wallet.wallet_id()) + .expect("the wallet's info") + .identity_manager; + identities + .add_identity( + Identity::V0(IdentityV0 { + id, + public_keys: Default::default(), + balance: 0, + revision: 0, + }), + 0, + wallet.wallet_id(), + wallet.persister(), + ) + .expect("the identity"); + identities + .managed_identity(&id) + .expect("the identity") + .dashpay() + .private_payments_unrestored() + }) + }; + assert!(held(marked), "the identity the kept record names is held"); + assert!(!held(other), "an identity no kept record names is not"); + } + #[test] fn should_persist_balance_watermarks_only_for_the_owning_wallet() { use platform_wallet::changeset::{IdentityChangeSet, IdentityEntry}; @@ -11538,6 +12104,26 @@ mod tests { PersistenceCallbacksExtension, on_load_private_contact_scan_state_free_fn ) + std::mem::size_of::>(), + std::mem::offset_of!( + PersistenceCallbacksExtension, + on_load_private_contact_scan_state_wallets_fn + ) + ); + assert_eq!( + std::mem::offset_of!( + PersistenceCallbacksExtension, + on_load_private_contact_scan_state_wallets_fn + ) + std::mem::size_of::>(), + std::mem::offset_of!( + PersistenceCallbacksExtension, + on_load_private_contact_scan_state_wallets_free_fn + ) + ); + assert_eq!( + std::mem::offset_of!( + PersistenceCallbacksExtension, + on_load_private_contact_scan_state_wallets_free_fn + ) + std::mem::size_of::>(), std::mem::size_of::() ); assert_eq!( diff --git a/packages/rs-platform-wallet-ffi/src/private_contact_request.rs b/packages/rs-platform-wallet-ffi/src/private_contact_request.rs index 02b7ddcfe59..d4eab49caa4 100644 --- a/packages/rs-platform-wallet-ffi/src/private_contact_request.rs +++ b/packages/rs-platform-wallet-ffi/src/private_contact_request.rs @@ -323,7 +323,8 @@ pub unsafe extern "C" fn managed_identity_get_private_contact( /// sync in between. /// /// `out_held` is reset to false first. An unknown `wallet_handle` is -/// `NotFound`; a sender the wallet does not hold is refused as its lookup is. +/// `NotFound`; a sender the wallet does not hold is `ErrorIdentityNotFound` +/// (7). /// /// # Safety /// - `sender_id` and `recipient_id` must each point to 32 readable bytes. @@ -367,8 +368,8 @@ pub unsafe extern "C" fn platform_wallet_get_private_pair_held( /// applied first, as there. /// /// `out_reason` is reset to null first. An unknown `wallet_handle` is -/// `NotFound`; an identity the wallet does not hold is refused as its lookup -/// is. +/// `NotFound`; an identity the wallet does not hold is +/// `ErrorIdentityNotFound` (7). /// /// # Safety /// - `identity_id` must point to 32 readable bytes. @@ -1743,8 +1744,8 @@ mod tests { /// A wallet whose identity is paying for nothing to a contact reads the /// pair as free and has no restoration failure to give; an unknown wallet - /// is not found and an unknown sender refused, each leaving the output - /// reset; a null output is refused. Holding a pair is the wallet's own + /// is not found and an identity the wallet does not hold is code 7, each + /// leaving the output reset; a null output is refused. Holding a pair is the wallet's own /// (its tests pin the reservation and the journal mirror), so this pins /// the reads. #[test] @@ -1836,8 +1837,24 @@ mod tests { &mut held, ) }; - assert_ne!(unknown_sender.code, PlatformWalletFFIResultCode::Success); + assert_eq!( + unknown_sender.code, + PlatformWalletFFIResultCode::ErrorIdentityNotFound + ); assert!(!held); + let mut reason: *mut c_char = std::ptr::dangling_mut(); + let unknown_identity = unsafe { + platform_wallet_get_private_payments_restore_failure( + handle, + stranger.as_ptr(), + &mut reason, + ) + }; + assert_eq!( + unknown_identity.code, + PlatformWalletFFIResultCode::ErrorIdentityNotFound + ); + assert!(reason.is_null(), "reset"); assert_eq!( unsafe { diff --git a/packages/rs-platform-wallet-ffi/src/test_fixtures.rs b/packages/rs-platform-wallet-ffi/src/test_fixtures.rs index d41459b6586..8e2db501f64 100644 --- a/packages/rs-platform-wallet-ffi/src/test_fixtures.rs +++ b/packages/rs-platform-wallet-ffi/src/test_fixtures.rs @@ -22,11 +22,15 @@ pub(crate) fn mock_manager() -> PlatformWalletManager { unsafe extern "C" fn end(_: *mut std::ffi::c_void, _: *const u8, _: bool) -> i32 { 0 } - let persister = FFIPersister::new(PersistenceCallbacks { + mock_manager_with(FFIPersister::new(PersistenceCallbacks { on_changeset_begin_fn: Some(begin), on_changeset_end_fn: Some(end), ..Default::default() - }); + })) +} + +/// [`mock_manager`] over `persister`. +pub(crate) fn mock_manager_with(persister: FFIPersister) -> PlatformWalletManager { let events = FFIEventHandler::new( EventHandlerCallbacks { context: std::ptr::null_mut(), From 41fbbef89387717e7381a8e365ab56fd928b663c Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Tue, 10 Nov 2026 19:38:02 +0700 Subject: [PATCH 244/269] feat(swift-sdk): keep marked scan state records through a wallet's purge and list them for the load - deleteWalletData keeps a PersistentPrivateContactRequestsPosition whose privatePaymentsUnresolved is set, and still purges every other position and every address with the wallet. A record stored cleared for the same wallet and identity replaces a kept one, so the next purge removes it. - loadPrivateContactScanStateWalletIds lists the network's wallets with stored positions, a removed wallet's kept ones included. It is wired as on_load_private_contact_scan_state_wallets_fn, with its free shim. - No SwiftData model or schema version change. - The pair-hold, readiness and restore-failure reads document PlatformWalletError.identityNotFound (code 7) for an identity the wallet does not hold. - Tests: - testShouldKeepAMarkedRecordThroughItsWalletsPurgeUntilAClearedRecordReplacesIt - testShouldListTheWalletsWithScanStateThroughTheCallbackShims Co-Authored-By: Claude Opus 5.5 --- .../PlatformWalletManager.swift | 8 ++- .../PlatformWalletPersistenceHandler.swift | 71 ++++++++++++++++++- .../PrivateContactRequests.swift | 10 ++- ...vateContactScanStatePersistenceTests.swift | 70 ++++++++++++++++++ 4 files changed, 152 insertions(+), 7 deletions(-) diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift index ef33146866f..fce0f9505e5 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManager.swift @@ -117,9 +117,13 @@ public struct PlatformWalletPersistenceCapabilities: Equatable, Sendable { /// The private contact request sync's scan state (each identity's read /// position in DashPay's private contact request store, and its private /// contacts' shielded addresses) is stored on its round and restored at - /// load, so a relaunch resumes the sync where it stopped. Mirrors + /// load, so a relaunch resumes the sync where it stopped. A store + /// declaring it keeps a position record marked unresolved through its + /// wallet's removal, until a record stored cleared for the same wallet + /// and identity replaces it, and lists that wallet so Rust holds the + /// identity again if the wallet comes back. Mirrors /// `PersistenceCapabilities::PRIVATE_CONTACT_REQUEST_SCAN_STATE`; Rust - /// only honours the declaration when the extension carries the three + /// only honours the declaration when the extension carries the five /// scan-state callbacks. public static let privateContactRequestScanState: UInt64 = 1 << 15 diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift index 67d4b5b1d48..40fc2e9aab3 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift @@ -3359,6 +3359,10 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { extensionCallbacks.on_load_private_contact_scan_state_fn = loadPrivateContactScanStateCallback extensionCallbacks.on_load_private_contact_scan_state_free_fn = loadPrivateContactScanStateFreeCallback + extensionCallbacks.on_load_private_contact_scan_state_wallets_fn = + loadPrivateContactScanStateWalletsCallback + extensionCallbacks.on_load_private_contact_scan_state_wallets_free_fn = + loadPrivateContactScanStateWalletsFreeCallback return extensionCallbacks } @@ -5273,6 +5277,21 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { } } + /// The ids of every wallet this handler's network keeps private contact + /// request scan state for, a removed wallet whose marked position + /// records outlived it included. Rust reads each one its wallet-list + /// load does not return and hands its marked records to the wallet as + /// retained. Throws when the store cannot be read. + func loadPrivateContactScanStateWalletIds() throws -> [Data] { + try onQueue { + let networkRaw = (self.network ?? .testnet).rawValue + let positions = try backgroundContext.fetch(FetchDescriptor( + predicate: #Predicate { $0.networkRaw == networkRaw })) + var seen = Set() + return positions.compactMap { seen.insert($0.walletId).inserted ? $0.walletId : nil } + } + } + /// The stored scan state of `walletId`. Throws when the store cannot be /// read, so the load fails rather than reading the store from its start. func loadPrivateContactScanState(walletId: Data) throws @@ -6837,7 +6856,13 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { backgroundContext.delete(row) } // The private contact request scan state carries the same - // explicit network key, so it is purged by the same rule. + // explicit network key, so it is purged by the same rule, + // except a position record whose unresolved-payment marker + // is set: it stands for a private payment that may still + // execute, so it outlives the wallet until a record stored + // cleared for the same wallet and identity replaces it, and + // the next load hands it back as retained + // (`loadPrivateContactScanStateWalletIds`). let positions: FetchDescriptor let addresses: FetchDescriptor if let raw = metadataNetwork?.rawValue { @@ -6852,7 +6877,7 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { addresses = FetchDescriptor(predicate: #Predicate { $0.walletId == walletId }) } for row in try backgroundContext.fetch(positions) - where !claimedNetworks.contains(row.networkRaw) { + where !claimedNetworks.contains(row.networkRaw) && !row.privatePaymentsUnresolved { backgroundContext.delete(row) } for row in try backgroundContext.fetch(addresses) @@ -12273,6 +12298,48 @@ private func loadPrivateContactScanStateFreeCallback( } } +/// C shim for `on_load_private_contact_scan_state_wallets_fn`: the ids of +/// the wallets the handler keeps scan state for, as one array of 32-byte ids +/// released by the free shim below. A stored id that is not 32 bytes cannot +/// cross: the load fails, as for an unreadable store. +private func loadPrivateContactScanStateWalletsCallback( + context: UnsafeMutableRawPointer?, + outWalletIds: UnsafeMutablePointer?>?, + outWalletIdsCount: UnsafeMutablePointer? +) -> Int32 { + guard let context, let outWalletIds, let outWalletIdsCount else { return -1 } + outWalletIds.pointee = nil + outWalletIdsCount.pointee = 0 + let handler = Unmanaged.fromOpaque(context).takeUnretainedValue() + let ids: [Data] + do { + ids = try handler.loadPrivateContactScanStateWalletIds() + } catch { return -1 } + guard ids.allSatisfy({ $0.count == 32 }) else { return -1 } + guard !ids.isEmpty else { return 0 } + let buffer = UnsafeMutablePointer.allocate(capacity: ids.count) + for (index, id) in ids.enumerated() { + var tuple: FFIByteTuple32 = (0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0) + Swift.withUnsafeMutableBytes(of: &tuple) { _ = id.copyBytes(to: $0) } + (buffer + index).initialize(to: tuple) + } + outWalletIds.pointee = UnsafePointer(buffer) + outWalletIdsCount.pointee = UInt(ids.count) + return 0 +} + +/// C shim for `on_load_private_contact_scan_state_wallets_free_fn`. +private func loadPrivateContactScanStateWalletsFreeCallback( + context: UnsafeMutableRawPointer?, + walletIds: UnsafePointer?, walletIdsCount: UInt +) { + guard let walletIds else { return } + let buffer = UnsafeMutablePointer(mutating: walletIds) + buffer.deinitialize(count: Int(walletIdsCount)) + buffer.deallocate() +} + private func loadIdentityBalanceBlockTimeCallback( context: UnsafeMutableRawPointer?, walletId: UnsafePointer?, identityId: UnsafePointer?, outFound: UnsafeMutablePointer?, outBlockTime: UnsafeMutablePointer? diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PrivateContactRequests.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PrivateContactRequests.swift index a3a88a607c7..1455895b0b6 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PrivateContactRequests.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PrivateContactRequests.swift @@ -140,7 +140,8 @@ extension ManagedPlatformWallet { /// applied first, briefly taking the wallet's write lock, as a send would /// see it; then it reads live wallet state. Read it again after the /// shielded store is configured or the wallet binds its shielded account - /// to see the restore. + /// to see the restore. Throws `PlatformWalletError.identityNotFound` + /// (code 7) for an identity the wallet does not hold. public func privateContactReadiness(identityId: Identifier) throws -> PrivateContactReadiness { var ffi = PrivateContactReadinessFFI() let handle = self.handle @@ -154,7 +155,9 @@ extension ManagedPlatformWallet { /// Why the last restoration of this wallet's private payments failed, /// while they are not restored for `identityId` and none succeeded since, /// or `nil` when none failed (restored, or only not restored yet). What - /// `PrivateContactReadiness.privatePaymentsRestoreFailed` flags. + /// `PrivateContactReadiness.privatePaymentsRestoreFailed` flags. Throws + /// `PlatformWalletError.identityNotFound` (code 7) for an identity the + /// wallet does not hold. public func privatePaymentsRestoreFailure(identityId: Identifier) throws -> String? { var reason: UnsafeMutablePointer? let handle = self.handle @@ -180,7 +183,8 @@ extension ManagedPlatformWallet { /// pruned, which the shielded sync judges); an endpoint's refusal of the /// payment does not. Not a pure read: a journal record discharged since /// is applied first, so a discharge reads as freed with no send or sync - /// in between. + /// in between. Throws `PlatformWalletError.identityNotFound` (code 7) + /// for a sender the wallet does not hold. public func privatePairHeld(senderId: Identifier, recipientId: Identifier) throws -> Bool { var held = false let handle = self.handle diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/PrivateContactScanStatePersistenceTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/PrivateContactScanStatePersistenceTests.swift index 0340c8545db..e90dac0b75a 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/PrivateContactScanStatePersistenceTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/PrivateContactScanStatePersistenceTests.swift @@ -124,6 +124,74 @@ final class PrivateContactScanStatePersistenceTests: XCTestCase { XCTAssertEqual(try context.fetchCount(FetchDescriptor()), 0) } + /// A position record marked unresolved survives its wallet's purge and + /// is listed for the load to hand back as retained; an unmarked record + /// and every address go with the wallet; a cleared record stored later + /// for the same wallet and identity replaces it, so the next purge + /// removes it. + func testShouldKeepAMarkedRecordThroughItsWalletsPurgeUntilAClearedRecordReplacesIt() throws { + let (container, handler) = try makeHandler() + let unmarked = Data(repeating: 1, count: 32) + let fingerprint = Data(repeating: 3, count: 32) + try round( + handler, + positions: [ + .init(identityId: owner, position: 12, keyFingerprint: fingerprint, + privatePaymentsUnresolved: true), + .init(identityId: unmarked, position: 4, keyFingerprint: fingerprint), + ], + addresses: [.init(ownerId: owner, contactId: contact, address: Data(repeating: 9, count: 43))]) + XCTAssertEqual(try handler.loadPrivateContactScanStateWalletIds(), [walletId]) + + try handler.deleteWalletData(walletId: walletId) + let context = ModelContext(container) + XCTAssertEqual(try context.fetchCount(FetchDescriptor()), 0) + var kept = try handler.loadPrivateContactScanState(walletId: walletId) + XCTAssertEqual(kept.positions, [.init( + identityId: owner, position: 12, keyFingerprint: fingerprint, privatePaymentsUnresolved: true)], + "only the marked record outlives the wallet") + XCTAssertTrue(kept.addresses.isEmpty) + XCTAssertEqual(try handler.loadPrivateContactScanStateWalletIds(), [walletId], + "the removed wallet is still listed for its kept record") + + // The wallet registered again stores the record cleared. + context.insert(PersistentWallet(walletId: walletId, network: .testnet)) + try context.save() + try round(handler, positions: [.init(identityId: owner, position: 0, keyFingerprint: fingerprint)]) + kept = try handler.loadPrivateContactScanState(walletId: walletId) + XCTAssertEqual(kept.positions.map(\.privatePaymentsUnresolved), [false]) + try handler.deleteWalletData(walletId: walletId) + XCTAssertEqual(try ModelContext(container).fetchCount( + FetchDescriptor()), 0) + XCTAssertTrue(try handler.loadPrivateContactScanStateWalletIds().isEmpty) + } + + /// The wallet-list shim hands Rust the listed ids as 32-byte rows and + /// its free shim releases them; an empty list crosses as null. + func testShouldListTheWalletsWithScanStateThroughTheCallbackShims() throws { + let (_, handler) = try makeHandler() + let ext = handler.makePersistenceCallbacksExtension() + let list = try XCTUnwrap(ext.on_load_private_contact_scan_state_wallets_fn) + let free = try XCTUnwrap(ext.on_load_private_contact_scan_state_wallets_free_fn) + let context = Unmanaged.passUnretained(handler).toOpaque() + + var ids: UnsafePointer? + var count: UInt = 7 + XCTAssertEqual(list(context, &ids, &count), 0) + XCTAssertNil(ids) + XCTAssertEqual(count, 0) + + try round(handler, positions: [.init( + identityId: owner, position: 1, keyFingerprint: Data(repeating: 3, count: 32), + privatePaymentsUnresolved: true)]) + try handler.deleteWalletData(walletId: walletId) + XCTAssertEqual(list(context, &ids, &count), 0) + XCTAssertEqual(count, 1) + var first = try XCTUnwrap(ids).pointee + XCTAssertEqual(Swift.withUnsafeBytes(of: &first) { Data($0) }, walletId) + free(context, ids, count) + } + /// A stored row that cannot cross the ABI fails the load instead of /// handing back the rest without it: a valid position beside a 42-byte /// address would otherwise resume past the entry that carried it. @@ -180,6 +248,8 @@ final class PrivateContactScanStatePersistenceTests: XCTestCase { let persist = try XCTUnwrap(ext.on_persist_private_contact_scan_state_fn) let load = try XCTUnwrap(ext.on_load_private_contact_scan_state_fn) let free = try XCTUnwrap(ext.on_load_private_contact_scan_state_free_fn) + XCTAssertNotNil(ext.on_load_private_contact_scan_state_wallets_fn) + XCTAssertNotNil(ext.on_load_private_contact_scan_state_wallets_free_fn) let context = Unmanaged.passUnretained(handler).toOpaque() var position = PrivateContactRequestsPositionFFI() From fb23948ac2a1e35fa1be1eeb3bcbe38783ea373f Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Tue, 10 Nov 2026 19:38:02 +0700 Subject: [PATCH 245/269] fix(swift-example-app): retry a failed private payment restoration on every DashPay refresh A restoration can fail after the shielded store opened. configureShielded then succeeds, so the app recorded no failure and never asked again, while the readiness kept Send and Accept off. Nothing short of a bind or a relaunch lifted it. - PrivatePaymentsRecovery.refresh is one flow for the request list and Add Contact: - When the store did not open, or the readiness reads not restored, it asks the wallet to retry (WalletManagerStore.configureShieldedAgain). On a store already open, that is the wallet's own retry of every wallet still held. It never waits on a busy store. - It then reads the readiness the view shows, so nothing displayed was read before the attempt. - The pair-hold reads follow. - While the payments stay unrestored, each view refreshes again every 3 s, at most 20 times, so a restoration the wallet deferred behind a busy store lifts the gate when it completes. No bind, restart or submitted request is needed. - privateRepliesPending reads code 7 (identityNotFound) as "holds nothing". The separate managed-identity lookup is gone. - Storage Explorer notes that a marked position record outlives its wallet. - The registry's row 72 describes the retry. - Tests: - testShouldRetryARestorationThatFailedAfterTheStoreOpenedOnARefresh - testShouldShowTheReadinessReadAfterTheRetry - testShouldNotAskTheWalletToRetryWhenThePaymentsAreRestored - testShouldOpenTheStoreAgainWhileAStoreFailureIsRecorded - testShouldRefreshAgainWhileARestorationIsDeferred Co-Authored-By: Claude Opus 5.5 --- .../ERROR_CODE_REGISTRY.md | 2 +- .../Views/DashPay/AddContactView.swift | 46 ++++-- .../DashPay/ContactRequestOutcomes.swift | 62 ++++++++ .../Views/DashPay/ContactRequestsView.swift | 44 ++++-- .../Views/DashPay/ContactsView.swift | 9 +- .../Views/StorageRecordDetailViews.swift | 11 +- .../SwiftExampleApp/WalletManagerStore.swift | 25 ++-- .../ContactRequestOutcomesTests.swift | 132 ++++++++++++++++++ 8 files changed, 293 insertions(+), 38 deletions(-) diff --git a/packages/rs-platform-wallet-ffi/ERROR_CODE_REGISTRY.md b/packages/rs-platform-wallet-ffi/ERROR_CODE_REGISTRY.md index 2bdfc24da26..a6d2e1b3a61 100644 --- a/packages/rs-platform-wallet-ffi/ERROR_CODE_REGISTRY.md +++ b/packages/rs-platform-wallet-ffi/ERROR_CODE_REGISTRY.md @@ -187,7 +187,7 @@ Fork-era numbers remain in the collision history, which is immutable record. | 69 | `ErrorShieldedBuildRequired` | DashPay private contact requests | Proposed. The operation needs the shielded pool and the library was built without the `shielded` feature: accepting in kind a request that came privately, whose 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. Returned by `platform_wallet_manager_accept_contact_request_replying_in_kind`, exported in every build. Swift types it (`PlatformWalletError.shieldedBuildRequired`, `ErrorHandlingTests` pin); Kotlin typing is added by the Kotlin PR (#5813) | | 70 | `ErrorProtocolVersionNotActive` | DashPay private contact requests | Proposed. The SDK refused a request without sending it (`dash_sdk::Error::ProtocolVersionNotActive`, not pinned) because the network is proven at a protocol version below the one that serves 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. An SDK pinned below the required version has its own code, 75. 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 types it (`PlatformWalletError.protocolVersionNotActive`, `ErrorHandlingTests` pin) and SwiftExampleApp keeps a refresh quiet on it; Kotlin typing is added by the Kotlin PR (#5813) | | 71 | `ErrorPrivateContactRequestPending` | DashPay private contact requests | 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 a private payment the shielded store's journal keeps unresolved holds the pair. Nothing new was paid; a later private contact request sync settles it once the entry is read back, and the shielded sync once the payment provably never executes. Pending, not failed: the host must not submit it again. A private store read that did not finish before the request was decided is 74, not 71. The shielded spend contract (`map_spend_result`) keeps the code rather than flattening it to `ErrorWalletOperation`. Returned by the private send, the private accept and the in-kind accept, and by a public send or accept the pair holds; `platform_wallet_get_private_pair_held` reads one pair's hold as a send decides it. Swift types it (`PlatformWalletError.privateContactRequestPending`, `ErrorHandlingTests` pin; `ManagedPlatformWallet.privatePairHeld`), and SwiftExampleApp keeps the pair pending until that read says free; Kotlin typing is added by the Kotlin PR (#5813) | -| 72 | `ErrorPrivateContactPaymentsNotRestored` | DashPay private contact requests | Proposed. Maps `PlatformWalletError::PrivateContactPaymentsNotRestored`: wallet readiness, not one contact's pending request (71). The wallet's unconfirmed private payments may hold pairs no reservation shows yet, which only a restoration from the shielded store's journal shows. In a build with the `shielded` feature: the wallet was published (created or loaded) while no shielded store was configured, its persister declaring the private scan state (bit 15), which holds a wallet removed and registered again from the same seed, in this process or a new one; it was loaded after binding a shielded account in an earlier process; or its last restoration failed, or waits for a busy store. In every build: the identity was loaded with its private scan state marking a private payment that may be unresolved, or was added to a wallet whose backend kept its record marked after an earlier wallet under the id was removed (`ClientStartState::retained_private_contact_scan_states`). Contact requests and acceptances, public and private, are refused with nothing read, signed or broadcast until they are restored: configuring the shielded store restores them with no keys, as binding the wallet's shielded account does, and a contact request first retries a restoration once without waiting on the store; a private sync does not, and a build without the `shielded` feature never does. A host whose persister declares the private scan state configures the shielded store when it activates the manager, before loading or creating wallets: until it does, and while a failed configure is not retried, 72 holds. `platform_wallet_get_private_contact_readiness` reads whether the payments are restored and whether the last restoration failed (`private_payments_restore_failed`; the wallet's `PrivateContactReadiness::private_payments_restore_failure` holds the reason). `platform_wallet_get_private_payments_restore_failure` gives that reason. Swift types it (`PlatformWalletError.privateContactPaymentsNotRestored`, `ErrorHandlingTests` pin) and binds both reads (`ManagedPlatformWallet.privateContactReadiness`, `privatePaymentsRestoreFailure`); SwiftExampleApp configures the shielded store when it activates a manager, before loading wallets, retries a failed configure, and shows "setting up" or the failure's reason until the readiness reads restored. Kotlin typing is added by the Kotlin PR (#5813) | +| 72 | `ErrorPrivateContactPaymentsNotRestored` | DashPay private contact requests | Proposed. Maps `PlatformWalletError::PrivateContactPaymentsNotRestored`: wallet readiness, not one contact's pending request (71). The wallet's unconfirmed private payments may hold pairs no reservation shows yet, which only a restoration from the shielded store's journal shows. In a build with the `shielded` feature: the wallet was published (created or loaded) while no shielded store was configured, its persister declaring the private scan state (bit 15), which holds a wallet removed and registered again from the same seed, in this process or a new one; it was loaded after binding a shielded account in an earlier process; or its last restoration failed, or waits for a busy store. In every build: the identity was loaded with its private scan state marking a private payment that may be unresolved, or was added to a wallet whose backend kept its record marked after an earlier wallet under the id was removed (`ClientStartState::retained_private_contact_scan_states`). Contact requests and acceptances, public and private, are refused with nothing read, signed or broadcast until they are restored: configuring the shielded store restores them with no keys, as binding the wallet's shielded account does, and a contact request first retries a restoration once without waiting on the store; a private sync does not, and a build without the `shielded` feature never does. A host whose persister declares the private scan state configures the shielded store when it activates the manager, before loading or creating wallets: until it does, and while a failed configure is not retried, 72 holds. `platform_wallet_get_private_contact_readiness` reads whether the payments are restored and whether the last restoration failed (`private_payments_restore_failed`; the wallet's `PrivateContactReadiness::private_payments_restore_failure` holds the reason). `platform_wallet_get_private_payments_restore_failure` gives that reason. Swift types it (`PlatformWalletError.privateContactPaymentsNotRestored`, `ErrorHandlingTests` pin) and binds both reads (`ManagedPlatformWallet.privateContactReadiness`, `privatePaymentsRestoreFailure`); SwiftExampleApp configures the shielded store when it activates a manager, before loading wallets; on each DashPay refresh while the store did not open or the readiness reads not restored, it configures the store again (on a store already open, the wallet's own retry of every wallet still held, a restoration that failed after the open included) and reads the readiness after that, refreshing again on a bounded timer while it stays unrestored; it shows "setting up" or the failure's reason until the readiness reads restored. Kotlin typing is added by the Kotlin PR (#5813) | | 73 | `ErrorPrivateContactRecoveryUnsupported` | DashPay private contact requests | Proposed. Maps `PlatformWalletError::PrivateContactRecoveryUnsupported`: the persister does not declare `PRIVATE_CONTACT_REQUEST_SCAN_STATE` (bit 15) 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. The FFI constant is `PLATFORM_WALLET_PERSISTENCE_CAPABILITY_PRIVATE_CONTACT_REQUEST_SCAN_STATE` (`1 << 15`). Swift types it (`PlatformWalletError.privateContactRecoveryUnsupported`, `ErrorHandlingTests` pin), SwiftExampleApp shows it as a storage configuration error, and the SwiftData handler declares bit 0 and bit 15 with the begin and end callbacks wired; Kotlin typing is added by the Kotlin PR (#5813) | | 74 | `ErrorPrivateContactStoreNotRead` | DashPay private contact requests | Proposed. Maps `PlatformWalletError::PrivateContactStoreNotRead`: DashPay's private contact request store was not read to its end for the identity, with the keys Platform proves it holds, before a contact request or acceptance, public or private, was decided (every one reads it from the identity's cursor first). Nothing was reserved, sealed, paid or broadcast; calling again continues the read from where it stopped. A node behind the store already seen, or keys that kept changing, stop it. It was reported as 71 before, which says not to submit again. Mapped by the blanket conversion and kept by the shielded spend contract (`map_spend_result`). Swift types it (`PlatformWalletError.privateContactStoreNotRead`, `ErrorHandlingTests` pin) and SwiftExampleApp offers a retry with no pending row; Kotlin typing is added by the Kotlin PR (#5813) | | 75 | `ErrorSdkPinnedBelowProtocolVersion` | DashPay private contact requests | Proposed. The SDK refused a request without sending it because the SDK itself is pinned below the protocol version the request needs (`dash_sdk::Error::ProtocolVersionNotActive` with `pinned`), whatever the network runs: for example every contact request's private store read with the SDK pinned below protocol version 15 on a network at 15. A network upgrade does not lift it; only raising or removing the pin does. Before, it shared 70 with a network proven below the version, which lifts by itself. Mapped by the blanket conversion. Swift types it (`PlatformWalletError.sdkPinnedBelowProtocolVersion`, `ErrorHandlingTests` pin) and SwiftExampleApp shows a developer-facing message; Kotlin typing is added by the Kotlin PR (#5813) | diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/AddContactView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/AddContactView.swift index 8b49f9246a2..95d752558d7 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/AddContactView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/AddContactView.swift @@ -81,6 +81,10 @@ struct AddContactView: View { /// pending state; nil when it is open. @State private var shieldedStoreFailure: String? + /// The automatic refreshes made since the wallet's private payments were + /// last read restored (`PrivatePaymentsRecovery.rechecks`). + @State private var readinessRechecks = 0 + /// A private sync the footer started is running. @State private var isSyncingPrivate = false @@ -171,6 +175,16 @@ struct AddContactView: View { } .navigationTitle("Add Contact") .task(id: resolvedRecipient) { reloadRecipientPending() } + // While the wallet's private payments stay unrestored, refresh + // again after a pause: a restoration the wallet deferred behind a + // busy store completes later and changes nothing this view + // observes. + .task(id: readinessRechecks) { + guard readinessRechecks > 0 else { return } + try? await Task.sleep(for: PrivatePaymentsRecovery.recheckInterval) + guard !Task.isCancelled else { return } + reloadRecipientPending() + } .onChange(of: shieldedService.isBound) { _, _ in reloadRecipientPending() } .navigationBarTitleDisplayMode(.inline) .toolbar { @@ -470,23 +484,33 @@ struct AddContactView: View { } } - /// Re-read from the wallet whether it holds the pair with the resolved - /// recipient for a private request, and its readiness, and try a - /// shielded store that did not open again. + /// Re-read from the wallet its readiness (`PrivatePaymentsRecovery.refresh`: + /// the wallet is asked to retry a restoration first when its payments are + /// not restored, and the readiness shown is read after that), then + /// whether it holds the pair with the resolved recipient for a private + /// request, and refresh again on a bounded timer while the payments stay + /// unrestored. private func reloadRecipientPending() { let contactIds = resolvedRecipient.map { [$0] } ?? [] let wallet = try? requireWallet() + let identityId = identity.identityId + let refreshed = PrivatePaymentsRecovery.refresh( + lastReadiness: recipientPending.readiness, + storeFailure: walletManagerStore.shieldedStoreFailure(of: walletManager), + readReadiness: { privateContactReadiness(wallet: wallet, identityId: identityId) }, + configureAgain: { walletManagerStore.configureShieldedAgain(walletManager) } + ) + recipientPending.recordReadiness(refreshed.readiness, restoreFailure: refreshed.restoreFailure) + shieldedStoreFailure = refreshed.storeFailure recipientPending.record( - privateRepliesPending( - wallet: wallet, - identityId: identity.identityId, - contactIds: contactIds - ), + privateRepliesPending(wallet: wallet, identityId: identityId, contactIds: contactIds), for: contactIds ) - let read = privateContactReadiness(wallet: wallet, identityId: identity.identityId) - recipientPending.recordReadiness(read.readiness, restoreFailure: read.restoreFailure) - shieldedStoreFailure = walletManagerStore.shieldedStoreFailure(retrying: walletManager) + if PrivatePaymentsRecovery.rechecks(after: recipientPending.readiness, attempt: readinessRechecks) { + readinessRechecks += 1 + } else if recipientPending.readiness?.privatePaymentsRestored == true { + readinessRechecks = 0 + } } /// Read the private store, which settles a pending private request, then diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ContactRequestOutcomes.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ContactRequestOutcomes.swift index 81ab26f58af..284f4f5766a 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ContactRequestOutcomes.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ContactRequestOutcomes.swift @@ -229,6 +229,68 @@ struct PrivateRepliesPendingSnapshot: Equatable { } } +/// The DashPay views' readiness refresh, one flow for the request list and +/// Add Contact. The wallet owns the recovery: on a store already open, a +/// repeat `configureShielded` retries restoring every wallet whose private +/// payments are still held, a restoration that failed after the store +/// opened included, and defers it to a task while a scan holds the store. +/// So a refresh asks for it whenever the payments are not restored, or the +/// store did not open, and only then reads the readiness it shows: the +/// readiness is read after the attempt, never cached from before it. While +/// the payments stay unrestored (a deferred restoration completes later, +/// changing nothing a view observes), the view refreshes again on a bounded +/// timer, so the gate lifts without a bind, a restart or a submitted +/// request. The views change no hold themselves. +enum PrivatePaymentsRecovery { + /// How many times a view refreshes again on its own while the payments + /// stay unrestored. + static let recheckLimit = 20 + /// How long a view waits before it refreshes again. + static let recheckInterval: Duration = .seconds(3) + + /// Whether a refresh asks the wallet to retry before it reads the + /// readiness it shows: the store did not open last time, or the last + /// readiness read says the payments are not restored. + static func retries(readiness: PrivateContactReadiness?, storeFailure: String?) -> Bool { + storeFailure != nil || readiness?.privatePaymentsRestored == false + } + + /// Whether a view refreshes again on its own after `attempt` automatic + /// refreshes: the payments are still not restored and the limit is not + /// reached. A failed read (nil) is left to the next refresh the view + /// makes anyway. + static func rechecks(after readiness: PrivateContactReadiness?, attempt: Int) -> Bool { + readiness?.privatePaymentsRestored == false && attempt < recheckLimit + } + + /// One refresh. `lastReadiness` is the readiness the view shows now; + /// `readReadiness` reads the wallet's (with the reason a restoration + /// failed); `configureAgain` asks the wallet to retry and returns why the + /// store did not open, or nil. When `retries` says so, the wallet is asked + /// first and the readiness is read after it; otherwise it is read once. + @MainActor + static func refresh( + lastReadiness: PrivateContactReadiness?, + storeFailure: String?, + readReadiness: () -> (readiness: PrivateContactReadiness?, restoreFailure: String?), + configureAgain: () -> String? + ) -> (readiness: PrivateContactReadiness?, restoreFailure: String?, storeFailure: String?) { + var storeFailure = storeFailure + if retries(readiness: lastReadiness, storeFailure: storeFailure) { + storeFailure = configureAgain() + let read = readReadiness() + return (read.readiness, read.restoreFailure, storeFailure) + } + let read = readReadiness() + if retries(readiness: read.readiness, storeFailure: storeFailure) { + storeFailure = configureAgain() + let again = readReadiness() + return (again.readiness, again.restoreFailure, storeFailure) + } + return (read.readiness, read.restoreFailure, storeFailure) + } +} + /// How Add Contact's collision alert accepts the request the recipient /// already sent us. With "Send privately" on, the user chose a private /// request, so the accept replies privately diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ContactRequestsView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ContactRequestsView.swift index 7656d7de241..f9ea1ad2d6b 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ContactRequestsView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ContactRequestsView.swift @@ -60,6 +60,10 @@ struct ContactRequestsView: View { /// pending state; nil when it is open. @State private var shieldedStoreFailure: String? + /// The automatic refreshes made since the wallet's private payments were + /// last read restored (`PrivatePaymentsRecovery.rechecks`). + @State private var readinessRechecks = 0 + /// What the last private-store refresh has to tell the user, if anything. @State private var privateRefreshNotice: String? @@ -203,6 +207,15 @@ struct ContactRequestsView: View { reloadPendingReplies() } .task { reloadPendingReplies() } + // While the wallet's private payments stay unrestored, refresh again + // after a pause: a restoration the wallet deferred behind a busy + // store completes later and changes nothing this view observes. + .task(id: readinessRechecks) { + guard readinessRechecks > 0 else { return } + try? await Task.sleep(for: PrivatePaymentsRecovery.recheckInterval) + guard !Task.isCancelled else { return } + reloadPendingReplies() + } .onChange(of: shieldedService.isBound) { _, _ in reloadPendingReplies() } .onChange(of: rowSignature) { _, _ in pruneOverlays() @@ -234,23 +247,32 @@ struct ContactRequestsView: View { } } - /// Re-read from the wallet which incoming rows' pairs it holds for a - /// private request and its readiness, and try a shielded store that did - /// not open again. + /// Re-read from the wallet its readiness (`PrivatePaymentsRecovery.refresh`: + /// the wallet is asked to retry a restoration first when its payments are + /// not restored, and the readiness shown is read after that), then which + /// incoming rows' pairs it holds for a private request, and refresh again + /// on a bounded timer while the payments stay unrestored. private func reloadPendingReplies() { let contactIds = incomingPending.map(\.contactIdentityId) let wallet = try? requireWallet() + let identityId = identity.identityId + let refreshed = PrivatePaymentsRecovery.refresh( + lastReadiness: walletPending.readiness, + storeFailure: walletManagerStore.shieldedStoreFailure(of: walletManager), + readReadiness: { privateContactReadiness(wallet: wallet, identityId: identityId) }, + configureAgain: { walletManagerStore.configureShieldedAgain(walletManager) } + ) + walletPending.recordReadiness(refreshed.readiness, restoreFailure: refreshed.restoreFailure) + shieldedStoreFailure = refreshed.storeFailure walletPending.record( - privateRepliesPending( - wallet: wallet, - identityId: identity.identityId, - contactIds: contactIds - ), + privateRepliesPending(wallet: wallet, identityId: identityId, contactIds: contactIds), for: contactIds ) - let read = privateContactReadiness(wallet: wallet, identityId: identity.identityId) - walletPending.recordReadiness(read.readiness, restoreFailure: read.restoreFailure) - shieldedStoreFailure = walletManagerStore.shieldedStoreFailure(retrying: walletManager) + if PrivatePaymentsRecovery.rechecks(after: walletPending.readiness, attempt: readinessRechecks) { + readinessRechecks += 1 + } else if walletPending.readiness?.privatePaymentsRestored == true { + readinessRechecks = 0 + } } // MARK: - Overlay maintenance diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ContactsView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ContactsView.swift index f35dcb9b7b3..656e5f63063 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ContactsView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ContactsView.swift @@ -245,11 +245,6 @@ func privateRepliesPending( guard !contactIds.isEmpty else { return [] } guard let wallet else { return nil } do { - do { - _ = try wallet.managedIdentity(identityId: identityId) - } catch PlatformWalletError.notFound(_) { - return [] - } var pending = Set() for contactId in contactIds { if try wallet.privatePairHeld(senderId: identityId, recipientId: contactId) { @@ -257,6 +252,10 @@ func privateRepliesPending( } } return pending + } catch PlatformWalletError.identityNotFound(_) { + // Code 7: the wallet does not hold the identity, so it holds nothing + // for it. + return [] } catch { return nil } diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageRecordDetailViews.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageRecordDetailViews.swift index 29431a3565a..4b2b4e0a471 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageRecordDetailViews.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/StorageRecordDetailViews.swift @@ -98,10 +98,19 @@ struct PrivateContactRequestsPositionStorageDetailView: View { FieldRow(label: "Wallet ID", value: hexString(record.walletId)) FieldRow(label: "Identity ID", value: hexString(record.identityId)) } - Section("Private Contact Request Store") { + Section { FieldRow(label: "Read Position", value: String(UInt64(bitPattern: record.position))) FieldRow(label: "Key Fingerprint", value: hexString(record.keyFingerprint)) FieldRow(label: "Private Payments Unresolved", value: record.privatePaymentsUnresolved ? "Yes" : "No") + } header: { + Text("Private Contact Request Store") + } footer: { + // The purge rule: a marked record outlives its wallet. + if record.privatePaymentsUnresolved { + Text("Kept when its wallet is removed, until a record stored cleared for the same " + + "wallet and identity replaces it: the private payment it stands for may still " + + "execute.") + } } } .navigationTitle("Private Position") diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/WalletManagerStore.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/WalletManagerStore.swift index fb84475b44f..99c54e89ee1 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/WalletManagerStore.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/WalletManagerStore.swift @@ -171,7 +171,7 @@ final class WalletManagerStore: ObservableObject { /// Why opening each network's shielded store failed, until a later /// attempt succeeds. The DashPay views show it while the wallet's /// private payments wait for that store, and retry it - /// (`shieldedStoreFailure(retrying:)`). + /// (`configureShieldedAgain(_:)`). @Published private(set) var shieldedStoreFailures: [Network: String] = [:] init(modelContainer: ModelContainer) { @@ -310,14 +310,21 @@ final class WalletManagerStore: ObservableObject { } } - /// Why the shielded store of `manager`'s network could not be opened, - /// after trying it again, or nil when it is open (or `manager` is not - /// one of this store's). - func shieldedStoreFailure(retrying manager: PlatformWalletManager) -> String? { - guard let network = managers.first(where: { $0.value === manager })?.key, - shieldedStoreFailures[network] != nil else { - return nil - } + /// Why opening the shielded store of `manager`'s network failed last, or + /// nil when it opened (or `manager` is not one of this store's). + func shieldedStoreFailure(of manager: PlatformWalletManager) -> String? { + guard let network = managers.first(where: { $0.value === manager })?.key else { return nil } + return shieldedStoreFailures[network] + } + + /// Open `manager`'s shielded store again and return why it failed, or + /// nil once it is open. On a store already open this asks the wallet to + /// retry restoring every wallet whose private payments are still held, + /// a restoration that failed after the store opened included; it never + /// waits on a busy store (a busy store defers the restoration to a + /// task), so it is safe on the main thread. + func configureShieldedAgain(_ manager: PlatformWalletManager) -> String? { + guard let network = managers.first(where: { $0.value === manager })?.key else { return nil } configureShieldedStore(manager, network: network) return shieldedStoreFailures[network] } diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/ContactRequestOutcomesTests.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/ContactRequestOutcomesTests.swift index 0612f3adbc4..1aaafa3733b 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/ContactRequestOutcomesTests.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/ContactRequestOutcomesTests.swift @@ -263,6 +263,138 @@ final class ContactRequestOutcomesTests: XCTestCase { "a contact the read did not cover is not offered yet") } + private func readiness(restored: Bool, failed: Bool = false) -> PrivateContactReadiness { + PrivateContactReadiness( + privatePaymentsRestored: restored, privateStoreRead: true, + unresolvedPrivatePayment: false, privatePaymentsRestoreFailed: failed) + } + + /// The store opened, but the wallet's journal restoration failed for a + /// transient reason. A DashPay refresh asks the wallet to retry even + /// though no store-open failure is recorded, reads the readiness after + /// the attempt, and once the retry restores the payments the gate lifts: + /// no bind, no restart and no submitted request. + @MainActor + func testShouldRetryARestorationThatFailedAfterTheStoreOpenedOnARefresh() { + let contact = Data(repeating: 1, count: 32) + var restorationFails = true + var configures = 0 + var snapshot = PrivateRepliesPendingSnapshot() + snapshot.record([], for: [contact]) + let read = { () -> (readiness: PrivateContactReadiness?, restoreFailure: String?) in + restorationFails + ? (self.readiness(restored: false, failed: true), "the journal could not be read") + : (self.readiness(restored: true), nil) + } + + // The first refresh: the store opened, the restoration failed, and + // the retry it asks for fails too. + var refreshed = PrivatePaymentsRecovery.refresh( + lastReadiness: snapshot.readiness, storeFailure: nil, readReadiness: read, + configureAgain: { configures += 1; return nil }) + snapshot.recordReadiness(refreshed.readiness, restoreFailure: refreshed.restoreFailure) + XCTAssertEqual(configures, 1, "not restored asks the wallet to retry with no store failure recorded") + XCTAssertNil(refreshed.storeFailure) + XCTAssertFalse(snapshot.allowsSubmission(to: contact)) + XCTAssertEqual(snapshot.restoreFailure, "the journal could not be read") + XCTAssertTrue(PrivatePaymentsRecovery.rechecks(after: snapshot.readiness, attempt: 0)) + + // The failure was transient: the next refresh asks first, the retry + // restores, and the readiness shown is the one read after it. + refreshed = PrivatePaymentsRecovery.refresh( + lastReadiness: snapshot.readiness, storeFailure: nil, readReadiness: read, + configureAgain: { configures += 1; restorationFails = false; return nil }) + snapshot.recordReadiness(refreshed.readiness, restoreFailure: refreshed.restoreFailure) + XCTAssertEqual(configures, 2) + XCTAssertFalse(snapshot.isRestoringPrivatePayments) + XCTAssertNil(snapshot.restoreFailure) + XCTAssertTrue(snapshot.allowsSubmission(to: contact), "the gate lifts on the refresh alone") + XCTAssertFalse(PrivatePaymentsRecovery.rechecks(after: snapshot.readiness, attempt: 1)) + } + + /// A refresh asks for the retry before it reads the readiness it shows: + /// a view that last showed "not restored" asks first and shows only the + /// read made after it. + @MainActor + func testShouldShowTheReadinessReadAfterTheRetry() { + var events: [String] = [] + var restored = false + let refreshed = PrivatePaymentsRecovery.refresh( + lastReadiness: readiness(restored: false), storeFailure: nil, + readReadiness: { + events.append("read") + return (self.readiness(restored: restored), nil) + }, + configureAgain: { + events.append("configure") + restored = true + return nil + }) + XCTAssertEqual(events, ["configure", "read"]) + XCTAssertEqual(refreshed.readiness?.privatePaymentsRestored, true) + } + + /// Restored payments and an open store need no retry: one read. + @MainActor + func testShouldNotAskTheWalletToRetryWhenThePaymentsAreRestored() { + var configures = 0 + var reads = 0 + let refreshed = PrivatePaymentsRecovery.refresh( + lastReadiness: readiness(restored: true), storeFailure: nil, + readReadiness: { reads += 1; return (self.readiness(restored: true), nil) }, + configureAgain: { configures += 1; return nil }) + XCTAssertEqual(configures, 0) + XCTAssertEqual(reads, 1) + XCTAssertEqual(refreshed.readiness?.privatePaymentsRestored, true) + } + + /// A store that did not open is opened again on a refresh, whatever the + /// readiness says, and its failure clears once it opens. + @MainActor + func testShouldOpenTheStoreAgainWhileAStoreFailureIsRecorded() { + var configures = 0 + let refreshed = PrivatePaymentsRecovery.refresh( + lastReadiness: nil, storeFailure: "disk full", + readReadiness: { (nil, nil) }, + configureAgain: { configures += 1; return nil }) + XCTAssertEqual(configures, 1) + XCTAssertNil(refreshed.storeFailure) + } + + /// A restoration the wallet deferred behind a busy store completes later, + /// changing nothing the view observes: the view refreshes again on its + /// own, bounded, while the payments stay unrestored, and the refresh after + /// the deferred restoration lifts the gate. + @MainActor + func testShouldRefreshAgainWhileARestorationIsDeferred() { + let contact = Data(repeating: 1, count: 32) + var deferredDone = false + var snapshot = PrivateRepliesPendingSnapshot() + snapshot.record([], for: [contact]) + let read = { () -> (readiness: PrivateContactReadiness?, restoreFailure: String?) in + (self.readiness(restored: deferredDone), nil) + } + var refreshed = PrivatePaymentsRecovery.refresh( + lastReadiness: snapshot.readiness, storeFailure: nil, readReadiness: read, + configureAgain: { nil }) + snapshot.recordReadiness(refreshed.readiness, restoreFailure: refreshed.restoreFailure) + XCTAssertFalse(snapshot.allowsSubmission(to: contact)) + XCTAssertTrue(PrivatePaymentsRecovery.rechecks(after: snapshot.readiness, attempt: 0)) + XCTAssertFalse( + PrivatePaymentsRecovery.rechecks( + after: snapshot.readiness, attempt: PrivatePaymentsRecovery.recheckLimit), + "bounded") + XCTAssertFalse(PrivatePaymentsRecovery.rechecks(after: nil, attempt: 0)) + + deferredDone = true + refreshed = PrivatePaymentsRecovery.refresh( + lastReadiness: snapshot.readiness, storeFailure: nil, readReadiness: read, + configureAgain: { nil }) + snapshot.recordReadiness(refreshed.readiness, restoreFailure: refreshed.restoreFailure) + XCTAssertTrue(snapshot.allowsSubmission(to: contact)) + XCTAssertFalse(PrivatePaymentsRecovery.rechecks(after: snapshot.readiness, attempt: 1)) + } + func testShouldAcceptACollisionPrivatelyWhenSendPrivatelyIsOn() { let accept = CollisionAccept.of(sendPrivately: true) XCTAssertEqual(accept, .privateReply) From 0a2d984acafb0123178c1d2f493c7398e8ca2c2b Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Tue, 10 Nov 2026 19:44:10 +0700 Subject: [PATCH 246/269] fix(platform-wallet): read the SDK's unconfirmed broadcast outcome in the single-dispatch broadcast and the spend contract Since #5556 the SDK puts a broadcast that may have been delivered under Error::StateTransitionResultUnconfirmed, a duplicate answer included (a gRPC AlreadyExists is classified as possibly accepted). The wallet's single-dispatch broadcast read only a bare AlreadyExists as admitted, so a node already holding the transition now read as maybe delivered and the same bytes went on to the rest of the budget. A node holding the transition, bare or under the unconfirmed or retry envelope, is admitted again. The envelope is maybe delivered whatever it wraps, never a refusal before anything was sent, and the identity shield never reads it as a refusal. The FFI's shielded spend contract (map_spend_result) flattened an unclassified SDK unconfirmed outcome to ErrorWalletOperation (6), which reads as a failure; it now keeps the blanket conversion's ErrorTransactionBroadcastUnconfirmed (20). Co-Authored-By: Claude Opus 5.5 --- packages/rs-platform-wallet-ffi/src/error.rs | 21 ++++++ .../src/shielded_send.rs | 12 ++++ .../src/broadcast_outcome.rs | 64 ++++++++++++++++++- 3 files changed, 95 insertions(+), 2 deletions(-) diff --git a/packages/rs-platform-wallet-ffi/src/error.rs b/packages/rs-platform-wallet-ffi/src/error.rs index f03a9bc4f30..60e7c2fb21c 100644 --- a/packages/rs-platform-wallet-ffi/src/error.rs +++ b/packages/rs-platform-wallet-ffi/src/error.rs @@ -3196,6 +3196,27 @@ mod private_contact_request_error_tests { } } + /// The SDK's unconfirmed outcome keeps its unconfirmed code (20) through + /// the shielded spend contract, as through the blanket conversion: it may + /// have executed, so it never reads as a failure a host may retry. + #[cfg(feature = "shielded")] + #[test] + fn should_keep_an_unconfirmed_sdk_outcome_unconfirmed_through_the_spend_contract() { + let unconfirmed = || { + PlatformWalletError::Sdk(dash_sdk::Error::StateTransitionResultUnconfirmed(Box::new( + dash_sdk::Error::Generic("the acknowledgement was lost".into()), + ))) + }; + assert_eq!( + map_spend_result(Err(unconfirmed()), "shielded transfer").code, + PlatformWalletFFIResultCode::ErrorTransactionBroadcastUnconfirmed + ); + assert_eq!( + PlatformWalletFFIResult::from(unconfirmed()).code, + PlatformWalletFFIResultCode::ErrorTransactionBroadcastUnconfirmed + ); + } + /// An identity the wallet does not hold reaches the host by its own code, /// not the catch-all. #[test] diff --git a/packages/rs-platform-wallet-ffi/src/shielded_send.rs b/packages/rs-platform-wallet-ffi/src/shielded_send.rs index 98aa8046d22..77109f7da91 100644 --- a/packages/rs-platform-wallet-ffi/src/shielded_send.rs +++ b/packages/rs-platform-wallet-ffi/src/shielded_send.rs @@ -688,6 +688,18 @@ pub(crate) fn map_spend_result( // here as the blanket `From` conversion does, so hosts route to key // repair instead of reading a generic wallet failure. Structural // position-0 check only, never a substring sniff of the rendering. + // The SDK's unconfirmed outcome (`StateTransitionResultUnconfirmed`) + // that reached here unclassified: the transition may have executed, + // so it keeps the blanket conversion's unconfirmed code rather than + // reading as a failure a host may retry. + Err(e) + if matches!( + &e, + PlatformWalletError::Sdk(source) if source.is_state_transition_result_unconfirmed() + ) => + { + e.into() + } Err(e) if is_preserved_signer_key_unavailable(&e) => PlatformWalletFFIResult::err( PlatformWalletFFIResultCode::ErrorSigningKeyUnavailable, format!("{operation} failed: {e}"), diff --git a/packages/rs-platform-wallet/src/broadcast_outcome.rs b/packages/rs-platform-wallet/src/broadcast_outcome.rs index 4747c1995cc..96b840e8742 100644 --- a/packages/rs-platform-wallet/src/broadcast_outcome.rs +++ b/packages/rs-platform-wallet/src/broadcast_outcome.rs @@ -28,7 +28,10 @@ /// transition Platform executed and rejected on its merits. /// /// Recurses through a `NoAvailableAddressesToRetry` envelope, mirroring -/// [`crate::error::as_address_invalid_nonce`]. +/// [`crate::error::as_address_invalid_nonce`], and not through the SDK's +/// unconfirmed-outcome envelope (`StateTransitionResultUnconfirmed`): a +/// verdict under it follows an attempt that may have been delivered, so it +/// shows no refusal. /// /// Only these prove the transition was evaluated and REJECTED. Everything /// else — transport errors, timeouts, `AlreadyExists` (which proves the @@ -212,7 +215,8 @@ where let mut maybe_delivered: Option = None; for _ in 0..dispatches.max(1) { let error = match dispatch().await { - Ok(()) | Err(dash_sdk::Error::AlreadyExists(_)) => return BroadcastDelivery::Admitted, + Ok(()) => return BroadcastDelivery::Admitted, + Err(error) if node_holds_transition(&error) => return BroadcastDelivery::Admitted, Err(error) => error, }; // Refused before anything was sent: no address, or the SDK's own @@ -253,6 +257,19 @@ where } } +/// Whether `error` says a node already holds the transition +/// (`AlreadyExists`): bare, or under the SDK's unconfirmed-outcome envelope +/// (`StateTransitionResultUnconfirmed`), which the SDK puts a duplicate +/// answer under as it may follow a delivery, or under its retry envelope. +fn node_holds_transition(error: &dash_sdk::Error) -> bool { + match error { + dash_sdk::Error::AlreadyExists(_) => true, + dash_sdk::Error::StateTransitionResultUnconfirmed(inner) + | dash_sdk::Error::NoAvailableAddressesToRetry(inner) => node_holds_transition(inner), + _ => false, + } +} + /// Whether `error` is a node refusing `transition` because one of its input /// addresses' nonces is ahead of what that node has: the answer of a node /// that is behind the one the nonce was read from, which another node may @@ -733,6 +750,49 @@ mod tests { assert_eq!(dispatched, 1); } + /// The SDK puts a dispatch that may have been delivered under its + /// unconfirmed-outcome envelope (`StateTransitionResultUnconfirmed`): it + /// is maybe delivered whatever it wraps, never a refusal before anything + /// was sent, and does not end the dispatches as a verdict would; a node + /// that holds the transition under it is admitted. + #[tokio::test] + async fn should_read_the_sdk_s_unconfirmed_outcome_as_maybe_delivered() { + let unconfirmed = |inner: dash_sdk::Error| { + dash_sdk::Error::StateTransitionResultUnconfirmed(Box::new(inner)) + }; + for inner in [ + grpc(Code::Unavailable), + dash_sdk::Error::DapiClientError(DapiClientError::NoAvailableAddresses), + rejection(), + ] { + let inner = &inner; + let (delivery, dispatched) = tracked(|_| Err(unconfirmed(copy(inner)))).await; + assert!( + matches!(delivery, BroadcastDelivery::MaybeDelivered(_)), + "{inner:?}: {delivery:?}" + ); + assert_eq!(dispatched, BUDGET, "{inner:?}"); + } + let (delivery, dispatched) = tracked(|_| { + Err(unconfirmed(dash_sdk::Error::AlreadyExists( + "state transition already in mempool".to_string(), + ))) + }) + .await; + assert!( + matches!(delivery, BroadcastDelivery::Admitted), + "{delivery:?}" + ); + assert_eq!(dispatched, 1); + #[cfg(feature = "shielded")] + { + assert!(!broadcast_was_refused(&unconfirmed(rejection()))); + assert!(!broadcast_was_refused(&unconfirmed(grpc( + Code::InvalidArgument + )))); + } + } + /// A dispatch a node admitted, or already holds, is admitted, after any /// number of failed ones. #[tokio::test] From 98b50da9b7365afc202c86da9f503c89337f11d7 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Tue, 10 Nov 2026 19:44:23 +0700 Subject: [PATCH 247/269] feat(sdk): keep marked scan state records through a wallet's purge in Room and list them over JNI - deleteWalletDataLocked keeps a private_contact_requests_positions row whose privatePaymentsUnresolved is set (deleteUnmarkedPositionsByWallet). Every other position and every address still goes with the wallet. A cleared record stored later for the same wallet and identity replaces a kept one. - NativePersistenceBridge.onLoadPrivateContactScanStateWallets (`()[B`) packs the ids of every wallet with stored positions, a removed wallet's kept ones included. The Room handler implements it with walletIdsWithPositions. - JNI wires the new on_load_private_contact_scan_state_wallets_fn and its free slot, with the other three scan state slots, only for a bridge that also overrides the wallet list. - No Room schema or version change: two new queries only. - Tests: - shouldKeepAMarkedRecordThroughItsWalletsPurgeUntilAClearedRecordReplacesIt - shouldListEveryWalletWithStoredScanStateOnce - the JNI wiring and packing assertions in the_scan_state_slots_are_wired_only_for_a_bridge_that_stores_and_loads_it Co-Authored-By: Claude Opus 5.5 --- .../dashsdk/ffi/NativePersistenceBridge.kt | 19 ++- .../PlatformWalletPersistenceHandler.kt | 14 ++- .../dao/PrivateContactScanStateDao.kt | 17 ++- .../PrivateContactScanStateEntities.kt | 6 +- .../PrivateContactScanStatePersistenceTest.kt | 49 ++++++++ .../rs-unified-sdk-jni/src/persistence.rs | 108 +++++++++++++++++- 6 files changed, 204 insertions(+), 9 deletions(-) diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/NativePersistenceBridge.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/NativePersistenceBridge.kt index 4d822a9f960..ae4d7946395 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/NativePersistenceBridge.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/NativePersistenceBridge.kt @@ -104,8 +104,12 @@ abstract class NativePersistenceBridge { * `PersistenceCapabilities::PRIVATE_CONTACT_REQUEST_SCAN_STATE` * (bit 15, `0x8000`; bit 14 is `ASSET_LOCK_SHIELDED_RECIPIENTS`): the * private contact request sync's scan state is stored on its round - * and restored at load. The one Kotlin home of this bit, aliased by - * the public diagnostic mirror. + * and restored at load. A store declaring it keeps a position record + * marked unresolved through its wallet's removal, until a record + * stored cleared for the same wallet and identity replaces it, and + * lists that wallet ([onLoadPrivateContactScanStateWallets]). The + * one Kotlin home of this bit, aliased by the public diagnostic + * mirror. */ const val CAPABILITY_PRIVATE_CONTACT_REQUEST_SCAN_STATE: Long = 1L shl 15 @@ -964,6 +968,17 @@ abstract class NativePersistenceBridge { open fun onLoadPrivateContactScanState(walletId: ByteArray): Array = arrayOf(ByteArray(0), ByteArray(0)) + /** + * `on_load_private_contact_scan_state_wallets_fn`: the ids of every + * wallet the store keeps scan state for, 32 bytes each, packed back to + * back, a removed wallet whose position records were kept marked + * included. Rust reads each one its wallet-list load does not return + * through [onLoadPrivateContactScanState] and hands its marked records to + * the wallet as retained. Descriptor `()[B`. Throw on a store that cannot + * be read. + */ + open fun onLoadPrivateContactScanStateWallets(): ByteArray = ByteArray(0) + /** * `on_get_core_tx_record_fn`. Returns the record for `txid` or `null` * if no row exists. Descriptor diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandler.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandler.kt index b8c895071d6..b33f99a467d 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandler.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandler.kt @@ -859,6 +859,14 @@ class PlatformWalletPersistenceHandler( } } + override fun onLoadPrivateContactScanStateWallets(): ByteArray = + loadOrThrow { + runBlockingResult { + database.privateContactScanStateDao().walletIdsWithPositions() + .fold(ByteArray(0)) { packed, id -> packed + id } + } + } + override fun onWalletChangesetChainLockHeight(walletId: ByteArray, height: Int): Int = guarded { val round = openRound(walletId) round?.finalityAdvanced = true @@ -3709,7 +3717,11 @@ class PlatformWalletPersistenceHandler( database.shieldedDao().deleteActivityByWallet(walletId) database.shieldedDao().deleteSyncStatesByWallet(walletId) database.shieldedDao().deleteViewingKeysByWallet(walletId) - database.privateContactScanStateDao().deletePositionsByWallet(walletId) + // A position record marked unresolved outlives the wallet: its + // private payment may still execute, and the next load hands it + // back as retained, so a wallet registered again holds the + // identity it names. + database.privateContactScanStateDao().deleteUnmarkedPositionsByWallet(walletId) database.privateContactScanStateDao().deleteAddressesByWallet(walletId) // The wallet row itself — cascades accounts → core / platform diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/dao/PrivateContactScanStateDao.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/dao/PrivateContactScanStateDao.kt index 3e452f5f3c5..b3ad12bed63 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/dao/PrivateContactScanStateDao.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/dao/PrivateContactScanStateDao.kt @@ -36,8 +36,21 @@ interface PrivateContactScanStateDao { @Query("SELECT * FROM private_shielded_addresses WHERE walletId = :walletId") suspend fun addressesByWallet(walletId: ByteArray): List - @Query("DELETE FROM private_contact_requests_positions WHERE walletId = :walletId") - suspend fun deletePositionsByWallet(walletId: ByteArray) + /** + * The wallet's position records, except those marked unresolved: a + * marked record stands for a private payment that may still execute, so + * it outlives its wallet until a record stored cleared for the same + * wallet and identity replaces it. + */ + @Query( + "DELETE FROM private_contact_requests_positions " + + "WHERE walletId = :walletId AND privatePaymentsUnresolved = 0", + ) + suspend fun deleteUnmarkedPositionsByWallet(walletId: ByteArray) + + /** The ids of every wallet with a stored position record, a removed wallet's kept ones included. */ + @Query("SELECT DISTINCT walletId FROM private_contact_requests_positions") + suspend fun walletIdsWithPositions(): List @Query("DELETE FROM private_shielded_addresses WHERE walletId = :walletId") suspend fun deleteAddressesByWallet(walletId: ByteArray) diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/entities/PrivateContactScanStateEntities.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/entities/PrivateContactScanStateEntities.kt index 807559c0f0a..214a9055e65 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/entities/PrivateContactScanStateEntities.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/entities/PrivateContactScanStateEntities.kt @@ -11,8 +11,10 @@ import androidx.room.Entity * the contact requests read before it. * * Keyed by the network-scoped wallet id; no foreign key, so the wallet - * deletion purges it explicitly (`deleteWalletDataLocked`), and the round - * that removes the identity clears it (`clearedIdentities`). + * deletion purges it explicitly (`deleteWalletDataLocked`), except a record + * whose [privatePaymentsUnresolved] is set, which outlives its wallet until a + * record stored cleared for the same wallet and identity replaces it; and + * the round that removes the identity clears it (`clearedIdentities`). */ @Entity( tableName = "private_contact_requests_positions", diff --git a/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/persistence/PrivateContactScanStatePersistenceTest.kt b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/persistence/PrivateContactScanStatePersistenceTest.kt index 3d335775a28..d23ea1ca9f1 100644 --- a/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/persistence/PrivateContactScanStatePersistenceTest.kt +++ b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/persistence/PrivateContactScanStatePersistenceTest.kt @@ -146,6 +146,55 @@ class PrivateContactScanStatePersistenceTest { assertEquals(0, addresses.size) } + /** + * A position record marked unresolved survives its wallet's purge and is + * listed for the load to hand back as retained; an unmarked record and + * every address go with the wallet; a cleared record stored later for the + * same wallet and identity replaces it, so the next purge removes it. + */ + @Test + fun shouldKeepAMarkedRecordThroughItsWalletsPurgeUntilAClearedRecordReplacesIt() = runTest { + val unmarked = ByteArray(32) { 1 } + round( + positions = position(owner, 12, unresolved = true) + position(unmarked, 4), + addresses = address(owner, contact, 9), + ) + assertArrayEquals(walletId, handler.onLoadPrivateContactScanStateWallets()) + + handler.deleteWalletData(walletId) + val (positions, addresses) = handler.onLoadPrivateContactScanState(walletId) + assertArrayEquals( + "only the marked record outlives the wallet", + position(owner, 12, unresolved = true), + positions, + ) + assertEquals(0, addresses.size) + assertArrayEquals( + "the removed wallet is still listed for its kept record", + walletId, + handler.onLoadPrivateContactScanStateWallets(), + ) + + // The wallet registered again stores the record cleared. + assertEquals(0, handler.onPersistWalletMetadata(walletId, 1, ByteArray(32) { 2 }, 0)) + round(positions = position(owner, 0)) + assertFalse( + unpackPrivatePositions(walletId, handler.onLoadPrivateContactScanState(walletId)[0]) + .single().privatePaymentsUnresolved, + ) + handler.deleteWalletData(walletId) + assertEquals(0, handler.onLoadPrivateContactScanState(walletId)[0].size) + assertEquals(0, handler.onLoadPrivateContactScanStateWallets().size) + } + + /** The wallet list packs one 32-byte id per wallet with stored records. */ + @Test + fun shouldListEveryWalletWithStoredScanStateOnce() { + assertEquals(0, handler.onLoadPrivateContactScanStateWallets().size) + round(positions = position(owner, 12) + position(ByteArray(32) { 1 }, 4)) + assertArrayEquals(walletId, handler.onLoadPrivateContactScanStateWallets()) + } + /** * A stored row that cannot cross the ABI fails the load instead of * handing back the rest without it: a valid position beside a 42-byte diff --git a/packages/rs-unified-sdk-jni/src/persistence.rs b/packages/rs-unified-sdk-jni/src/persistence.rs index 51aad244695..86a38c3329e 100644 --- a/packages/rs-unified-sdk-jni/src/persistence.rs +++ b/packages/rs-unified-sdk-jni/src/persistence.rs @@ -254,6 +254,11 @@ pub(crate) fn build_extension(env: &mut JNIEnv, bridge: &JObject) -> Persistence bridge, "onLoadPrivateContactScanState", LOAD_PRIVATE_CONTACT_SCAN_STATE_DESCRIPTOR, + ) && bridge_overrides( + env, + bridge, + "onLoadPrivateContactScanStateWallets", + LOAD_PRIVATE_CONTACT_SCAN_STATE_WALLETS_DESCRIPTOR, ) && bridge_overrides( env, bridge, @@ -316,8 +321,9 @@ struct BridgeOverrides { header: bool, /// `onWalletChangesetDashPayBackfill`, which stores the record. backfill: bool, - /// `onPersistPrivateContactScanState`, `onLoadPrivateContactScanState` - /// and `onPersistContactUpsert`: an inherited no-op store would + /// `onPersistPrivateContactScanState`, `onLoadPrivateContactScanState`, + /// `onLoadPrivateContactScanStateWallets` and `onPersistContactUpsert`: + /// an inherited no-op store would /// acknowledge a read position it never kept, and a read position /// vouches for the contact requests read before it, which an inherited /// no-op contact upsert never keeps. @@ -351,6 +357,12 @@ fn extension_for(overrides: BridgeOverrides) -> PersistenceCallbacksExtension { on_load_private_contact_scan_state_free_fn: overrides .private_contact_scan_state .then_some(tramp_load_private_contact_scan_state_free), + on_load_private_contact_scan_state_wallets_fn: overrides + .private_contact_scan_state + .then_some(tramp_load_private_contact_scan_state_wallets), + on_load_private_contact_scan_state_wallets_free_fn: overrides + .private_contact_scan_state + .then_some(tramp_load_private_contact_scan_state_wallets_free), ..Default::default() } } @@ -1133,6 +1145,10 @@ const PERSIST_PRIVATE_CONTACT_SCAN_STATE_DESCRIPTOR: &str = "([B[B[B[B[B)I"; /// `(walletId) -> [positions, addresses]`. const LOAD_PRIVATE_CONTACT_SCAN_STATE_DESCRIPTOR: &str = "([B)[[B"; +/// Descriptor of `NativePersistenceBridge.onLoadPrivateContactScanStateWallets`: +/// `() -> walletIds`, the 32-byte ids packed back to back. +const LOAD_PRIVATE_CONTACT_SCAN_STATE_WALLETS_DESCRIPTOR: &str = "()[B"; + fn pack_private_positions(rows: &[PrivateContactRequestsPositionFFI]) -> Vec { let mut packed = Vec::with_capacity(rows.len() * PRIVATE_POSITION_ENTRY_LEN); for row in rows { @@ -1310,6 +1326,64 @@ unsafe extern "C" fn tramp_load_private_contact_scan_state( 0 } +/// Hand Rust the ids of every wallet the store keeps scan state for (see +/// `LoadPrivateContactScanStateWalletsFn`), a removed wallet whose marked +/// records outlived it included, from one packed array of 32-byte ids. A +/// failed read, an exception or an array that is not a whole number of ids +/// returns `ERR_JNI`, which fails the load. +unsafe extern "C" fn tramp_load_private_contact_scan_state_wallets( + context: *mut c_void, + out_wallet_ids: *mut *const [u8; 32], + out_wallet_ids_count: *mut usize, +) -> i32 { + *out_wallet_ids = ptr::null(); + *out_wallet_ids_count = 0; + let loaded = with_bridge_load(context, |env, bridge| { + let packed: JByteArray = env + .call_method( + bridge, + "onLoadPrivateContactScanStateWallets", + LOAD_PRIVATE_CONTACT_SCAN_STATE_WALLETS_DESCRIPTOR, + &[], + )? + .l()? + .into(); + env.convert_byte_array(&packed) + }); + let Some(packed) = loaded else { + return ERR_JNI; + }; + let Some(ids) = unpack_wallet_ids(&packed) else { + return ERR_JNI; + }; + if !ids.is_empty() { + *out_wallet_ids_count = ids.len(); + *out_wallet_ids = Box::into_raw(ids.into_boxed_slice()) as *const _; + } + 0 +} + +/// The 32-byte ids packed back to back in `packed`, or `None` when it is not +/// a whole number of them. +fn unpack_wallet_ids(packed: &[u8]) -> Option> { + let (ids, rest) = packed.as_chunks::<32>(); + rest.is_empty().then(|| ids.to_vec()) +} + +/// Release the array [`tramp_load_private_contact_scan_state_wallets`] minted. +unsafe extern "C" fn tramp_load_private_contact_scan_state_wallets_free( + _context: *mut c_void, + wallet_ids: *const [u8; 32], + wallet_ids_count: usize, +) { + if !wallet_ids.is_null() { + drop(Box::from_raw(ptr::slice_from_raw_parts_mut( + wallet_ids.cast_mut(), + wallet_ids_count, + ))); + } +} + /// Release the arrays [`tramp_load_private_contact_scan_state`] minted. unsafe extern "C" fn tramp_load_private_contact_scan_state_free( _context: *mut c_void, @@ -5172,6 +5246,10 @@ const BRIDGE_METHOD_TABLE: &[(&str, &str)] = &[ "onLoadPrivateContactScanState", LOAD_PRIVATE_CONTACT_SCAN_STATE_DESCRIPTOR, ), + ( + "onLoadPrivateContactScanStateWallets", + LOAD_PRIVATE_CONTACT_SCAN_STATE_WALLETS_DESCRIPTOR, + ), ( "onPersistIdentityUpsert", "([B[BJJZIBZ[B[Ljava/lang/String;[JZLjava/lang/String;Ljava/lang/String;\ @@ -5439,12 +5517,38 @@ mod tests { .is_some(), wired ); + assert_eq!( + extension + .on_load_private_contact_scan_state_wallets_fn + .is_some(), + wired + ); + assert_eq!( + extension + .on_load_private_contact_scan_state_wallets_free_fn + .is_some(), + wired + ); } + assert_eq!( + unpack_wallet_ids(&[[1u8; 32], [2u8; 32]].concat()), + Some(vec![[1u8; 32], [2u8; 32]]) + ); + assert_eq!(unpack_wallet_ids(&[]), Some(vec![])); + assert_eq!( + unpack_wallet_ids(&[0u8; 33]), + None, + "not a whole number of ids" + ); let names = |names: &[&str]| names.iter().map(|n| n.to_string()).collect::>(); assert_eq!( method_descriptor(&names(&["[B", "[B", "[B", "[B", "[B"]), "int"), PERSIST_PRIVATE_CONTACT_SCAN_STATE_DESCRIPTOR ); + assert_eq!( + method_descriptor(&names(&[]), "[B"), + LOAD_PRIVATE_CONTACT_SCAN_STATE_WALLETS_DESCRIPTOR + ); assert_eq!( method_descriptor(&names(&["[B"]), "[[B"), LOAD_PRIVATE_CONTACT_SCAN_STATE_DESCRIPTOR From 00f80f6b0bf36eaf935c015e4256e1879fe24824 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Tue, 10 Nov 2026 19:44:23 +0700 Subject: [PATCH 248/269] fix(kotlin-example-app): recover before reading readiness on every DashPay refresh, and re-read while unrestored Two problems, ported fixes from SwiftExampleApp. First, a restoration can fail after the shielded store opened. configureShieldedStore then cleared the recorded failure, so no refresh ever asked the wallet again. Second, Requests and Add Contact read the readiness before any retry and kept it. A recovery that changed no effect key (no isBound change, and Add Contact observes no DashPay sync) left Send and the collision accept off. - PrivatePaymentsRecovery.refresh is one flow for both screens: - When the store did not open, or the readiness reads not restored, it asks the wallet to retry (AppContainer.configureShieldedStoreAgain). On a store already open, that is the wallet's own retry of every wallet still held. It never waits on a busy store. - It then reads the readiness the screen shows. The pair-hold reads follow. - While the payments stay unrestored, the effect refreshes again every 3 s, at most 20 times, so a restoration the wallet deferred behind a busy store lifts the gate when it completes. - Tests in ContactRequestOutcomesTest: - shouldRetryARestorationThatFailedAfterTheStoreOpenedOnARefresh - shouldShowTheReadinessReadAfterTheRetry - shouldNotAskTheWalletToRetryWhenThePaymentsAreRestored - shouldOpenTheStoreAgainWhileAStoreFailureIsRecorded - shouldRefreshAgainWhileARestorationIsDeferred Co-Authored-By: Claude Opus 5.5 --- .../dashfoundation/example/di/AppContainer.kt | 15 +- .../example/ui/dashpay/AddContactScreen.kt | 30 +++- .../ui/dashpay/ContactRequestOutcomes.kt | 68 ++++++++ .../ui/dashpay/ContactRequestsScreen.kt | 30 +++- .../ui/dashpay/ContactRequestOutcomesTest.kt | 156 ++++++++++++++++++ 5 files changed, 285 insertions(+), 14 deletions(-) diff --git a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/di/AppContainer.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/di/AppContainer.kt index aa78175b62b..35b30f96085 100644 --- a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/di/AppContainer.kt +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/di/AppContainer.kt @@ -215,7 +215,7 @@ class AppContainer(private val context: Context) { /** * Why opening the active network's shielded store failed, until a later * attempt succeeds. The DashPay screens show it while the wallet's private - * payments wait for that store, and retry it ([shieldedStoreFailureRetrying]). + * payments wait for that store, and retry it ([configureShieldedStoreAgain]). * ← Swift `WalletManagerStore.shieldedStoreFailures`. */ val shieldedStoreFailure: StateFlow = _shieldedStoreFailure.asStateFlow() @@ -239,12 +239,15 @@ class AppContainer(private val context: Context) { } /** - * Why the active network's shielded store could not be opened, after - * trying it again, or null when it is open. - * ← Swift `WalletManagerStore.shieldedStoreFailure(retrying:)`. + * Open the active network's shielded store again and return why it + * failed, or null once it is open. On a store already open this asks the + * wallet to retry restoring every wallet whose private payments are still + * held, a restoration that failed after the store opened included; it + * never waits on a busy store (a busy store defers the restoration to a + * task). + * ← Swift `WalletManagerStore.configureShieldedAgain(_:)`. */ - suspend fun shieldedStoreFailureRetrying(): String? { - if (_shieldedStoreFailure.value == null) return null + suspend fun configureShieldedStoreAgain(): String? { val manager = walletManagerStore.activeManager.value ?: return _shieldedStoreFailure.value configureShieldedStore(manager) return _shieldedStoreFailure.value diff --git a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/AddContactScreen.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/AddContactScreen.kt index 90663b10189..52761567570 100644 --- a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/AddContactScreen.kt +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/AddContactScreen.kt @@ -116,6 +116,9 @@ fun AddContactScreen(identityIdHex: String, navController: NavHostController) { var shieldedStoreFailure by remember { mutableStateOf(null) } var isSyncingPrivate by remember { mutableStateOf(false) } var pendingReadVersion by remember { mutableStateOf(0) } + // The automatic refreshes made since the wallet's private payments were + // last read restored (`PrivatePaymentsRecovery.rechecks`). + var readinessRechecks by remember { mutableStateOf(0) } var collisionRecipient by remember { mutableStateOf(null) } var previewProfile by remember { mutableStateOf(null) } @@ -195,11 +198,30 @@ fun AddContactScreen(identityIdHex: String, navController: NavHostController) { val recipientHex = resolvedRecipient?.toHex() LaunchedEffect(recipientHex, wallet, pendingReadVersion) { val contactIds = listOfNotNull(resolvedRecipient) + // Recovery first: the readiness shown is read after the wallet was + // asked to retry a restoration its payments still wait for. + val refreshed = PrivatePaymentsRecovery.refresh( + lastReadiness = recipientPending.readiness, + storeFailure = container.shieldedStoreFailure.value, + readReadiness = { privateContactReadiness(wallet, idBytes) }, + configureAgain = { container.configureShieldedStoreAgain() }, + ) + shieldedStoreFailure = refreshed.storeFailure val pendingRead = privateRepliesPending(wallet, idBytes, contactIds) - val readiness = privateContactReadiness(wallet, idBytes) - recipientPending = recipientPending.recorded(pendingRead, contactIds.map { it.toHex() }) - .withReadiness(readiness?.readiness, readiness?.restoreFailure) - shieldedStoreFailure = container.shieldedStoreFailureRetrying() + recipientPending = recipientPending + .withReadiness(refreshed.read?.readiness, refreshed.read?.restoreFailure) + .recorded(pendingRead, contactIds.map { it.toHex() }) + // While the payments stay unrestored, refresh again after a pause: a + // restoration the wallet deferred behind a busy store completes later + // and changes no key of this effect (Add Contact observes neither a + // bind nor a DashPay sync for it). + if (PrivatePaymentsRecovery.rechecks(recipientPending.readiness, readinessRechecks)) { + delay(PrivatePaymentsRecovery.RECHECK_INTERVAL_MS) + readinessRechecks++ + pendingReadVersion++ + } else if (recipientPending.readiness?.privatePaymentsRestored == true) { + readinessRechecks = 0 + } } // Binding the shielded account restores the wallet's private payments: // read the readiness again then. diff --git a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/ContactRequestOutcomes.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/ContactRequestOutcomes.kt index 02d9802e03b..81116200319 100644 --- a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/ContactRequestOutcomes.kt +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/ContactRequestOutcomes.kt @@ -232,6 +232,74 @@ data class PrivateRepliesPendingSnapshot( } } +/** + * The DashPay screens' readiness refresh, one flow for Requests and Add + * Contact. The wallet owns the recovery: on a store already open, a repeat + * `configureShielded` retries restoring every wallet whose private payments + * are still held, a restoration that failed after the store opened included, + * and defers it to a task while a scan holds the store. So a refresh asks for + * it whenever the payments are not restored, or the store did not open, and + * only then reads the readiness it shows: the readiness is read after the + * attempt, never cached from before it. While the payments stay unrestored (a + * deferred restoration completes later, changing nothing a screen observes), + * the screen refreshes again on a bounded timer, so the gate lifts without a + * bind, a restart or a submitted request. The screens change no hold + * themselves. + * ← Swift `PrivatePaymentsRecovery` (`ContactRequestOutcomes.swift`). + */ +object PrivatePaymentsRecovery { + /** How many times a screen refreshes again on its own while the payments stay unrestored. */ + const val RECHECK_LIMIT = 20 + + /** How long a screen waits before it refreshes again. */ + const val RECHECK_INTERVAL_MS = 3_000L + + /** + * Whether a refresh asks the wallet to retry before it reads the + * readiness it shows: the store did not open last time, or the last + * readiness read says the payments are not restored. + */ + fun retries(readiness: Dashpay.PrivateContactReadiness?, storeFailure: String?): Boolean = + storeFailure != null || readiness?.privatePaymentsRestored == false + + /** + * Whether a screen refreshes again on its own after [attempt] automatic + * refreshes: the payments are still not restored and the limit is not + * reached. A failed read (null) is left to the next refresh the screen + * makes anyway. + */ + fun rechecks(after: Dashpay.PrivateContactReadiness?, attempt: Int): Boolean = + after?.privatePaymentsRestored == false && attempt < RECHECK_LIMIT + + /** A refresh's readiness read, null when it failed, and why the store did not open, or null. */ + data class Refreshed(val read: PrivateContactReadinessRead?, val storeFailure: String?) + + /** + * One refresh. [lastReadiness] is the readiness the screen shows now; + * [readReadiness] reads the wallet's (with the reason a restoration + * failed); [configureAgain] asks the wallet to retry and returns why the + * store did not open, or null. When [retries] says so, the wallet is asked + * first and the readiness is read after it; otherwise it is read once. + */ + suspend fun refresh( + lastReadiness: Dashpay.PrivateContactReadiness?, + storeFailure: String?, + readReadiness: suspend () -> PrivateContactReadinessRead?, + configureAgain: suspend () -> String?, + ): Refreshed { + if (retries(lastReadiness, storeFailure)) { + val failure = configureAgain() + return Refreshed(readReadiness(), failure) + } + val read = readReadiness() + if (retries(read?.readiness, storeFailure)) { + val failure = configureAgain() + return Refreshed(readReadiness(), failure) + } + return Refreshed(read, storeFailure) + } +} + /** * What the request list's private-store refresh tells the user. A network * that does not serve the store yet (before protocol version 15) is expected diff --git a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/ContactRequestsScreen.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/ContactRequestsScreen.kt index fbd17e87858..1318008d419 100644 --- a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/ContactRequestsScreen.kt +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/ContactRequestsScreen.kt @@ -38,6 +38,7 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.NavHostController +import kotlinx.coroutines.delay import kotlinx.coroutines.launch import org.dashfoundation.example.di.LocalAppContainer import org.dashfoundation.example.di.LocalAppState @@ -104,6 +105,9 @@ fun ContactRequestsScreen(identityIdHex: String, navController: NavHostControlle // pending state; null when it is open. var shieldedStoreFailure by remember { mutableStateOf(null) } var pendingReadVersion by remember { mutableStateOf(0) } + // The automatic refreshes made since the wallet's private payments were + // last read restored (`PrivatePaymentsRecovery.rechecks`). + var readinessRechecks by remember { mutableStateOf(0) } // What the last private-store refresh has to tell the user, if anything. var privateRefreshNotice by remember { mutableStateOf(null) } @@ -163,15 +167,33 @@ fun ContactRequestsScreen(identityIdHex: String, navController: NavHostControlle LaunchedEffect(wallet, rows, pendingReadVersion) { val contactIds = rows.filter { !it.isOutgoing }.map { it.contactIdentityId } + // Recovery first: the readiness shown is read after the wallet was + // asked to retry a restoration its payments still wait for. + val refreshed = PrivatePaymentsRecovery.refresh( + lastReadiness = walletPending.readiness, + storeFailure = container.shieldedStoreFailure.value, + readReadiness = { privateContactReadiness(wallet, idBytes) }, + configureAgain = { container.configureShieldedStoreAgain() }, + ) + shieldedStoreFailure = refreshed.storeFailure val read = privateRepliesPending( wallet = wallet, ourIdentityId = idBytes, contactIds = contactIds, ) - val readiness = privateContactReadiness(wallet, idBytes) - walletPending = walletPending.recorded(read, contactIds.map { it.toHex() }) - .withReadiness(readiness?.readiness, readiness?.restoreFailure) - shieldedStoreFailure = container.shieldedStoreFailureRetrying() + walletPending = walletPending + .withReadiness(refreshed.read?.readiness, refreshed.read?.restoreFailure) + .recorded(read, contactIds.map { it.toHex() }) + // While the payments stay unrestored, refresh again after a pause: a + // restoration the wallet deferred behind a busy store completes later + // and changes no key of this effect. + if (PrivatePaymentsRecovery.rechecks(walletPending.readiness, readinessRechecks)) { + delay(PrivatePaymentsRecovery.RECHECK_INTERVAL_MS) + readinessRechecks++ + pendingReadVersion++ + } else if (walletPending.readiness?.privatePaymentsRestored == true) { + readinessRechecks = 0 + } } // Binding the shielded account restores the wallet's private payments: // read the readiness again then. diff --git a/packages/kotlin-sdk/KotlinExampleApp/app/src/test/java/org/dashfoundation/example/ui/dashpay/ContactRequestOutcomesTest.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/test/java/org/dashfoundation/example/ui/dashpay/ContactRequestOutcomesTest.kt index 390bcd1c401..b4bd962fada 100644 --- a/packages/kotlin-sdk/KotlinExampleApp/app/src/test/java/org/dashfoundation/example/ui/dashpay/ContactRequestOutcomesTest.kt +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/test/java/org/dashfoundation/example/ui/dashpay/ContactRequestOutcomesTest.kt @@ -1,5 +1,6 @@ package org.dashfoundation.example.ui.dashpay +import kotlinx.coroutines.test.runTest import org.dashfoundation.dashsdk.errors.DashSdkError import org.dashfoundation.dashsdk.tokens.Dashpay import org.junit.Assert.assertEquals @@ -166,6 +167,161 @@ class ContactRequestOutcomesTest { assertEquals(ContactRequestSubmissionOutcome.SDK_PINNED_MESSAGE, PrivateContactRefreshNotice.notice(error)) } + /** + * The store opened, but the wallet's journal restoration failed for a + * transient reason. A DashPay refresh asks the wallet to retry even + * though no store-open failure is recorded, reads the readiness after the + * attempt, and once the retry restores the payments the gate lifts: no + * bind, no restart and no submitted request. + */ + @Test + fun shouldRetryARestorationThatFailedAfterTheStoreOpenedOnARefresh() = runTest { + var restorationFails = true + var configures = 0 + val read: suspend () -> PrivateContactReadinessRead? = { + if (restorationFails) { + PrivateContactReadinessRead( + readiness(restored = false, storeRead = true, restoreFailed = true), + "the journal could not be read", + ) + } else { + PrivateContactReadinessRead(readiness(restored = true, storeRead = true), null) + } + } + var snapshot = PrivateRepliesPendingSnapshot().recorded(emptySet(), listOf("01")) + + // The first refresh: the store opened, the restoration failed, and the + // retry it asks for fails too. + var refreshed = PrivatePaymentsRecovery.refresh(snapshot.readiness, null, read) { + configures++ + null + } + snapshot = snapshot.withReadiness(refreshed.read?.readiness, refreshed.read?.restoreFailure) + assertEquals("not restored asks the wallet to retry with no store failure recorded", 1, configures) + assertNull(refreshed.storeFailure) + assertFalse(snapshot.allowsSubmission("01")) + assertEquals("the journal could not be read", snapshot.restoreFailure) + assertTrue(PrivatePaymentsRecovery.rechecks(snapshot.readiness, 0)) + + // The failure was transient: the next refresh asks first, the retry + // restores, and the readiness shown is the one read after it. + refreshed = PrivatePaymentsRecovery.refresh(snapshot.readiness, null, read) { + configures++ + restorationFails = false + null + } + snapshot = snapshot.withReadiness(refreshed.read?.readiness, refreshed.read?.restoreFailure) + assertEquals(2, configures) + assertFalse(snapshot.isRestoringPrivatePayments) + assertNull(snapshot.restoreFailure) + assertTrue("the gate lifts on the refresh alone", snapshot.allowsSubmission("01")) + assertFalse(PrivatePaymentsRecovery.rechecks(snapshot.readiness, 1)) + } + + /** + * A refresh asks for the retry before it reads the readiness it shows: + * a screen that last showed "not restored" asks first and shows only the + * read made after it, so recovery without an isBound change re-enables + * the existing screen. + */ + @Test + fun shouldShowTheReadinessReadAfterTheRetry() = runTest { + val events = mutableListOf() + var restored = false + val refreshed = PrivatePaymentsRecovery.refresh( + lastReadiness = readiness(restored = false, storeRead = true), + storeFailure = null, + readReadiness = { + events += "read" + PrivateContactReadinessRead(readiness(restored = restored, storeRead = true), null) + }, + configureAgain = { + events += "configure" + restored = true + null + }, + ) + assertEquals(listOf("configure", "read"), events) + assertEquals(true, refreshed.read?.readiness?.privatePaymentsRestored) + val shown = PrivateRepliesPendingSnapshot() + .withReadiness(readiness(restored = false, storeRead = true)) + .recorded(emptySet(), listOf("01")) + .withReadiness(refreshed.read?.readiness, refreshed.read?.restoreFailure) + assertTrue(shown.allowsSubmission("01")) + } + + /** Restored payments and an open store need no retry: one read. */ + @Test + fun shouldNotAskTheWalletToRetryWhenThePaymentsAreRestored() = runTest { + var configures = 0 + var reads = 0 + val refreshed = PrivatePaymentsRecovery.refresh( + lastReadiness = readiness(restored = true, storeRead = true), + storeFailure = null, + readReadiness = { + reads++ + PrivateContactReadinessRead(readiness(restored = true, storeRead = true), null) + }, + configureAgain = { + configures++ + null + }, + ) + assertEquals(0, configures) + assertEquals(1, reads) + assertEquals(true, refreshed.read?.readiness?.privatePaymentsRestored) + } + + /** + * Opening the store succeeds on the refresh after a failed open: the + * failure clears, whatever the readiness says. + */ + @Test + fun shouldOpenTheStoreAgainWhileAStoreFailureIsRecorded() = runTest { + var configures = 0 + val refreshed = PrivatePaymentsRecovery.refresh( + lastReadiness = null, + storeFailure = "disk full", + readReadiness = { null }, + configureAgain = { + configures++ + null + }, + ) + assertEquals(1, configures) + assertNull(refreshed.storeFailure) + } + + /** + * A restoration the wallet deferred behind a busy store completes later, + * changing no key of the screen's effect: the screen refreshes again on + * its own, bounded, while the payments stay unrestored, and the refresh + * after the deferred restoration lifts the gate. + */ + @Test + fun shouldRefreshAgainWhileARestorationIsDeferred() = runTest { + var deferredDone = false + val read: suspend () -> PrivateContactReadinessRead? = { + PrivateContactReadinessRead(readiness(restored = deferredDone, storeRead = true), null) + } + var snapshot = PrivateRepliesPendingSnapshot().recorded(emptySet(), listOf("01")) + var refreshed = PrivatePaymentsRecovery.refresh(snapshot.readiness, null, read) { null } + snapshot = snapshot.withReadiness(refreshed.read?.readiness, refreshed.read?.restoreFailure) + assertFalse(snapshot.allowsSubmission("01")) + assertTrue(PrivatePaymentsRecovery.rechecks(snapshot.readiness, 0)) + assertFalse( + "bounded", + PrivatePaymentsRecovery.rechecks(snapshot.readiness, PrivatePaymentsRecovery.RECHECK_LIMIT), + ) + assertFalse(PrivatePaymentsRecovery.rechecks(null, 0)) + + deferredDone = true + refreshed = PrivatePaymentsRecovery.refresh(snapshot.readiness, null, read) { null } + snapshot = snapshot.withReadiness(refreshed.read?.readiness, refreshed.read?.restoreFailure) + assertTrue(snapshot.allowsSubmission("01")) + assertFalse(PrivatePaymentsRecovery.rechecks(snapshot.readiness, 1)) + } + private fun readiness( restored: Boolean, storeRead: Boolean, From 6502e697b0528c3dadb8c1515fecaa8fd9cc173c Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Tue, 10 Nov 2026 19:44:23 +0700 Subject: [PATCH 249/269] docs(sdk): record the retained scan state records and the refresh recovery in the parity manifest - The scan state entry lists the two wallet list slots, describes the purge rule on both hosts, and verifies with the new purge tests and the two FFI retained-record tests. - The private contact entry describes the refresh recovery and verifies it on both apps. Co-Authored-By: Claude Opus 5.5 --- docs/sdk/sdk-parity-manifest.json | 52 ++++++++++++++++++++++++++----- 1 file changed, 44 insertions(+), 8 deletions(-) diff --git a/docs/sdk/sdk-parity-manifest.json b/docs/sdk/sdk-parity-manifest.json index 0f012de030e..732b92c84ba 100644 --- a/docs/sdk/sdk-parity-manifest.json +++ b/docs/sdk/sdk-parity-manifest.json @@ -34,6 +34,8 @@ "managed_identity_get_private_contact": "packages/rs-platform-wallet-ffi/src/private_contact_request.rs", "on_load_private_contact_scan_state_fn": "packages/rs-platform-wallet-ffi/src/persistence.rs", "on_load_private_contact_scan_state_free_fn": "packages/rs-platform-wallet-ffi/src/persistence.rs", + "on_load_private_contact_scan_state_wallets_fn": "packages/rs-platform-wallet-ffi/src/persistence.rs", + "on_load_private_contact_scan_state_wallets_free_fn": "packages/rs-platform-wallet-ffi/src/persistence.rs", "on_load_shielded_viewing_keys_fn": "packages/rs-platform-wallet-ffi/src/persistence.rs", "on_load_shielded_viewing_keys_free_fn": "packages/rs-platform-wallet-ffi/src/persistence.rs", "on_persist_address_balances_fn": "packages/rs-platform-wallet-ffi/src/persistence.rs", @@ -805,13 +807,13 @@ "sdk": "partial", "example_app": "partial", "restart": "required", - "reason": "PrivateContactRequests.swift wraps the store sync, the shielded send, the accept replying in kind (the wallet replies privately to a request that came privately, and with a document otherwise; a public or adopted reply needs no shielded support or payment mnemonic read, and a build without shielded support refuses only a private reply, with code 69), the explicit private-reply accept, PrivateContactInfo and the wallet's pair-hold read (privatePairHeld, which answers as a send decides it, the shielded store's journal included), all signed with the identity signer the public calls take and counted as native manager operations; codes 68 to 75 are typed and pinned in ErrorHandlingTests. The sync, send and accepts have no automated or recorded gate: a private reply needs a protocol version 15 network with shielded funds. The private marker (core height 0) round-trips through the persisted coreHeightCreatedAt, but no test covers a restart; a paid reply's hold survives one in the shielded store's payment journal, which no host test exercises. The example app has the send-privately toggle, accepts with acceptContactRequestReplyingInKind, keeps a held or unconfirmed private send or reply pending by reading the wallet's per-pair hold (a failed read keeps the last snapshot and the controls off), re-enables a pair only when that read says free (a payment that never lands frees it only once the wallet proves it expired), offers a private sync for a pending request a public send or accept meets (code 71), and reports private refresh failures (quiet before protocol version 15), with no recorded gate. Code 72 (unrestored private payments) is typed apart from 71, and the readiness getter and the restore-failure reason are bound; the example app configures the shielded store when it activates a manager, before loading wallets, retries a failed configure, and shows \"setting up\", why the last restoration failed or why the store did not open, from 72 or the readiness, lifted only by a readiness read that says restored, never by a private sync. Code 73 (a store that cannot keep a private payment's recovery state) is typed and shown as a storage configuration error; the host declares atomic changesets with the scan state, so it should not occur. Code 74 (a private store read that did not finish) is a retry with no pending row, and code 75 (an SDK pinned below the protocol version) a developer-facing message." + "reason": "PrivateContactRequests.swift wraps the store sync, the shielded send, the accept replying in kind (the wallet replies privately to a request that came privately, and with a document otherwise; a public or adopted reply needs no shielded support or payment mnemonic read, and a build without shielded support refuses only a private reply, with code 69), the explicit private-reply accept, PrivateContactInfo and the wallet's pair-hold read (privatePairHeld, which answers as a send decides it, the shielded store's journal included), all signed with the identity signer the public calls take and counted as native manager operations; codes 68 to 75 are typed and pinned in ErrorHandlingTests. The sync, send and accepts have no automated or recorded gate: a private reply needs a protocol version 15 network with shielded funds. The private marker (core height 0) round-trips through the persisted coreHeightCreatedAt, but no test covers a restart; a paid reply's hold survives one in the shielded store's payment journal, which no host test exercises. The example app has the send-privately toggle, accepts with acceptContactRequestReplyingInKind, keeps a held or unconfirmed private send or reply pending by reading the wallet's per-pair hold (a failed read keeps the last snapshot and the controls off), re-enables a pair only when that read says free (a payment that never lands frees it only once the wallet proves it expired), offers a private sync for a pending request a public send or accept meets (code 71), and reports private refresh failures (quiet before protocol version 15), with no recorded gate. Code 72 (unrestored private payments) is typed apart from 71, and the readiness getter and the restore-failure reason are bound; the example app configures the shielded store when it activates a manager, before loading wallets; on each DashPay refresh while the store did not open or the readiness reads not restored it asks the wallet to retry (a repeat configure) and reads the readiness after that, refreshing again on a bounded timer while it stays unrestored; it shows \"setting up\", why the last restoration failed or why the store did not open, from 72 or the readiness, lifted only by a readiness read that says restored, never by a private sync. Code 73 (a store that cannot keep a private payment's recovery state) is typed and shown as a storage configuration error; the host declares atomic changesets with the scan state, so it should not occur. Code 74 (a private store read that did not finish) is a retry with no pending row, and code 75 (an SDK pinned below the protocol version) a developer-facing message." }, "kotlin": { "sdk": "partial", "example_app": "partial", "restart": "required", - "reason": "Dashpay.syncPrivateContactRequests, privateContact, privateRepliesPending (one pair-hold read per contact, as a send decides it, the shielded store's journal included), privateContactReadiness and privatePaymentsRestoreFailure, and PlatformWalletManager.sendPrivateContactRequest / acceptContactRequestReplyingInKind / acceptPrivateContactRequest bridge the same FFI (signed with the manager's identity signer, behind the manager's teardown gate); the in-kind accept is exported in every build, and codes 68 to 75 map to typed PlatformWallet errors (DashSdkErrorTest). JNI descriptors are covered only by the FfiSmokeTest symbol test; the flows need a protocol version 15 network with shielded funds and have no recorded gate, no test covers a restart, and a paid reply's hold survives one only in the shielded store's payment journal, which no host test exercises. The example app accepts with acceptContactRequestReplyingInKind, keeps a held or unconfirmed reply pending by reading the wallet's per-pair hold on its Requests rows and Add Contact (a failed read keeps the last snapshot and the controls off), re-enables a pair only when that read says free, offers a private sync for a pending request a public send or accept meets (code 71), and reports private refresh failures (quiet before protocol version 15); it has no send-privately toggle. Code 72 (unrestored private payments) is typed apart from 71, and the readiness getter and the restore-failure reason are bound; the example app configures the shielded store when it activates the manager, before loading wallets, retries a failed configure, and shows \"setting up\", why the last restoration failed or why the store did not open, from 72 or the readiness, lifted only by a readiness read that says restored, never by a private sync. Code 73 (a store that cannot keep a private payment's recovery state) is typed and shown as a storage configuration error; the host declares atomic changesets with the scan state (bit 15), so it should not occur. Code 74 (a private store read that did not finish) is a retry with no pending row, and code 75 (an SDK pinned below the protocol version) a developer-facing message." + "reason": "Dashpay.syncPrivateContactRequests, privateContact, privateRepliesPending (one pair-hold read per contact, as a send decides it, the shielded store's journal included), privateContactReadiness and privatePaymentsRestoreFailure, and PlatformWalletManager.sendPrivateContactRequest / acceptContactRequestReplyingInKind / acceptPrivateContactRequest bridge the same FFI (signed with the manager's identity signer, behind the manager's teardown gate); the in-kind accept is exported in every build, and codes 68 to 75 map to typed PlatformWallet errors (DashSdkErrorTest). JNI descriptors are covered only by the FfiSmokeTest symbol test; the flows need a protocol version 15 network with shielded funds and have no recorded gate, no test covers a restart, and a paid reply's hold survives one only in the shielded store's payment journal, which no host test exercises. The example app accepts with acceptContactRequestReplyingInKind, keeps a held or unconfirmed reply pending by reading the wallet's per-pair hold on its Requests rows and Add Contact (a failed read keeps the last snapshot and the controls off), re-enables a pair only when that read says free, offers a private sync for a pending request a public send or accept meets (code 71), and reports private refresh failures (quiet before protocol version 15); it has no send-privately toggle. Code 72 (unrestored private payments) is typed apart from 71, and the readiness getter and the restore-failure reason are bound; the example app configures the shielded store when it activates the manager, before loading wallets; on each DashPay refresh while the store did not open or the readiness reads not restored it asks the wallet to retry (a repeat configure) and reads the readiness after that, refreshing again on a bounded timer while it stays unrestored; it shows \"setting up\", why the last restoration failed or why the store did not open, from 72 or the readiness, lifted only by a readiness read that says restored, never by a private sync. Code 73 (a store that cannot keep a private payment's recovery state) is typed and shown as a storage configuration error; the host declares atomic changesets with the scan state (bit 15), so it should not occur. Code 74 (a private store read that did not finish) is a retry with no pending row, and code 75 (an SDK pinned below the protocol version) a developer-facing message." } }, "verification": [ @@ -918,17 +920,35 @@ "id": "privateContactRequestSymbolsBindAndRejectInvalidInput", "command": "cd packages/kotlin-sdk && ./gradlew :sdk:connectedDebugAndroidTest", "covers_restart": false + }, + { + "host": "swift", + "kind": "unit", + "file": "packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/ContactRequestOutcomesTests.swift", + "id": "testShouldRetryARestorationThatFailedAfterTheStoreOpenedOnARefresh", + "command": "xcodebuild test -project packages/swift-sdk/SwiftExampleApp/SwiftExampleApp.xcodeproj -scheme SwiftExampleApp -destination 'platform=iOS Simulator,name=iPhone 17 Pro' -only-testing:SwiftExampleAppTests/ContactRequestOutcomesTests", + "covers_restart": false + }, + { + "host": "kotlin", + "kind": "unit", + "file": "packages/kotlin-sdk/KotlinExampleApp/app/src/test/java/org/dashfoundation/example/ui/dashpay/ContactRequestOutcomesTest.kt", + "id": "shouldRetryARestorationThatFailedAfterTheStoreOpenedOnARefresh", + "command": "cd packages/kotlin-sdk && ./gradlew :app:testDebugUnitTest --tests '*ContactRequestOutcomesTest*'", + "covers_restart": false } ] }, { "id": "dashpay.private_contact_request_scan_state", - "title": "DashPay private contact request sync resumes after restart from its persisted read position", + "title": "DashPay private contact request sync resumes after restart from its persisted read position; a marked record outlives its wallet", "area": "persistence", "shared_apis": [ "on_persist_private_contact_scan_state_fn", "on_load_private_contact_scan_state_fn", - "on_load_private_contact_scan_state_free_fn" + "on_load_private_contact_scan_state_free_fn", + "on_load_private_contact_scan_state_wallets_fn", + "on_load_private_contact_scan_state_wallets_free_fn" ], "required_persistence_capabilities": [ "atomic_changesets", @@ -940,13 +960,13 @@ "sdk": "partial", "example_app": "partial", "restart": "required", - "reason": "SwiftData schema V5 stores each identity's read position and its private contacts' shielded addresses (PersistentPrivateContactRequestsPosition, PersistentPrivateShieldedAddress) inside the changeset round, clears the state of a removed identity, purges it with the wallet, hands it back through the load/free shims (a stored row that cannot cross fails the load) and declares the capability; the example app's Storage Explorer lists both models. No test restarts a process and resumes a private sync from the stored position." + "reason": "SwiftData schema V5 stores each identity's read position and its private contacts' shielded addresses (PersistentPrivateContactRequestsPosition, PersistentPrivateShieldedAddress) inside the changeset round, clears the state of a removed identity, and purges it with the wallet except a position record marked unresolved, which it keeps until a record stored cleared for the same wallet and identity replaces it and lists through the wallet list shim, so the load hands it back as retained and a wallet registered again holds the identity it names; it hands the state back through the load/free shims (a stored row that cannot cross fails the load) and declares the capability; the example app's Storage Explorer lists both models and notes a kept record. No test restarts a process and resumes a private sync from the stored position." }, "kotlin": { "sdk": "partial", "example_app": "not-applicable", "restart": "required", - "reason": "Room v16 (migration 15 to 16) stores the same rows in the round, clears a removed identity's state, purges it with the wallet and declares the capability; JNI wires the persist/load/free slots only for a bridge that overrides both callbacks and the contact upsert (the capability also needs contact persistence on the Rust side), rows cross as packed byte arrays, and a stored row that cannot cross fails the load. The example app has no storage explorer. No test restarts a process and resumes a private sync from the stored position." + "reason": "Room v16 (migration 15 to 16) stores the same rows in the round, clears a removed identity's state, purges it with the wallet except a position record marked unresolved, which it keeps until a cleared record for the same wallet and identity replaces it and lists through onLoadPrivateContactScanStateWallets, and declares the capability; JNI wires the five scan state slots only for a bridge that overrides its store, its load, its wallet list and the contact upsert (the capability also needs contact persistence on the Rust side), rows cross as packed byte arrays, and a stored row that cannot cross fails the load. The example app has no storage explorer. No test restarts a process and resumes a private sync from the stored position." } }, "verification": [ @@ -966,11 +986,27 @@ "command": "cargo test -p platform-wallet-ffi --lib should_leave_hosts_without_the_scan_state_capability_unchanged", "covers_restart": false }, + { + "host": "shared", + "kind": "unit", + "file": "packages/rs-platform-wallet-ffi/src/persistence.rs", + "id": "should_hand_back_the_marked_records_no_restored_identity_takes", + "command": "cargo test -p platform-wallet-ffi --lib should_hand_back_the_marked_records_no_restored_identity_takes", + "covers_restart": false + }, + { + "host": "shared", + "kind": "unit", + "file": "packages/rs-platform-wallet-ffi/src/persistence.rs", + "id": "should_hold_an_identity_a_kept_marked_record_names_when_its_wallet_is_registered_again", + "command": "cargo test -p platform-wallet-ffi --lib should_hold_an_identity_a_kept_marked_record_names_when_its_wallet_is_registered_again", + "covers_restart": false + }, { "host": "swift", "kind": "unit", "file": "packages/swift-sdk/SwiftTests/SwiftDashSDKTests/PrivateContactScanStatePersistenceTests.swift", - "id": "testShouldDeclareTheCapabilityAndRoundTripThroughTheCallbackShims", + "id": "testShouldKeepAMarkedRecordThroughItsWalletsPurgeUntilAClearedRecordReplacesIt", "command": "cd packages/swift-sdk && xcodebuild test -scheme SwiftDashSDK -destination 'platform=iOS Simulator,name=iPhone 17' -only-testing:SwiftDashSDKTests/PrivateContactScanStatePersistenceTests", "covers_restart": false }, @@ -978,7 +1014,7 @@ "host": "kotlin", "kind": "unit", "file": "packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/persistence/PrivateContactScanStatePersistenceTest.kt", - "id": "shouldStoreReplaceAndRemoveTheScanStateWithItsRound", + "id": "shouldKeepAMarkedRecordThroughItsWalletsPurgeUntilAClearedRecordReplacesIt", "command": "cd packages/kotlin-sdk && ./gradlew :sdk:testDebugUnitTest --tests '*PrivateContactScanStatePersistenceTest*'", "covers_restart": false }, From 207bd7250ac117e49446390d8cd35d5d455878f8 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Tue, 10 Nov 2026 19:55:04 +0700 Subject: [PATCH 250/269] docs(platform-wallet-ffi): note code 20's unconfirmed state transitions in the registry #5556 routes the SDK's unconfirmed state transition outcome to ErrorTransactionBroadcastUnconfirmed (20), and the shielded spend contract keeps it there; the registry row now says so. Co-Authored-By: Claude Opus 5.5 --- packages/rs-platform-wallet-ffi/ERROR_CODE_REGISTRY.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/rs-platform-wallet-ffi/ERROR_CODE_REGISTRY.md b/packages/rs-platform-wallet-ffi/ERROR_CODE_REGISTRY.md index f27c3ccf640..83c5ed3214d 100644 --- a/packages/rs-platform-wallet-ffi/ERROR_CODE_REGISTRY.md +++ b/packages/rs-platform-wallet-ffi/ERROR_CODE_REGISTRY.md @@ -91,7 +91,7 @@ These are shipped ABI. Do not renumber. | 17 | `ErrorShieldedBroadcastUnconfirmed` | | | 18 | `ErrorShieldedSpendUnconfirmed` | Also, since #5810, what note spends (shielded transfers, unshields, withdrawals, identity top-ups and private document creations from the pool) reported as 16 before: an endpoint's verdict and any transport failure after a dispatch, as the transition may have been delivered. Also `ShieldedBroadcastMaybeDelivered`. The notes and the redrive record are kept, the record saved before the broadcast and the reservation restored from it after a restart; a host must not pay again with other notes. Also what a shield from Platform addresses returns once a dispatch may have reached a node and its result is not proved, an endpoint's refusal or consensus verdict included | | 19 | `ErrorShieldedNoRecordedAnchor` | | -| 20 | `ErrorTransactionBroadcastUnconfirmed` | | +| 20 | `ErrorTransactionBroadcastUnconfirmed` | Since #5556 also a Platform state transition the SDK reports unconfirmed after a possible acceptance (`dash_sdk::Error::StateTransitionResultUnconfirmed`), through the blanket conversion and the shielded spend contract (`map_spend_result`) alike: the host reconciles the original operation and does not retry | | 21 | `ErrorAddressNonceMismatch` | No longer returned by a shield from Platform addresses: an endpoint's nonce verdict on a dispatched shield is 18. Transfers and withdrawals from Platform addresses, and identity registrations and top-ups from them, still return it | | 22 | `ErrorCoreInsufficientFunds` | | | 23 | `ErrorAssetLockNotTracked` | | From b9ccb36471b19eeb8744a74a8c7816c734d1d24a Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Tue, 10 Nov 2026 19:56:26 +0700 Subject: [PATCH 251/269] test(sdk): give the scan state capability test's bridges the wallet list override The JNI wires the private contact scan state slots only for a bridge that overrides the contact upsert, the scan state store, the scan state load and, since the wallet list slot was added, `onLoadPrivateContactScanStateWallets`. `FfiSmokeTest.privateContactScanStateCapabilityNeedsEveryOverride` still built its "every override" bridge without the wallet list, so the capability was not attested and the test failed on the emulator. `Full` now overrides the wallet list, each partial bridge still lacks exactly the one override its name says, and a new `WithoutScanStateWalletList` shows that a bridge lacking only the wallet list does not get the capability. Co-Authored-By: Claude Fable 5.2 --- .../dashfoundation/dashsdk/FfiSmokeTest.kt | 57 ++++++++++++++++++- 1 file changed, 54 insertions(+), 3 deletions(-) diff --git a/packages/kotlin-sdk/sdk/src/androidTest/kotlin/org/dashfoundation/dashsdk/FfiSmokeTest.kt b/packages/kotlin-sdk/sdk/src/androidTest/kotlin/org/dashfoundation/dashsdk/FfiSmokeTest.kt index 0f27dead571..a0dc7ac0c16 100644 --- a/packages/kotlin-sdk/sdk/src/androidTest/kotlin/org/dashfoundation/dashsdk/FfiSmokeTest.kt +++ b/packages/kotlin-sdk/sdk/src/androidTest/kotlin/org/dashfoundation/dashsdk/FfiSmokeTest.kt @@ -162,14 +162,16 @@ class FfiSmokeTest { * The private contact request scan state needs every override it * depends on, and atomic changesets: JNI wires its slots only for a * bridge that overrides the contact upsert native calls, the - * scan-state store and the scan-state load (a read position vouches - * for the requests an inherited no-op contact writer never stored), and + * scan-state store, the scan-state load and the scan-state wallet list + * (a read position vouches for the requests an inherited no-op contact + * writer never stored, and a record kept marked after its wallet's + * removal reaches the wallet only through that list), and * the wallet keeps the state only with `ATOMIC_CHANGESETS` too (an * un-ignore stores the sender's removal and the position rewind in one * changeset). JNI always wires the changeset begin and end callbacks, * so the atomic half is the bridge's declaration. A bridge declaring * both but leaving any override inherited, or declaring the scan state - * without atomic rounds, has neither; one implementing all three and + * without atomic rounds, has neither; one implementing all four and * declaring both, and the Room handler, keep both. */ @Test @@ -184,6 +186,10 @@ class FfiSmokeTest { assertFalse("no contact upsert", effective(ScanStateBridge.WithoutContactUpsert())) assertFalse("no scan-state store", effective(ScanStateBridge.WithoutScanStateStore())) assertFalse("no scan-state load", effective(ScanStateBridge.WithoutScanStateLoad())) + assertFalse( + "no scan-state wallet list", + effective(ScanStateBridge.WithoutScanStateWalletList()), + ) assertFalse( "every override, not declared", effective(object : ScanStateBridge.Full() { @@ -282,6 +288,8 @@ private abstract class ScanStateBridge : NativePersistenceBridge() { override fun onLoadPrivateContactScanState(walletId: ByteArray): Array = arrayOf(ByteArray(0), ByteArray(0)) + + override fun onLoadPrivateContactScanStateWallets(): ByteArray = ByteArray(0) } class WithoutContactUpsert : ScanStateBridge() { @@ -295,6 +303,8 @@ private abstract class ScanStateBridge : NativePersistenceBridge() { override fun onLoadPrivateContactScanState(walletId: ByteArray): Array = arrayOf(ByteArray(0), ByteArray(0)) + + override fun onLoadPrivateContactScanStateWallets(): ByteArray = ByteArray(0) } class WithoutScanStateStore : ScanStateBridge() { @@ -324,6 +334,8 @@ private abstract class ScanStateBridge : NativePersistenceBridge() { override fun onLoadPrivateContactScanState(walletId: ByteArray): Array = arrayOf(ByteArray(0), ByteArray(0)) + + override fun onLoadPrivateContactScanStateWallets(): ByteArray = ByteArray(0) } class WithoutScanStateLoad : ScanStateBridge() { @@ -358,6 +370,45 @@ private abstract class ScanStateBridge : NativePersistenceBridge() { removedAddresses: ByteArray, clearedIdentities: ByteArray, ): Int = 0 + + override fun onLoadPrivateContactScanStateWallets(): ByteArray = ByteArray(0) + } + + class WithoutScanStateWalletList : ScanStateBridge() { + @Suppress("LongParameterList") + override fun onPersistContactUpsert( + walletId: ByteArray, + ownerId: ByteArray, + contactId: ByteArray, + isOutgoing: Boolean, + senderKeyIndex: Int, + recipientKeyIndex: Int, + accountReference: Int, + encryptedPublicKey: ByteArray, + encryptedAccountLabel: ByteArray?, + autoAcceptProof: ByteArray?, + coreHeightCreatedAt: Int, + createdAt: Long, + paymentChannelBroken: Boolean, + alias: String?, + note: String?, + isHidden: Boolean, + contactAccountLabel: String?, + acceptedAccounts: IntArray, + hasExternalAccountReference: Boolean, + externalAccountReference: Int, + ): Int = 0 + + override fun onPersistPrivateContactScanState( + walletId: ByteArray, + positions: ByteArray, + addresses: ByteArray, + removedAddresses: ByteArray, + clearedIdentities: ByteArray, + ): Int = 0 + + override fun onLoadPrivateContactScanState(walletId: ByteArray): Array = + arrayOf(ByteArray(0), ByteArray(0)) } } From 994d3922ac89fdad4ec2b96a468116c6d4ba85f6 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Tue, 10 Nov 2026 20:19:36 +0700 Subject: [PATCH 252/269] fix(swift-example-app): read code 20 as may have gone through on contact requests and shielded sends Since the wallet's spend contract keeps code 20 (an SDK outcome left unconfirmed after a possible acceptance), a private send or accept can return it, and a public one through the blanket conversion. So can a shielded transfer, unshield or withdrawal. Both screens showed it as an ordinary, retryable failure. - ContactRequestSubmissionOutcome maps `transactionBroadcastUnconfirmed` to mayHaveGoneThrough, as it maps 18. - SendViewModel's shielded path reads both 18 and 20 (`SendViewModel.mayHaveGoneThrough`) as "may have gone through, do not retry". - The private send and accept docs name 20. - Tests: - testShouldKeepAnUnconfirmedStateTransitionPending - testShouldNotOfferARetryForAShieldedOperationThatMayHaveGoneThrough Co-Authored-By: Claude Opus 5.5 --- .../PrivateContactRequests.swift | 11 +++++--- .../Core/ViewModels/SendViewModel.swift | 26 ++++++++++++++----- .../DashPay/ContactRequestOutcomes.swift | 7 ++--- .../ContactRequestOutcomesTests.swift | 19 ++++++++++++++ 4 files changed, 49 insertions(+), 14 deletions(-) diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PrivateContactRequests.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PrivateContactRequests.swift index 1455895b0b6..ec414443932 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PrivateContactRequests.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PrivateContactRequests.swift @@ -264,9 +264,11 @@ extension PlatformWalletManager { /// signs a public request's transition. Before anything is paid the /// wallet reads DashPay's private store for the sender to its end. /// Throws `PlatformWalletError.shieldedSpendUnconfirmed` when the payment - /// may already be on chain, and `.privateContactRequestPending` when the + /// may already be on chain, `.transactionBroadcastUnconfirmed` when the + /// SDK could not confirm a transition it may have accepted, and + /// `.privateContactRequestPending` when the /// pair is held for a private request (in flight, paid and not yet read - /// back, or kept by the journal): either way do not send again. + /// back, or kept by the journal): in each case do not send again. /// `.privateContactStoreNotRead` sent nothing; calling again continues /// the read. `.privateContactPaymentsNotRestored` and /// `.privateContactRecoveryUnsupported` refuse before the read. @@ -389,8 +391,9 @@ extension PlatformWalletManager { /// `shieldedAddress` is the 43-byte Orchard address the contact may pay /// us at privately; a public reply ignores it. A public or adopted reply /// needs neither shielded support nor a mnemonic read for the payment. - /// Throws `PlatformWalletError.shieldedSpendUnconfirmed` when a private - /// reply may already be on chain, or `.privateContactRequestPending` when + /// Throws `PlatformWalletError.shieldedSpendUnconfirmed` (or + /// `.transactionBroadcastUnconfirmed`) when a private reply may already be + /// on chain, or `.privateContactRequestPending` when /// the pair is held for one: do not accept again; `.privateContactStoreNotRead` /// when the private store read every accept runs first did not finish /// (nothing sent; calling again continues it); and diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/ViewModels/SendViewModel.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/ViewModels/SendViewModel.swift index 8304a48e2d1..0ee1ea8c137 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/ViewModels/SendViewModel.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Core/ViewModels/SendViewModel.swift @@ -829,15 +829,16 @@ class SendViewModel: ObservableObject { + "the recipient's next shielded sync." } - } catch PlatformWalletError.shieldedSpendUnconfirmed { + } catch let unconfirmed as PlatformWalletError where SendViewModel.mayHaveGoneThrough(unconfirmed) { // The shielded operation (shield / unshield / transfer / withdraw) // may have reached a node and its result is not proved (a node's - // refusal or verdict included), so it may already be on chain. Rust - // intentionally KEEPS any spent notes' reservations, so this must - // NOT be presented as a retryable failure: a retry signs a second - // transition. Surface it through the - // non-error (success) path so the UI doesn't invite a retry; the - // next shielded sync reconciles the outcome. + // refusal or verdict included, code 18), or the SDK could not + // confirm a transition it may have accepted (code 20), so it may + // already be on chain. Rust intentionally KEEPS any spent notes' + // reservations, so this must NOT be presented as a retryable + // failure: a retry signs a second transition. Surface it through + // the non-error (success) path so the UI doesn't invite a retry; + // the next shielded sync reconciles the outcome. successMessage = "Transaction may have gone through — waiting for " + "the next shielded sync to confirm. Do not retry." } catch { @@ -845,6 +846,17 @@ class SendViewModel: ObservableObject { } } + /// Whether a shielded operation's error means it may already be on + /// chain, so no retry is offered: a spend whose result is not proved + /// (18) or a state transition the SDK could not confirm after it may + /// have been accepted (20). + nonisolated static func mayHaveGoneThrough(_ error: PlatformWalletError) -> Bool { + switch error { + case .shieldedSpendUnconfirmed, .transactionBroadcastUnconfirmed: return true + default: return false + } + } + /// Apply the authoritative Core broadcast outcome to the send UI. Kept as /// a small pure state transition so rejected/unknown can be regression /// tested without constructing a live wallet or transaction. diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ContactRequestOutcomes.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ContactRequestOutcomes.swift index 284f4f5766a..62241c3ba9f 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ContactRequestOutcomes.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ContactRequestOutcomes.swift @@ -4,8 +4,9 @@ import SwiftDashSDK /// How a contact request send or accept that threw ended, for the DashPay /// views. Follows `SendViewModel`: a shielded spend whose result could not be /// confirmed (code 18) may already be on chain (Rust keeps its notes -/// reserved), so the view keeps it pending and never offers a fresh -/// submission; the next sync settles it. A pair Rust reports as held for a +/// reserved), and so may a state transition the SDK could not confirm after +/// it may have been accepted (code 20), so the view keeps either pending and +/// never offers a fresh submission; the next sync settles it. A pair Rust reports as held for a /// private request (code 71), which a public send or accept can also meet, /// sent nothing new: the view shows it as pending until the wallet's /// per-contact read says the pair is free. Unrestored private payments @@ -102,7 +103,7 @@ enum ContactRequestSubmissionOutcome: Equatable { static func of(_ error: Error) -> ContactRequestSubmissionOutcome { if let walletError = error as? PlatformWalletError { switch walletError { - case .shieldedSpendUnconfirmed: + case .shieldedSpendUnconfirmed, .transactionBroadcastUnconfirmed: return .mayHaveGoneThrough case .privateContactRequestPending: return .privateRequestPending diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/ContactRequestOutcomesTests.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/ContactRequestOutcomesTests.swift index 1aaafa3733b..28ee493919a 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/ContactRequestOutcomesTests.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/ContactRequestOutcomesTests.swift @@ -21,6 +21,25 @@ final class ContactRequestOutcomesTests: XCTestCase { XCTAssertEqual(outcome, .mayHaveGoneThrough) } + /// Code 20: a state transition the SDK could not confirm after it may + /// have been accepted, which a private send or accept can now return + /// through the spend contract, and a public one through the blanket + /// conversion. It may have executed: nothing is offered again. + func testShouldKeepAnUnconfirmedStateTransitionPending() { + XCTAssertEqual( + ContactRequestSubmissionOutcome.of( + PlatformWalletError.transactionBroadcastUnconfirmed("result could not be confirmed")), + .mayHaveGoneThrough) + } + + /// A shielded send treats code 20 as it treats 18: it may have gone + /// through, so no retry is offered; an ordinary failure is not. + func testShouldNotOfferARetryForAShieldedOperationThatMayHaveGoneThrough() { + XCTAssertTrue(SendViewModel.mayHaveGoneThrough(.shieldedSpendUnconfirmed("not proved"))) + XCTAssertTrue(SendViewModel.mayHaveGoneThrough(.transactionBroadcastUnconfirmed("not confirmed"))) + XCTAssertFalse(SendViewModel.mayHaveGoneThrough(.shieldedBroadcastFailed("never sent"))) + } + /// A pending private request (code 71), which a public send or accept /// can meet too, sent nothing: it is shown as pending rather than as a /// submission that may have gone through. From c116858948caa9d5006c5bbd0101d37428a41e46 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Tue, 10 Nov 2026 20:24:33 +0700 Subject: [PATCH 253/269] fix(kotlin-example-app): read code 20 as may have gone through on contact requests and shielded sends Port of the SwiftExampleApp fix. Since the wallet's spend contract keeps code 20, a private send or accept can return it, and so can a shielded transfer, unshield or withdrawal. Both screens showed it as an ordinary, retryable failure. - ContactRequestSubmissionOutcome maps `PlatformWallet.TransactionBroadcastUnconfirmed` (and the SDK's `StateTransitionResultUnconfirmed`) to MayHaveGoneThrough, as it maps 18. - SendTransactionScreen's shielded path reads 18 and 20 alike (`shieldedOperationMayHaveGoneThrough`) as "may have gone through, do not retry". - The private send and in-kind accept KDocs name 20. - The parity manifest notes it for both apps. - Test: shouldKeepAnUnconfirmedStateTransitionPending. Co-Authored-By: Claude Opus 5.5 --- docs/sdk/sdk-parity-manifest.json | 4 +-- .../ui/dashpay/ContactRequestOutcomes.kt | 11 +++++-- .../ui/wallet/SendTransactionScreen.kt | 33 ++++++++++++++----- .../ui/dashpay/ContactRequestOutcomesTest.kt | 27 +++++++++++++++ .../dashsdk/wallet/PlatformWalletManager.kt | 11 ++++--- 5 files changed, 68 insertions(+), 18 deletions(-) diff --git a/docs/sdk/sdk-parity-manifest.json b/docs/sdk/sdk-parity-manifest.json index 732b92c84ba..ccdcf7ebfc5 100644 --- a/docs/sdk/sdk-parity-manifest.json +++ b/docs/sdk/sdk-parity-manifest.json @@ -807,13 +807,13 @@ "sdk": "partial", "example_app": "partial", "restart": "required", - "reason": "PrivateContactRequests.swift wraps the store sync, the shielded send, the accept replying in kind (the wallet replies privately to a request that came privately, and with a document otherwise; a public or adopted reply needs no shielded support or payment mnemonic read, and a build without shielded support refuses only a private reply, with code 69), the explicit private-reply accept, PrivateContactInfo and the wallet's pair-hold read (privatePairHeld, which answers as a send decides it, the shielded store's journal included), all signed with the identity signer the public calls take and counted as native manager operations; codes 68 to 75 are typed and pinned in ErrorHandlingTests. The sync, send and accepts have no automated or recorded gate: a private reply needs a protocol version 15 network with shielded funds. The private marker (core height 0) round-trips through the persisted coreHeightCreatedAt, but no test covers a restart; a paid reply's hold survives one in the shielded store's payment journal, which no host test exercises. The example app has the send-privately toggle, accepts with acceptContactRequestReplyingInKind, keeps a held or unconfirmed private send or reply pending by reading the wallet's per-pair hold (a failed read keeps the last snapshot and the controls off), re-enables a pair only when that read says free (a payment that never lands frees it only once the wallet proves it expired), offers a private sync for a pending request a public send or accept meets (code 71), and reports private refresh failures (quiet before protocol version 15), with no recorded gate. Code 72 (unrestored private payments) is typed apart from 71, and the readiness getter and the restore-failure reason are bound; the example app configures the shielded store when it activates a manager, before loading wallets; on each DashPay refresh while the store did not open or the readiness reads not restored it asks the wallet to retry (a repeat configure) and reads the readiness after that, refreshing again on a bounded timer while it stays unrestored; it shows \"setting up\", why the last restoration failed or why the store did not open, from 72 or the readiness, lifted only by a readiness read that says restored, never by a private sync. Code 73 (a store that cannot keep a private payment's recovery state) is typed and shown as a storage configuration error; the host declares atomic changesets with the scan state, so it should not occur. Code 74 (a private store read that did not finish) is a retry with no pending row, and code 75 (an SDK pinned below the protocol version) a developer-facing message." + "reason": "PrivateContactRequests.swift wraps the store sync, the shielded send, the accept replying in kind (the wallet replies privately to a request that came privately, and with a document otherwise; a public or adopted reply needs no shielded support or payment mnemonic read, and a build without shielded support refuses only a private reply, with code 69), the explicit private-reply accept, PrivateContactInfo and the wallet's pair-hold read (privatePairHeld, which answers as a send decides it, the shielded store's journal included), all signed with the identity signer the public calls take and counted as native manager operations; codes 68 to 75 are typed and pinned in ErrorHandlingTests. The sync, send and accepts have no automated or recorded gate: a private reply needs a protocol version 15 network with shielded funds. The private marker (core height 0) round-trips through the persisted coreHeightCreatedAt, but no test covers a restart; a paid reply's hold survives one in the shielded store's payment journal, which no host test exercises. The example app has the send-privately toggle, accepts with acceptContactRequestReplyingInKind, keeps a held or unconfirmed private send or reply pending by reading the wallet's per-pair hold (a failed read keeps the last snapshot and the controls off), re-enables a pair only when that read says free (a payment that never lands frees it only once the wallet proves it expired), offers a private sync for a pending request a public send or accept meets (code 71), and reports private refresh failures (quiet before protocol version 15), with no recorded gate. Code 72 (unrestored private payments) is typed apart from 71, and the readiness getter and the restore-failure reason are bound; the example app configures the shielded store when it activates a manager, before loading wallets; on each DashPay refresh while the store did not open or the readiness reads not restored it asks the wallet to retry (a repeat configure) and reads the readiness after that, refreshing again on a bounded timer while it stays unrestored; it shows \"setting up\", why the last restoration failed or why the store did not open, from 72 or the readiness, lifted only by a readiness read that says restored, never by a private sync. Code 73 (a store that cannot keep a private payment's recovery state) is typed and shown as a storage configuration error; the host declares atomic changesets with the scan state, so it should not occur. Code 74 (a private store read that did not finish) is a retry with no pending row, and code 75 (an SDK pinned below the protocol version) a developer-facing message. Code 20 (an SDK outcome left unconfirmed after a possible acceptance, which the spend contract now keeps) reads as may have gone through, as 18 does, with no retry offered." }, "kotlin": { "sdk": "partial", "example_app": "partial", "restart": "required", - "reason": "Dashpay.syncPrivateContactRequests, privateContact, privateRepliesPending (one pair-hold read per contact, as a send decides it, the shielded store's journal included), privateContactReadiness and privatePaymentsRestoreFailure, and PlatformWalletManager.sendPrivateContactRequest / acceptContactRequestReplyingInKind / acceptPrivateContactRequest bridge the same FFI (signed with the manager's identity signer, behind the manager's teardown gate); the in-kind accept is exported in every build, and codes 68 to 75 map to typed PlatformWallet errors (DashSdkErrorTest). JNI descriptors are covered only by the FfiSmokeTest symbol test; the flows need a protocol version 15 network with shielded funds and have no recorded gate, no test covers a restart, and a paid reply's hold survives one only in the shielded store's payment journal, which no host test exercises. The example app accepts with acceptContactRequestReplyingInKind, keeps a held or unconfirmed reply pending by reading the wallet's per-pair hold on its Requests rows and Add Contact (a failed read keeps the last snapshot and the controls off), re-enables a pair only when that read says free, offers a private sync for a pending request a public send or accept meets (code 71), and reports private refresh failures (quiet before protocol version 15); it has no send-privately toggle. Code 72 (unrestored private payments) is typed apart from 71, and the readiness getter and the restore-failure reason are bound; the example app configures the shielded store when it activates the manager, before loading wallets; on each DashPay refresh while the store did not open or the readiness reads not restored it asks the wallet to retry (a repeat configure) and reads the readiness after that, refreshing again on a bounded timer while it stays unrestored; it shows \"setting up\", why the last restoration failed or why the store did not open, from 72 or the readiness, lifted only by a readiness read that says restored, never by a private sync. Code 73 (a store that cannot keep a private payment's recovery state) is typed and shown as a storage configuration error; the host declares atomic changesets with the scan state (bit 15), so it should not occur. Code 74 (a private store read that did not finish) is a retry with no pending row, and code 75 (an SDK pinned below the protocol version) a developer-facing message." + "reason": "Dashpay.syncPrivateContactRequests, privateContact, privateRepliesPending (one pair-hold read per contact, as a send decides it, the shielded store's journal included), privateContactReadiness and privatePaymentsRestoreFailure, and PlatformWalletManager.sendPrivateContactRequest / acceptContactRequestReplyingInKind / acceptPrivateContactRequest bridge the same FFI (signed with the manager's identity signer, behind the manager's teardown gate); the in-kind accept is exported in every build, and codes 68 to 75 map to typed PlatformWallet errors (DashSdkErrorTest). JNI descriptors are covered only by the FfiSmokeTest symbol test; the flows need a protocol version 15 network with shielded funds and have no recorded gate, no test covers a restart, and a paid reply's hold survives one only in the shielded store's payment journal, which no host test exercises. The example app accepts with acceptContactRequestReplyingInKind, keeps a held or unconfirmed reply pending by reading the wallet's per-pair hold on its Requests rows and Add Contact (a failed read keeps the last snapshot and the controls off), re-enables a pair only when that read says free, offers a private sync for a pending request a public send or accept meets (code 71), and reports private refresh failures (quiet before protocol version 15); it has no send-privately toggle. Code 72 (unrestored private payments) is typed apart from 71, and the readiness getter and the restore-failure reason are bound; the example app configures the shielded store when it activates the manager, before loading wallets; on each DashPay refresh while the store did not open or the readiness reads not restored it asks the wallet to retry (a repeat configure) and reads the readiness after that, refreshing again on a bounded timer while it stays unrestored; it shows \"setting up\", why the last restoration failed or why the store did not open, from 72 or the readiness, lifted only by a readiness read that says restored, never by a private sync. Code 73 (a store that cannot keep a private payment's recovery state) is typed and shown as a storage configuration error; the host declares atomic changesets with the scan state (bit 15), so it should not occur. Code 74 (a private store read that did not finish) is a retry with no pending row, and code 75 (an SDK pinned below the protocol version) a developer-facing message. Code 20 (an SDK outcome left unconfirmed after a possible acceptance, which the spend contract now keeps) reads as may have gone through, as 18 does, with no retry offered." } }, "verification": [ diff --git a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/ContactRequestOutcomes.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/ContactRequestOutcomes.kt index 81116200319..24d6ee39e18 100644 --- a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/ContactRequestOutcomes.kt +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/ContactRequestOutcomes.kt @@ -7,8 +7,10 @@ import org.dashfoundation.dashsdk.tokens.Dashpay * How a contact request send or accept that threw ended, for the DashPay * screens. Follows the send screen (and iOS `SendViewModel.swift`): a shielded * spend whose result could not be confirmed (code 18) may already be on chain - * (Rust keeps its notes reserved), so the screen keeps it pending and never - * offers a fresh submission; the next sync settles it. A pair Rust reports as + * (Rust keeps its notes reserved), and so may a state transition the SDK could + * not confirm after it may have been accepted (code 20), so the screen keeps + * either pending and never offers a fresh submission; the next sync settles + * it. A pair Rust reports as * held for a private request (code 71), which a public send or accept can * also meet, sent nothing new: the screen shows it as pending until the * wallet's per-contact read says the pair is free. Unrestored private @@ -92,7 +94,10 @@ sealed interface ContactRequestSubmissionOutcome { } fun of(error: Throwable): ContactRequestSubmissionOutcome = when (error) { - is DashSdkError.PlatformWallet.ShieldedSpendUnconfirmed -> MayHaveGoneThrough + is DashSdkError.PlatformWallet.ShieldedSpendUnconfirmed, + is DashSdkError.PlatformWallet.TransactionBroadcastUnconfirmed, + is DashSdkError.StateTransitionResultUnconfirmed, + -> MayHaveGoneThrough is DashSdkError.PlatformWallet.PrivateContactRequestPending -> PrivateRequestPending is DashSdkError.PlatformWallet.PrivateContactPaymentsNotRestored -> PrivatePaymentsNotRestored is DashSdkError.PlatformWallet.PrivateContactStoreNotRead -> PrivateStoreNotRead diff --git a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/wallet/SendTransactionScreen.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/wallet/SendTransactionScreen.kt index 783128fef29..e1c133c12f2 100644 --- a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/wallet/SendTransactionScreen.kt +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/wallet/SendTransactionScreen.kt @@ -729,16 +729,20 @@ fun SendTransactionScreen( successMessage = "Withdrawal submitted" } } - } catch (e: DashSdkError.PlatformWallet.ShieldedSpendUnconfirmed) { - // It may have reached a node and its result is not - // proved (a node's refusal or verdict included), so it - // may already be on chain and a retry signs a second - // transition: this must NOT read as a retryable - // failure (← SendViewModel.swift:790). - successMessage = "Transaction may have gone through — waiting for " + - "the next shielded sync to confirm. Do not retry." } catch (t: Throwable) { - error = t.message ?: "Failed to send the transaction." + if (shieldedOperationMayHaveGoneThrough(t)) { + // It may have reached a node and its result is not + // proved (a node's refusal or verdict included, + // code 18), or the SDK could not confirm a + // transition it may have accepted (code 20), so it + // may already be on chain and a retry signs a + // second transition: this must NOT read as a + // retryable failure (← SendViewModel.swift). + successMessage = "Transaction may have gone through — waiting for " + + "the next shielded sync to confirm. Do not retry." + } else { + error = t.message ?: "Failed to send the transaction." + } } finally { isSending = false } @@ -796,3 +800,14 @@ fun SendTransactionScreen( ErrorAlertDialog(message = error, onDismiss = { error = null }) } + +/** + * Whether a shielded operation's error means it may already be on chain, so + * no retry is offered: a spend whose result is not proved (18) or a state + * transition the SDK could not confirm after it may have been accepted (20). + * ← Swift `SendViewModel.mayHaveGoneThrough`. + */ +internal fun shieldedOperationMayHaveGoneThrough(error: Throwable): Boolean = + error is DashSdkError.PlatformWallet.ShieldedSpendUnconfirmed || + error is DashSdkError.PlatformWallet.TransactionBroadcastUnconfirmed || + error is DashSdkError.StateTransitionResultUnconfirmed diff --git a/packages/kotlin-sdk/KotlinExampleApp/app/src/test/java/org/dashfoundation/example/ui/dashpay/ContactRequestOutcomesTest.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/test/java/org/dashfoundation/example/ui/dashpay/ContactRequestOutcomesTest.kt index b4bd962fada..62381131fec 100644 --- a/packages/kotlin-sdk/KotlinExampleApp/app/src/test/java/org/dashfoundation/example/ui/dashpay/ContactRequestOutcomesTest.kt +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/test/java/org/dashfoundation/example/ui/dashpay/ContactRequestOutcomesTest.kt @@ -3,6 +3,7 @@ package org.dashfoundation.example.ui.dashpay import kotlinx.coroutines.test.runTest import org.dashfoundation.dashsdk.errors.DashSdkError import org.dashfoundation.dashsdk.tokens.Dashpay +import org.dashfoundation.example.ui.wallet.shieldedOperationMayHaveGoneThrough import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertNull @@ -35,6 +36,32 @@ class ContactRequestOutcomesTest { * meet too, sent nothing: it is shown as pending, with a private sync to * settle it, rather than as a submission that may have gone through. */ + /** + * Code 20: a state transition the SDK could not confirm after it may have + * been accepted, which a private send or accept can now return through + * the spend contract, and a public one through the blanket conversion. It + * may have executed: nothing is offered again, on a contact request or a + * shielded send alike. + */ + @Test + fun shouldKeepAnUnconfirmedStateTransitionPending() { + val wallet = DashSdkError.PlatformWallet.TransactionBroadcastUnconfirmed("result could not be confirmed") + val sdk = DashSdkError.StateTransitionResultUnconfirmed("result could not be confirmed") + assertEquals(ContactRequestSubmissionOutcome.MayHaveGoneThrough, ContactRequestSubmissionOutcome.of(wallet)) + assertEquals(ContactRequestSubmissionOutcome.MayHaveGoneThrough, ContactRequestSubmissionOutcome.of(sdk)) + assertTrue(shieldedOperationMayHaveGoneThrough(wallet)) + assertTrue( + shieldedOperationMayHaveGoneThrough( + DashSdkError.PlatformWallet.ShieldedSpendUnconfirmed("not proved"), + ), + ) + assertFalse( + shieldedOperationMayHaveGoneThrough( + DashSdkError.PlatformWallet.ShieldedBroadcastFailed("never sent"), + ), + ) + } + @Test fun shouldKeepAPendingPrivateRequestPending() { val error = DashSdkError.PlatformWallet.PrivateContactRequestPending("not yet read back from the store") diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/PlatformWalletManager.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/PlatformWalletManager.kt index f7a3d1231e6..ab4207b1213 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/PlatformWalletManager.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/PlatformWalletManager.kt @@ -2159,9 +2159,11 @@ class PlatformWalletManager( * Before anything is paid the wallet reads DashPay's private store for * the sender to its end. Throws * [DashSdkError.PlatformWallet.ShieldedSpendUnconfirmed] when the payment - * may already be on chain, and + * may already be on chain, + * [DashSdkError.PlatformWallet.TransactionBroadcastUnconfirmed] when the + * SDK could not confirm a transition it may have accepted, and * [DashSdkError.PlatformWallet.PrivateContactRequestPending] when the - * pair is held for a private request: either way do not send again. + * pair is held for a private request: in each case do not send again. * [DashSdkError.PlatformWallet.PrivateContactStoreNotRead] sent nothing; * calling again continues the read. * [DashSdkError.PlatformWallet.PrivateContactPaymentsNotRestored] and @@ -2252,8 +2254,9 @@ class PlatformWalletManager( * * A public or adopted reply needs neither shielded support nor a * mnemonic read for the payment. Throws - * [DashSdkError.PlatformWallet.ShieldedSpendUnconfirmed] when a private - * reply may already be on chain, or + * [DashSdkError.PlatformWallet.ShieldedSpendUnconfirmed] (or + * [DashSdkError.PlatformWallet.TransactionBroadcastUnconfirmed]) when a + * private reply may already be on chain, or * [DashSdkError.PlatformWallet.PrivateContactRequestPending] when the pair * is held for one: do not accept again; * [DashSdkError.PlatformWallet.PrivateContactStoreNotRead] when the From c092a6250192b4e0e5ea8c3d48386112d5b96caf Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Tue, 10 Nov 2026 21:22:08 +0700 Subject: [PATCH 254/269] fix(swift-example-app): keep refreshing while a deferred restoration waits, with no last refresh A shielded scan can hold the store for minutes, and the wallet runs a restoration it deferred only once the scan frees the store. The views stopped refreshing after 20 automatic refreshes (a minute), so a restoration that completed later was never seen until the user acted. - While the payments stay unrestored and the view is shown, it refreshes every 3 s for the first minute, then every 15 s, with no limit. PrivatePaymentsRecovery.recheckDelay sets the pace. - PrivatePaymentsRecovery.waitForRecheck is the wait both views share. It ends when the readiness reads restored, before any read succeeded, or when the view's task is cancelled. - Each refresh is local: the in-memory reads, and a repeat configure that checks which wallets are still held and queues at most one deferred restoration per wallet. - Both views also refresh when a shielded sync pass ends (isSyncing turns false), since that is when a deferred restoration can run. The wallet emits no restoration-complete event to observe instead. - The registry's row 72 describes the cadence. - Tests: testShouldKeepRefreshingWhileARestorationStaysDeferred and testShouldStopRefreshingWhenRestoredOrWhenTheViewGoesAway replace testShouldRefreshAgainWhileARestorationIsDeferred. Co-Authored-By: Claude Opus 5.5 --- .../ERROR_CODE_REGISTRY.md | 2 +- .../Views/DashPay/AddContactView.swift | 18 +++-- .../DashPay/ContactRequestOutcomes.swift | 58 +++++++++++---- .../Views/DashPay/ContactRequestsView.swift | 20 ++++-- .../ContactRequestOutcomesTests.swift | 71 ++++++++++++------- 5 files changed, 116 insertions(+), 53 deletions(-) diff --git a/packages/rs-platform-wallet-ffi/ERROR_CODE_REGISTRY.md b/packages/rs-platform-wallet-ffi/ERROR_CODE_REGISTRY.md index 7c7644d7967..3d0fe5895ed 100644 --- a/packages/rs-platform-wallet-ffi/ERROR_CODE_REGISTRY.md +++ b/packages/rs-platform-wallet-ffi/ERROR_CODE_REGISTRY.md @@ -187,7 +187,7 @@ Fork-era numbers remain in the collision history, which is immutable record. | 69 | `ErrorShieldedBuildRequired` | DashPay private contact requests | Proposed. The operation needs the shielded pool and the library was built without the `shielded` feature: accepting in kind a request that came privately, whose 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. Returned by `platform_wallet_manager_accept_contact_request_replying_in_kind`, exported in every build. Swift types it (`PlatformWalletError.shieldedBuildRequired`, `ErrorHandlingTests` pin); Kotlin typing is added by the Kotlin PR (#5813) | | 70 | `ErrorProtocolVersionNotActive` | DashPay private contact requests | Proposed. The SDK refused a request without sending it (`dash_sdk::Error::ProtocolVersionNotActive`, not pinned) because the network is proven at a protocol version below the one that serves 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. An SDK pinned below the required version has its own code, 75. 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 types it (`PlatformWalletError.protocolVersionNotActive`, `ErrorHandlingTests` pin) and SwiftExampleApp keeps a refresh quiet on it; Kotlin typing is added by the Kotlin PR (#5813) | | 71 | `ErrorPrivateContactRequestPending` | DashPay private contact requests | 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 a private payment the shielded store's journal keeps unresolved holds the pair. Nothing new was paid; a later private contact request sync settles it once the entry is read back, and the shielded sync once the payment provably never executes. Pending, not failed: the host must not submit it again. A private store read that did not finish before the request was decided is 74, not 71. The shielded spend contract (`map_spend_result`) keeps the code rather than flattening it to `ErrorWalletOperation`. Returned by the private send, the private accept and the in-kind accept, and by a public send or accept the pair holds; `platform_wallet_get_private_pair_held` reads one pair's hold as a send decides it. Swift types it (`PlatformWalletError.privateContactRequestPending`, `ErrorHandlingTests` pin; `ManagedPlatformWallet.privatePairHeld`), and SwiftExampleApp keeps the pair pending until that read says free; Kotlin typing is added by the Kotlin PR (#5813) | -| 72 | `ErrorPrivateContactPaymentsNotRestored` | DashPay private contact requests | Proposed. Maps `PlatformWalletError::PrivateContactPaymentsNotRestored`: wallet readiness, not one contact's pending request (71). The wallet's unconfirmed private payments may hold pairs no reservation shows yet, which only a restoration from the shielded store's journal shows. In a build with the `shielded` feature: the wallet was published (created or loaded) while no shielded store was configured, its persister declaring the private scan state (bit 15), which holds a wallet removed and registered again from the same seed, in this process or a new one; it was loaded after binding a shielded account in an earlier process; or its last restoration failed, or waits for a busy store. In every build: the identity was loaded with its private scan state marking a private payment that may be unresolved, or was added to a wallet whose backend kept its record marked after an earlier wallet under the id was removed (`ClientStartState::retained_private_contact_scan_states`). Contact requests and acceptances, public and private, are refused with nothing read, signed or broadcast until they are restored: configuring the shielded store restores them with no keys, as binding the wallet's shielded account does, and a contact request first retries a restoration once without waiting on the store; a private sync does not, and a build without the `shielded` feature never does. A host whose persister declares the private scan state configures the shielded store when it activates the manager, before loading or creating wallets: until it does, and while a failed configure is not retried, 72 holds. `platform_wallet_get_private_contact_readiness` reads whether the payments are restored and whether the last restoration failed (`private_payments_restore_failed`; the wallet's `PrivateContactReadiness::private_payments_restore_failure` holds the reason). `platform_wallet_get_private_payments_restore_failure` gives that reason. Swift types it (`PlatformWalletError.privateContactPaymentsNotRestored`, `ErrorHandlingTests` pin) and binds both reads (`ManagedPlatformWallet.privateContactReadiness`, `privatePaymentsRestoreFailure`); SwiftExampleApp configures the shielded store when it activates a manager, before loading wallets; on each DashPay refresh while the store did not open or the readiness reads not restored, it configures the store again (on a store already open, the wallet's own retry of every wallet still held, a restoration that failed after the open included) and reads the readiness after that, refreshing again on a bounded timer while it stays unrestored; it shows "setting up" or the failure's reason until the readiness reads restored. Kotlin typing is added by the Kotlin PR (#5813) | +| 72 | `ErrorPrivateContactPaymentsNotRestored` | DashPay private contact requests | Proposed. Maps `PlatformWalletError::PrivateContactPaymentsNotRestored`: wallet readiness, not one contact's pending request (71). The wallet's unconfirmed private payments may hold pairs no reservation shows yet, which only a restoration from the shielded store's journal shows. In a build with the `shielded` feature: the wallet was published (created or loaded) while no shielded store was configured, its persister declaring the private scan state (bit 15), which holds a wallet removed and registered again from the same seed, in this process or a new one; it was loaded after binding a shielded account in an earlier process; or its last restoration failed, or waits for a busy store. In every build: the identity was loaded with its private scan state marking a private payment that may be unresolved, or was added to a wallet whose backend kept its record marked after an earlier wallet under the id was removed (`ClientStartState::retained_private_contact_scan_states`). Contact requests and acceptances, public and private, are refused with nothing read, signed or broadcast until they are restored: configuring the shielded store restores them with no keys, as binding the wallet's shielded account does, and a contact request first retries a restoration once without waiting on the store; a private sync does not, and a build without the `shielded` feature never does. A host whose persister declares the private scan state configures the shielded store when it activates the manager, before loading or creating wallets: until it does, and while a failed configure is not retried, 72 holds. `platform_wallet_get_private_contact_readiness` reads whether the payments are restored and whether the last restoration failed (`private_payments_restore_failed`; the wallet's `PrivateContactReadiness::private_payments_restore_failure` holds the reason). `platform_wallet_get_private_payments_restore_failure` gives that reason. Swift types it (`PlatformWalletError.privateContactPaymentsNotRestored`, `ErrorHandlingTests` pin) and binds both reads (`ManagedPlatformWallet.privateContactReadiness`, `privatePaymentsRestoreFailure`); SwiftExampleApp configures the shielded store when it activates a manager, before loading wallets; on each DashPay refresh while the store did not open or the readiness reads not restored, it configures the store again (on a store already open, the wallet's own retry of every wallet still held, a restoration that failed after the open included) and reads the readiness after that, refreshing again on a timer (every 3 s for a minute, then every 15 s) for as long as the view is shown and the payments stay unrestored, and when a shielded sync pass ends; it shows "setting up" or the failure's reason until the readiness reads restored. Kotlin typing is added by the Kotlin PR (#5813) | | 73 | `ErrorPrivateContactRecoveryUnsupported` | DashPay private contact requests | Proposed. Maps `PlatformWalletError::PrivateContactRecoveryUnsupported`: the persister does not declare `PRIVATE_CONTACT_REQUEST_SCAN_STATE` (bit 15) 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. The FFI constant is `PLATFORM_WALLET_PERSISTENCE_CAPABILITY_PRIVATE_CONTACT_REQUEST_SCAN_STATE` (`1 << 15`). Swift types it (`PlatformWalletError.privateContactRecoveryUnsupported`, `ErrorHandlingTests` pin), SwiftExampleApp shows it as a storage configuration error, and the SwiftData handler declares bit 0 and bit 15 with the begin and end callbacks wired; Kotlin typing is added by the Kotlin PR (#5813) | | 74 | `ErrorPrivateContactStoreNotRead` | DashPay private contact requests | Proposed. Maps `PlatformWalletError::PrivateContactStoreNotRead`: DashPay's private contact request store was not read to its end for the identity, with the keys Platform proves it holds, before a contact request or acceptance, public or private, was decided (every one reads it from the identity's cursor first). Nothing was reserved, sealed, paid or broadcast; calling again continues the read from where it stopped. A node behind the store already seen, or keys that kept changing, stop it. It was reported as 71 before, which says not to submit again. Mapped by the blanket conversion and kept by the shielded spend contract (`map_spend_result`). Swift types it (`PlatformWalletError.privateContactStoreNotRead`, `ErrorHandlingTests` pin) and SwiftExampleApp offers a retry with no pending row; Kotlin typing is added by the Kotlin PR (#5813) | | 75 | `ErrorSdkPinnedBelowProtocolVersion` | DashPay private contact requests | Proposed. The SDK refused a request without sending it because the SDK itself is pinned below the protocol version the request needs (`dash_sdk::Error::ProtocolVersionNotActive` with `pinned`), whatever the network runs: for example every contact request's private store read with the SDK pinned below protocol version 15 on a network at 15. A network upgrade does not lift it; only raising or removing the pin does. Before, it shared 70 with a network proven below the version, which lifts by itself. Mapped by the blanket conversion. Swift types it (`PlatformWalletError.sdkPinnedBelowProtocolVersion`, `ErrorHandlingTests` pin) and SwiftExampleApp shows a developer-facing message; Kotlin typing is added by the Kotlin PR (#5813) | diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/AddContactView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/AddContactView.swift index 95d752558d7..cd3dc75471a 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/AddContactView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/AddContactView.swift @@ -82,7 +82,7 @@ struct AddContactView: View { @State private var shieldedStoreFailure: String? /// The automatic refreshes made since the wallet's private payments were - /// last read restored (`PrivatePaymentsRecovery.rechecks`). + /// last read restored (`PrivatePaymentsRecovery.recheckDelay`). @State private var readinessRechecks = 0 /// A private sync the footer started is running. @@ -181,9 +181,15 @@ struct AddContactView: View { // observes. .task(id: readinessRechecks) { guard readinessRechecks > 0 else { return } - try? await Task.sleep(for: PrivatePaymentsRecovery.recheckInterval) - guard !Task.isCancelled else { return } - reloadRecipientPending() + let due = await PrivatePaymentsRecovery.waitForRecheck( + after: recipientPending.readiness, attempt: readinessRechecks - 1, + sleep: { try await Task.sleep(for: $0) }) + if due { reloadRecipientPending() } + } + // A deferred restoration runs once the shielded scan holding the store + // ends: refresh then too, ahead of the timer. + .onChange(of: shieldedService.isSyncing) { _, syncing in + if !syncing { reloadRecipientPending() } } .onChange(of: shieldedService.isBound) { _, _ in reloadRecipientPending() } .navigationBarTitleDisplayMode(.inline) @@ -488,7 +494,7 @@ struct AddContactView: View { /// the wallet is asked to retry a restoration first when its payments are /// not restored, and the readiness shown is read after that), then /// whether it holds the pair with the resolved recipient for a private - /// request, and refresh again on a bounded timer while the payments stay + /// request, and keep refreshing on a timer while the payments stay /// unrestored. private func reloadRecipientPending() { let contactIds = resolvedRecipient.map { [$0] } ?? [] @@ -506,7 +512,7 @@ struct AddContactView: View { privateRepliesPending(wallet: wallet, identityId: identityId, contactIds: contactIds), for: contactIds ) - if PrivatePaymentsRecovery.rechecks(after: recipientPending.readiness, attempt: readinessRechecks) { + if PrivatePaymentsRecovery.recheckDelay(after: recipientPending.readiness, attempt: readinessRechecks) != nil { readinessRechecks += 1 } else if recipientPending.readiness?.privatePaymentsRestored == true { readinessRechecks = 0 diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ContactRequestOutcomes.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ContactRequestOutcomes.swift index 62241c3ba9f..7a1c3608269 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ContactRequestOutcomes.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ContactRequestOutcomes.swift @@ -239,15 +239,26 @@ struct PrivateRepliesPendingSnapshot: Equatable { /// store did not open, and only then reads the readiness it shows: the /// readiness is read after the attempt, never cached from before it. While /// the payments stay unrestored (a deferred restoration completes later, -/// changing nothing a view observes), the view refreshes again on a bounded -/// timer, so the gate lifts without a bind, a restart or a submitted -/// request. The views change no hold themselves. +/// changing nothing a view observes but the end of the shielded scan that +/// held the store), the view keeps refreshing on a timer for as long as it +/// is shown, and again when a shielded sync pass ends, so the gate lifts +/// without a bind, a restart or a submitted request. The views change no +/// hold themselves. enum PrivatePaymentsRecovery { - /// How many times a view refreshes again on its own while the payments - /// stay unrestored. - static let recheckLimit = 20 - /// How long a view waits before it refreshes again. - static let recheckInterval: Duration = .seconds(3) + /// The cadence of the automatic refreshes while the payments stay + /// unrestored: every `fastRecheckInterval` for the first `fastRechecks` + /// of them (a minute), then every `slowRecheckInterval`, for as long as + /// the view is shown. It never stops on a count: a shielded scan can hold + /// the store for minutes, and the wallet runs a restoration it deferred + /// only once the scan frees it. Each refresh is local and cheap, reads of + /// in-memory wallet state and a repeat `configureShielded` that checks + /// which wallets are still held and queues at most one deferred + /// restoration per wallet, with no network call, so an open-ended poll at + /// this cadence costs little. It stops when the readiness reads restored + /// or the view goes away; a view for another identity starts its own. + static let fastRechecks = 20 + static let fastRecheckInterval: Duration = .seconds(3) + static let slowRecheckInterval: Duration = .seconds(15) /// Whether a refresh asks the wallet to retry before it reads the /// readiness it shows: the store did not open last time, or the last @@ -256,12 +267,31 @@ enum PrivatePaymentsRecovery { storeFailure != nil || readiness?.privatePaymentsRestored == false } - /// Whether a view refreshes again on its own after `attempt` automatic - /// refreshes: the payments are still not restored and the limit is not - /// reached. A failed read (nil) is left to the next refresh the view - /// makes anyway. - static func rechecks(after readiness: PrivateContactReadiness?, attempt: Int) -> Bool { - readiness?.privatePaymentsRestored == false && attempt < recheckLimit + /// The pause before the next automatic refresh after `attempt` of them, + /// or nil when there is none: the readiness reads restored, or no read + /// succeeded yet (left to the refresh the view makes anyway). + static func recheckDelay(after readiness: PrivateContactReadiness?, attempt: Int) -> Duration? { + guard readiness?.privatePaymentsRestored == false else { return nil } + return attempt < fastRechecks ? fastRecheckInterval : slowRecheckInterval + } + + /// Wait for the next automatic refresh and say whether to make it: false + /// at once when none is due (`recheckDelay`), false when the wait is cut + /// short (`sleep` throws, as the view's task does when the view goes + /// away), true otherwise. The views pass `Task.sleep`. + @MainActor + static func waitForRecheck( + after readiness: PrivateContactReadiness?, + attempt: Int, + sleep: (Duration) async throws -> Void + ) async -> Bool { + guard let delay = recheckDelay(after: readiness, attempt: attempt) else { return false } + do { + try await sleep(delay) + } catch { + return false + } + return !Task.isCancelled } /// One refresh. `lastReadiness` is the readiness the view shows now; diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ContactRequestsView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ContactRequestsView.swift index f9ea1ad2d6b..0e667e5d996 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ContactRequestsView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ContactRequestsView.swift @@ -61,7 +61,7 @@ struct ContactRequestsView: View { @State private var shieldedStoreFailure: String? /// The automatic refreshes made since the wallet's private payments were - /// last read restored (`PrivatePaymentsRecovery.rechecks`). + /// last read restored (`PrivatePaymentsRecovery.recheckDelay`). @State private var readinessRechecks = 0 /// What the last private-store refresh has to tell the user, if anything. @@ -212,9 +212,15 @@ struct ContactRequestsView: View { // store completes later and changes nothing this view observes. .task(id: readinessRechecks) { guard readinessRechecks > 0 else { return } - try? await Task.sleep(for: PrivatePaymentsRecovery.recheckInterval) - guard !Task.isCancelled else { return } - reloadPendingReplies() + let due = await PrivatePaymentsRecovery.waitForRecheck( + after: walletPending.readiness, attempt: readinessRechecks - 1, + sleep: { try await Task.sleep(for: $0) }) + if due { reloadPendingReplies() } + } + // A deferred restoration runs once the shielded scan holding the store + // ends: refresh then too, ahead of the timer. + .onChange(of: shieldedService.isSyncing) { _, syncing in + if !syncing { reloadPendingReplies() } } .onChange(of: shieldedService.isBound) { _, _ in reloadPendingReplies() } .onChange(of: rowSignature) { _, _ in @@ -250,8 +256,8 @@ struct ContactRequestsView: View { /// Re-read from the wallet its readiness (`PrivatePaymentsRecovery.refresh`: /// the wallet is asked to retry a restoration first when its payments are /// not restored, and the readiness shown is read after that), then which - /// incoming rows' pairs it holds for a private request, and refresh again - /// on a bounded timer while the payments stay unrestored. + /// incoming rows' pairs it holds for a private request, and keep + /// refreshing on a timer while the payments stay unrestored. private func reloadPendingReplies() { let contactIds = incomingPending.map(\.contactIdentityId) let wallet = try? requireWallet() @@ -268,7 +274,7 @@ struct ContactRequestsView: View { privateRepliesPending(wallet: wallet, identityId: identityId, contactIds: contactIds), for: contactIds ) - if PrivatePaymentsRecovery.rechecks(after: walletPending.readiness, attempt: readinessRechecks) { + if PrivatePaymentsRecovery.recheckDelay(after: walletPending.readiness, attempt: readinessRechecks) != nil { readinessRechecks += 1 } else if walletPending.readiness?.privatePaymentsRestored == true { readinessRechecks = 0 diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/ContactRequestOutcomesTests.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/ContactRequestOutcomesTests.swift index 28ee493919a..a2d140c1387 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/ContactRequestOutcomesTests.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/ContactRequestOutcomesTests.swift @@ -316,7 +316,7 @@ final class ContactRequestOutcomesTests: XCTestCase { XCTAssertNil(refreshed.storeFailure) XCTAssertFalse(snapshot.allowsSubmission(to: contact)) XCTAssertEqual(snapshot.restoreFailure, "the journal could not be read") - XCTAssertTrue(PrivatePaymentsRecovery.rechecks(after: snapshot.readiness, attempt: 0)) + XCTAssertNotNil(PrivatePaymentsRecovery.recheckDelay(after: snapshot.readiness, attempt: 0)) // The failure was transient: the next refresh asks first, the retry // restores, and the readiness shown is the one read after it. @@ -328,7 +328,7 @@ final class ContactRequestOutcomesTests: XCTestCase { XCTAssertFalse(snapshot.isRestoringPrivatePayments) XCTAssertNil(snapshot.restoreFailure) XCTAssertTrue(snapshot.allowsSubmission(to: contact), "the gate lifts on the refresh alone") - XCTAssertFalse(PrivatePaymentsRecovery.rechecks(after: snapshot.readiness, attempt: 1)) + XCTAssertNil(PrivatePaymentsRecovery.recheckDelay(after: snapshot.readiness, attempt: 1)) } /// A refresh asks for the retry before it reads the readiness it shows: @@ -380,12 +380,13 @@ final class ContactRequestOutcomesTests: XCTestCase { XCTAssertNil(refreshed.storeFailure) } - /// A restoration the wallet deferred behind a busy store completes later, - /// changing nothing the view observes: the view refreshes again on its - /// own, bounded, while the payments stay unrestored, and the refresh after - /// the deferred restoration lifts the gate. + /// A restoration the wallet deferred behind a scan that holds the store + /// for minutes: the view keeps refreshing past the first minute (every + /// 3 s for 20 refreshes, then every 15 s, with no last one), and the + /// refresh after the deferred restoration completes re-enables submission + /// and schedules no more. @MainActor - func testShouldRefreshAgainWhileARestorationIsDeferred() { + func testShouldKeepRefreshingWhileARestorationStaysDeferred() async { let contact = Data(repeating: 1, count: 32) var deferredDone = false var snapshot = PrivateRepliesPendingSnapshot() @@ -393,25 +394,45 @@ final class ContactRequestOutcomesTests: XCTestCase { let read = { () -> (readiness: PrivateContactReadiness?, restoreFailure: String?) in (self.readiness(restored: deferredDone), nil) } - var refreshed = PrivatePaymentsRecovery.refresh( - lastReadiness: snapshot.readiness, storeFailure: nil, readReadiness: read, - configureAgain: { nil }) - snapshot.recordReadiness(refreshed.readiness, restoreFailure: refreshed.restoreFailure) - XCTAssertFalse(snapshot.allowsSubmission(to: contact)) - XCTAssertTrue(PrivatePaymentsRecovery.rechecks(after: snapshot.readiness, attempt: 0)) - XCTAssertFalse( - PrivatePaymentsRecovery.rechecks( - after: snapshot.readiness, attempt: PrivatePaymentsRecovery.recheckLimit), - "bounded") - XCTAssertFalse(PrivatePaymentsRecovery.rechecks(after: nil, attempt: 0)) - - deferredDone = true - refreshed = PrivatePaymentsRecovery.refresh( - lastReadiness: snapshot.readiness, storeFailure: nil, readReadiness: read, - configureAgain: { nil }) - snapshot.recordReadiness(refreshed.readiness, restoreFailure: refreshed.restoreFailure) + var pauses: [Duration] = [] + var refreshes = 0 + // Well past the first minute: 200 automatic refreshes, about 46 minutes. + for attempt in 0..<200 { + let refreshed = PrivatePaymentsRecovery.refresh( + lastReadiness: snapshot.readiness, storeFailure: nil, readReadiness: read, + configureAgain: { nil }) + snapshot.recordReadiness(refreshed.readiness, restoreFailure: refreshed.restoreFailure) + refreshes += 1 + if attempt == 150 { deferredDone = true } + let due = await PrivatePaymentsRecovery.waitForRecheck( + after: snapshot.readiness, attempt: attempt, sleep: { pauses.append($0) }) + if !due { break } + } + XCTAssertEqual(refreshes, 152, "it kept refreshing until the restoration completed") XCTAssertTrue(snapshot.allowsSubmission(to: contact)) - XCTAssertFalse(PrivatePaymentsRecovery.rechecks(after: snapshot.readiness, attempt: 1)) + XCTAssertEqual(pauses.count, 151, "no pause once the payments read restored") + XCTAssertEqual(Array(pauses.prefix(20)), Array(repeating: .seconds(3), count: 20)) + XCTAssertEqual(Set(pauses.dropFirst(20)), [.seconds(15)]) + } + + /// The timer stops when the payments read restored, before any read + /// succeeded, and when the view goes away (its task's sleep is cut + /// short), however many refreshes came before. + @MainActor + func testShouldStopRefreshingWhenRestoredOrWhenTheViewGoesAway() async { + var slept = false + let restored = await PrivatePaymentsRecovery.waitForRecheck( + after: readiness(restored: true), attempt: 0, sleep: { _ in slept = true }) + XCTAssertFalse(restored) + XCTAssertFalse(slept, "nothing is due once restored") + let unread = await PrivatePaymentsRecovery.waitForRecheck( + after: nil, attempt: 0, sleep: { _ in slept = true }) + XCTAssertFalse(unread) + XCTAssertFalse(slept) + let dismissed = await PrivatePaymentsRecovery.waitForRecheck( + after: readiness(restored: false), attempt: 500, + sleep: { _ in throw CancellationError() }) + XCTAssertFalse(dismissed, "a view that went away refreshes no more") } func testShouldAcceptACollisionPrivatelyWhenSendPrivatelyIsOn() { From 2003e15252a7b3026c40966db1f9c5440bcb6cda Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Tue, 10 Nov 2026 21:27:21 +0700 Subject: [PATCH 255/269] fix(kotlin-example-app): keep refreshing while a deferred restoration waits, with no last refresh Port of the SwiftExampleApp fix. - Requests and Add Contact stopped refreshing after 20 automatic refreshes (a minute). A restoration the wallet deferred behind a scan that held the store for longer was never seen. - While the payments stay unrestored and the screen is shown, each now refreshes every 3 s for the first minute, then every 15 s, with no limit. PrivatePaymentsRecovery.recheckDelayMs sets the pace, and PrivatePaymentsRecovery.waitForRecheck is the wait both share. - The wait ends when the readiness reads restored, before any read succeeded, or when the screen leaves the composition. - Both screens also refresh when a shielded sync pass ends. - Tests: shouldKeepRefreshingWhileARestorationStaysDeferred and shouldStopRefreshingWhenRestoredOrWhenTheScreenGoesAway replace shouldRefreshAgainWhileARestorationIsDeferred. Also, in rs-unified-sdk-jni's wallet_manager.rs, nativeCreate and nativeEffectivePersistenceCapabilityBits now build the bridge's persistence through one helper (bridge_persistence): the declaration, the vtable, the account-writer restriction and the extension. The capability check therefore cannot drift from what a manager is created with. Co-Authored-By: Claude Opus 5.5 --- .../example/ui/dashpay/AddContactScreen.kt | 24 ++- .../ui/dashpay/ContactRequestOutcomes.kt | 66 ++++-- .../ui/dashpay/ContactRequestsScreen.kt | 23 +- .../ui/dashpay/ContactRequestOutcomesTest.kt | 71 ++++-- .../rs-unified-sdk-jni/src/wallet_manager.rs | 204 +++++++++--------- 5 files changed, 235 insertions(+), 153 deletions(-) diff --git a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/AddContactScreen.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/AddContactScreen.kt index 52761567570..a168d7e4ad5 100644 --- a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/AddContactScreen.kt +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/AddContactScreen.kt @@ -39,6 +39,8 @@ import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.NavHostController import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch import org.dashfoundation.example.di.LocalAppContainer import org.dashfoundation.example.di.LocalAppState @@ -117,7 +119,7 @@ fun AddContactScreen(identityIdHex: String, navController: NavHostController) { var isSyncingPrivate by remember { mutableStateOf(false) } var pendingReadVersion by remember { mutableStateOf(0) } // The automatic refreshes made since the wallet's private payments were - // last read restored (`PrivatePaymentsRecovery.rechecks`). + // last read restored (`PrivatePaymentsRecovery.recheckDelayMs`). var readinessRechecks by remember { mutableStateOf(0) } var collisionRecipient by remember { mutableStateOf(null) } var previewProfile by remember { mutableStateOf(null) } @@ -211,12 +213,12 @@ fun AddContactScreen(identityIdHex: String, navController: NavHostController) { recipientPending = recipientPending .withReadiness(refreshed.read?.readiness, refreshed.read?.restoreFailure) .recorded(pendingRead, contactIds.map { it.toHex() }) - // While the payments stay unrestored, refresh again after a pause: a - // restoration the wallet deferred behind a busy store completes later - // and changes no key of this effect (Add Contact observes neither a - // bind nor a DashPay sync for it). - if (PrivatePaymentsRecovery.rechecks(recipientPending.readiness, readinessRechecks)) { - delay(PrivatePaymentsRecovery.RECHECK_INTERVAL_MS) + // While the payments stay unrestored, refresh again after a pause, for + // as long as the screen is shown: a restoration the wallet deferred + // behind a busy store completes later and changes no key of this + // effect (Add Contact observes neither a bind nor a DashPay sync for + // it). + if (PrivatePaymentsRecovery.waitForRecheck(recipientPending.readiness, readinessRechecks)) { readinessRechecks++ pendingReadVersion++ } else if (recipientPending.readiness?.privatePaymentsRestored == true) { @@ -228,6 +230,14 @@ fun AddContactScreen(identityIdHex: String, navController: NavHostController) { LaunchedEffect(container.shieldedService) { container.shieldedService.isBound.collect { pendingReadVersion++ } } + // A deferred restoration runs once the shielded scan holding the store + // ends: refresh then too, ahead of the timer. + LaunchedEffect(container.shieldedService) { + container.shieldedService.state + .map { it.isSyncing } + .distinctUntilChanged() + .collect { syncing -> if (!syncing) pendingReadVersion++ } + } LaunchedEffect(recipientHex) { val recipient = resolvedRecipient val w = wallet diff --git a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/ContactRequestOutcomes.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/ContactRequestOutcomes.kt index 24d6ee39e18..4e15286167f 100644 --- a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/ContactRequestOutcomes.kt +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/ContactRequestOutcomes.kt @@ -1,5 +1,6 @@ package org.dashfoundation.example.ui.dashpay +import kotlinx.coroutines.delay import org.dashfoundation.dashsdk.errors.DashSdkError import org.dashfoundation.dashsdk.tokens.Dashpay @@ -246,18 +247,37 @@ data class PrivateRepliesPendingSnapshot( * it whenever the payments are not restored, or the store did not open, and * only then reads the readiness it shows: the readiness is read after the * attempt, never cached from before it. While the payments stay unrestored (a - * deferred restoration completes later, changing nothing a screen observes), - * the screen refreshes again on a bounded timer, so the gate lifts without a - * bind, a restart or a submitted request. The screens change no hold - * themselves. + * deferred restoration completes later, changing nothing a screen observes but + * the end of the shielded scan that held the store), the screen keeps + * refreshing on a timer for as long as it is shown, and again when a shielded + * sync pass ends, so the gate lifts without a bind, a restart or a submitted + * request. The screens change no hold themselves. * ← Swift `PrivatePaymentsRecovery` (`ContactRequestOutcomes.swift`). */ object PrivatePaymentsRecovery { - /** How many times a screen refreshes again on its own while the payments stay unrestored. */ - const val RECHECK_LIMIT = 20 + /* + * The cadence of the automatic refreshes while the payments stay + * unrestored: every FAST_RECHECK_INTERVAL_MS for the first FAST_RECHECKS + * of them (a minute), then every SLOW_RECHECK_INTERVAL_MS, for as long as + * the screen is shown. It never stops on a count: a shielded scan can hold + * the store for minutes, and the wallet runs a restoration it deferred + * only once the scan frees it. Each refresh is local and cheap, reads of + * in-memory wallet state and a repeat `configureShielded` that checks + * which wallets are still held and queues at most one deferred + * restoration per wallet, with no network call, so an open-ended poll at + * this cadence costs little. It stops when the readiness reads restored + * or the screen leaves the composition; a screen for another identity + * starts its own. + */ + + /** The automatic refreshes made at the fast cadence. */ + const val FAST_RECHECKS = 20 - /** How long a screen waits before it refreshes again. */ - const val RECHECK_INTERVAL_MS = 3_000L + /** The pause between automatic refreshes for the first minute. */ + const val FAST_RECHECK_INTERVAL_MS = 3_000L + + /** The pause between automatic refreshes after the first minute. */ + const val SLOW_RECHECK_INTERVAL_MS = 15_000L /** * Whether a refresh asks the wallet to retry before it reads the @@ -268,13 +288,31 @@ object PrivatePaymentsRecovery { storeFailure != null || readiness?.privatePaymentsRestored == false /** - * Whether a screen refreshes again on its own after [attempt] automatic - * refreshes: the payments are still not restored and the limit is not - * reached. A failed read (null) is left to the next refresh the screen - * makes anyway. + * The pause before the next automatic refresh after [attempt] of them, or + * null when there is none: the readiness reads restored, or no read + * succeeded yet (left to the refresh the screen makes anyway). + */ + fun recheckDelayMs(after: Dashpay.PrivateContactReadiness?, attempt: Int): Long? = when { + after?.privatePaymentsRestored != false -> null + attempt < FAST_RECHECKS -> FAST_RECHECK_INTERVAL_MS + else -> SLOW_RECHECK_INTERVAL_MS + } + + /** + * Wait for the next automatic refresh and say whether to make it: false at + * once when none is due ([recheckDelayMs]), true after the pause. The + * screens pass `delay`; a screen leaving the composition cancels the wait, + * and the cancellation ends its effect. */ - fun rechecks(after: Dashpay.PrivateContactReadiness?, attempt: Int): Boolean = - after?.privatePaymentsRestored == false && attempt < RECHECK_LIMIT + suspend fun waitForRecheck( + after: Dashpay.PrivateContactReadiness?, + attempt: Int, + sleep: suspend (Long) -> Unit = { delay(it) }, + ): Boolean { + val pause = recheckDelayMs(after, attempt) ?: return false + sleep(pause) + return true + } /** A refresh's readiness read, null when it failed, and why the store did not open, or null. */ data class Refreshed(val read: PrivateContactReadinessRead?, val storeFailure: String?) diff --git a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/ContactRequestsScreen.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/ContactRequestsScreen.kt index 1318008d419..82adfb72496 100644 --- a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/ContactRequestsScreen.kt +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/ContactRequestsScreen.kt @@ -38,7 +38,8 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.NavHostController -import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch import org.dashfoundation.example.di.LocalAppContainer import org.dashfoundation.example.di.LocalAppState @@ -106,7 +107,7 @@ fun ContactRequestsScreen(identityIdHex: String, navController: NavHostControlle var shieldedStoreFailure by remember { mutableStateOf(null) } var pendingReadVersion by remember { mutableStateOf(0) } // The automatic refreshes made since the wallet's private payments were - // last read restored (`PrivatePaymentsRecovery.rechecks`). + // last read restored (`PrivatePaymentsRecovery.recheckDelayMs`). var readinessRechecks by remember { mutableStateOf(0) } // What the last private-store refresh has to tell the user, if anything. var privateRefreshNotice by remember { mutableStateOf(null) } @@ -184,11 +185,11 @@ fun ContactRequestsScreen(identityIdHex: String, navController: NavHostControlle walletPending = walletPending .withReadiness(refreshed.read?.readiness, refreshed.read?.restoreFailure) .recorded(read, contactIds.map { it.toHex() }) - // While the payments stay unrestored, refresh again after a pause: a - // restoration the wallet deferred behind a busy store completes later - // and changes no key of this effect. - if (PrivatePaymentsRecovery.rechecks(walletPending.readiness, readinessRechecks)) { - delay(PrivatePaymentsRecovery.RECHECK_INTERVAL_MS) + // While the payments stay unrestored, refresh again after a pause, for + // as long as the screen is shown: a restoration the wallet deferred + // behind a busy store completes later and changes no key of this + // effect. + if (PrivatePaymentsRecovery.waitForRecheck(walletPending.readiness, readinessRechecks)) { readinessRechecks++ pendingReadVersion++ } else if (walletPending.readiness?.privatePaymentsRestored == true) { @@ -200,6 +201,14 @@ fun ContactRequestsScreen(identityIdHex: String, navController: NavHostControlle LaunchedEffect(container.shieldedService) { container.shieldedService.isBound.collect { pendingReadVersion++ } } + // A deferred restoration runs once the shielded scan holding the store + // ends: refresh then too, ahead of the timer. + LaunchedEffect(container.shieldedService) { + container.shieldedService.state + .map { it.isSyncing } + .distinctUntilChanged() + .collect { syncing -> if (!syncing) pendingReadVersion++ } + } LaunchedEffect(manager) { manager?.dashPaySyncIsSyncing?.collect { syncing -> if (!syncing) pendingReadVersion++ } } diff --git a/packages/kotlin-sdk/KotlinExampleApp/app/src/test/java/org/dashfoundation/example/ui/dashpay/ContactRequestOutcomesTest.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/test/java/org/dashfoundation/example/ui/dashpay/ContactRequestOutcomesTest.kt index 62381131fec..eab86e6d5b7 100644 --- a/packages/kotlin-sdk/KotlinExampleApp/app/src/test/java/org/dashfoundation/example/ui/dashpay/ContactRequestOutcomesTest.kt +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/test/java/org/dashfoundation/example/ui/dashpay/ContactRequestOutcomesTest.kt @@ -1,11 +1,13 @@ package org.dashfoundation.example.ui.dashpay +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.test.runTest import org.dashfoundation.dashsdk.errors.DashSdkError import org.dashfoundation.dashsdk.tokens.Dashpay import org.dashfoundation.example.ui.wallet.shieldedOperationMayHaveGoneThrough import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Test @@ -228,7 +230,7 @@ class ContactRequestOutcomesTest { assertNull(refreshed.storeFailure) assertFalse(snapshot.allowsSubmission("01")) assertEquals("the journal could not be read", snapshot.restoreFailure) - assertTrue(PrivatePaymentsRecovery.rechecks(snapshot.readiness, 0)) + assertNotNull(PrivatePaymentsRecovery.recheckDelayMs(snapshot.readiness, 0)) // The failure was transient: the next refresh asks first, the retry // restores, and the readiness shown is the one read after it. @@ -242,7 +244,7 @@ class ContactRequestOutcomesTest { assertFalse(snapshot.isRestoringPrivatePayments) assertNull(snapshot.restoreFailure) assertTrue("the gate lifts on the refresh alone", snapshot.allowsSubmission("01")) - assertFalse(PrivatePaymentsRecovery.rechecks(snapshot.readiness, 1)) + assertNull(PrivatePaymentsRecovery.recheckDelayMs(snapshot.readiness, 1)) } /** @@ -320,33 +322,60 @@ class ContactRequestOutcomesTest { } /** - * A restoration the wallet deferred behind a busy store completes later, - * changing no key of the screen's effect: the screen refreshes again on - * its own, bounded, while the payments stay unrestored, and the refresh - * after the deferred restoration lifts the gate. + * A restoration the wallet deferred behind a scan that holds the store for + * minutes: the screen keeps refreshing past the first minute (every 3 s + * for 20 refreshes, then every 15 s, with no last one), and the refresh + * after the deferred restoration completes re-enables submission and + * schedules no more. */ @Test - fun shouldRefreshAgainWhileARestorationIsDeferred() = runTest { + fun shouldKeepRefreshingWhileARestorationStaysDeferred() = runTest { var deferredDone = false val read: suspend () -> PrivateContactReadinessRead? = { PrivateContactReadinessRead(readiness(restored = deferredDone, storeRead = true), null) } var snapshot = PrivateRepliesPendingSnapshot().recorded(emptySet(), listOf("01")) - var refreshed = PrivatePaymentsRecovery.refresh(snapshot.readiness, null, read) { null } - snapshot = snapshot.withReadiness(refreshed.read?.readiness, refreshed.read?.restoreFailure) - assertFalse(snapshot.allowsSubmission("01")) - assertTrue(PrivatePaymentsRecovery.rechecks(snapshot.readiness, 0)) - assertFalse( - "bounded", - PrivatePaymentsRecovery.rechecks(snapshot.readiness, PrivatePaymentsRecovery.RECHECK_LIMIT), - ) - assertFalse(PrivatePaymentsRecovery.rechecks(null, 0)) - - deferredDone = true - refreshed = PrivatePaymentsRecovery.refresh(snapshot.readiness, null, read) { null } - snapshot = snapshot.withReadiness(refreshed.read?.readiness, refreshed.read?.restoreFailure) + val pauses = mutableListOf() + var refreshes = 0 + // Well past the first minute: 200 automatic refreshes, about 46 minutes. + for (attempt in 0 until 200) { + val refreshed = PrivatePaymentsRecovery.refresh(snapshot.readiness, null, read) { null } + snapshot = snapshot.withReadiness(refreshed.read?.readiness, refreshed.read?.restoreFailure) + refreshes++ + if (attempt == 150) deferredDone = true + if (!PrivatePaymentsRecovery.waitForRecheck(snapshot.readiness, attempt) { pauses += it }) break + } + assertEquals("it kept refreshing until the restoration completed", 152, refreshes) assertTrue(snapshot.allowsSubmission("01")) - assertFalse(PrivatePaymentsRecovery.rechecks(snapshot.readiness, 1)) + assertEquals("no pause once the payments read restored", 151, pauses.size) + assertEquals(List(20) { PrivatePaymentsRecovery.FAST_RECHECK_INTERVAL_MS }, pauses.take(20)) + assertEquals(setOf(PrivatePaymentsRecovery.SLOW_RECHECK_INTERVAL_MS), pauses.drop(20).toSet()) + } + + /** + * The timer stops when the payments read restored, before any read + * succeeded, and when the screen leaves the composition (its wait is + * cancelled), however many refreshes came before. + */ + @Test + fun shouldStopRefreshingWhenRestoredOrWhenTheScreenGoesAway() = runTest { + var slept = false + assertFalse(PrivatePaymentsRecovery.waitForRecheck(readiness(restored = true, storeRead = true), 0) { slept = true }) + assertFalse("nothing is due once restored", slept) + assertFalse(PrivatePaymentsRecovery.waitForRecheck(null, 0) { slept = true }) + assertFalse(slept) + var refreshedAfter = false + try { + if (PrivatePaymentsRecovery.waitForRecheck(readiness(restored = false, storeRead = true), 500) { + throw CancellationException("the screen left the composition") + } + ) { + refreshedAfter = true + } + } catch (e: CancellationException) { + // The screen's effect ends with its cancelled wait. + } + assertFalse("a screen that went away refreshes no more", refreshedAfter) } private fun readiness( diff --git a/packages/rs-unified-sdk-jni/src/wallet_manager.rs b/packages/rs-unified-sdk-jni/src/wallet_manager.rs index 7fccc2d99e6..b077777228c 100644 --- a/packages/rs-unified-sdk-jni/src/wallet_manager.rs +++ b/packages/rs-unified-sdk-jni/src/wallet_manager.rs @@ -57,7 +57,9 @@ use platform_wallet_ffi::error::{ use platform_wallet_ffi::event_handler::{EventHandlerCallbacks, EventHandlerCallbacksExtension}; use platform_wallet_ffi::handle::Handle; use platform_wallet_ffi::manager::effective_persistence_capabilities; -use platform_wallet_ffi::persistence::{PersistenceCallbacks, PersistenceCapabilitiesFFI}; +use platform_wallet_ffi::persistence::{ + PersistenceCallbacks, PersistenceCallbacksExtension, PersistenceCapabilitiesFFI, +}; use platform_wallet_ffi::types::IdentifierArray; use std::ffi::{c_void, CStr}; use std::os::raw::c_char; @@ -110,60 +112,14 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_WalletManagerNative_n return 0; } - // Read the backend's explicit semantic attestation before installing - // any trampolines. NativePersistenceBridge defaults both methods to - // zero, so a no-op subclass remains fail-closed even though JNI wires a - // structurally complete callback table. - let declared_capabilities_version = match env - .call_method( - &persistence_bridge, - "persistenceCapabilitiesVersion", - "()I", - &[], - ) - .and_then(|value| value.i()) - { - Ok(value) => value as u32, - Err(_) => { - let _ = env.exception_clear(); - throw_sdk_exception(env, 99, "reading persistence capability version failed"); - return 0; - } - }; - let declared_capabilities_bits = match env - .call_method( - &persistence_bridge, - "persistenceCapabilitiesBits", - "()J", - &[], - ) - .and_then(|value| value.j()) - { - Ok(value) => value as u64, - Err(_) => { - let _ = env.exception_clear(); - throw_sdk_exception(env, 99, "reading persistence capability mask failed"); - return 0; - } - }; - - // Box the persistence context (GlobalRef → boxed KotlinPersistenceCtx). - let persistence_global = match env.new_global_ref(&persistence_bridge) { - Ok(g) => g, - Err(_) => { - throw_sdk_exception(env, 99, "NewGlobalRef(persistence bridge) failed"); - return 0; - } - }; - let persistence_ctx = - Box::into_raw(Box::new(KotlinPersistenceCtx::new(persistence_global))); - let mut persistence: PersistenceCallbacks = build_vtable(persistence_ctx as *mut c_void); - restrict_account_writers(env, &persistence_bridge, &mut persistence); - let persistence_extension = build_extension(env, &persistence_bridge); - let persistence_capabilities = PersistenceCapabilitiesFFI { - version: declared_capabilities_version, - reserved: 0, - bits: declared_capabilities_bits, + let Some(BridgePersistence { + context: persistence_ctx, + callbacks: persistence, + extension: persistence_extension, + declared: persistence_capabilities, + }) = bridge_persistence(env, &persistence_bridge) + else { + return 0; }; // Box the event context. @@ -226,62 +182,102 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_WalletManagerNative_n persistence_bridge: JObject, ) -> jlong { guard(&mut env, 0, |env| { - let version = match env - .call_method( - &persistence_bridge, - "persistenceCapabilitiesVersion", - "()I", - &[], - ) - .and_then(|value| value.i()) - { - Ok(value) => value as u32, - Err(_) => { - let _ = env.exception_clear(); - throw_sdk_exception(env, 99, "reading persistence capability version failed"); - return 0; - } - }; - let bits = match env - .call_method( - &persistence_bridge, - "persistenceCapabilitiesBits", - "()J", - &[], - ) - .and_then(|value| value.j()) - { - Ok(value) => value as u64, - Err(_) => { - let _ = env.exception_clear(); - throw_sdk_exception(env, 99, "reading persistence capability mask failed"); - return 0; - } - }; - let global = match env.new_global_ref(&persistence_bridge) { - Ok(global) => global, - Err(_) => { - throw_sdk_exception(env, 99, "NewGlobalRef(persistence bridge) failed"); - return 0; - } - }; - let context = Box::into_raw(Box::new(KotlinPersistenceCtx::new(global))); - let mut persistence: PersistenceCallbacks = build_vtable(context as *mut c_void); - restrict_account_writers(env, &persistence_bridge, &mut persistence); - let extension = build_extension(env, &persistence_bridge); - let declared = PersistenceCapabilitiesFFI { - version, - reserved: 0, - bits, + let Some(BridgePersistence { + callbacks, + extension, + declared, + .. + }) = bridge_persistence(env, &persistence_bridge) + else { + return 0; }; // SAFETY: `extension` is a live, fully sized extension; the vtable's - // `release_fn` frees `context` before this returns. + // `release_fn` frees the context before this returns. let effective = - unsafe { effective_persistence_capabilities(persistence, &declared, &extension) }; + unsafe { effective_persistence_capabilities(callbacks, &declared, &extension) }; effective.bits() as jlong }) } +/// The persistence a manager is created with from a Kotlin +/// `NativePersistenceBridge`: the bridge's declared capabilities, a vtable +/// over a boxed context holding a global reference to it (with the account +/// writers it does not override removed), and the extension its overrides +/// wire. `nativeCreate` creates its manager from it and +/// `nativeEffectivePersistenceCapabilityBits` asks what that manager would +/// attest, so the two cannot drift apart. +struct BridgePersistence { + /// The boxed context the vtable points at. Its `release_fn` frees it + /// once a manager (or `effective_persistence_capabilities`) takes the + /// vtable; a caller that hands it to neither frees it. + context: *mut KotlinPersistenceCtx, + callbacks: PersistenceCallbacks, + extension: PersistenceCallbacksExtension, + declared: PersistenceCapabilitiesFFI, +} + +/// Build [`BridgePersistence`] for `persistence_bridge`. The backend's +/// explicit semantic attestation is read before any trampoline is installed: +/// `NativePersistenceBridge` defaults both methods to zero, so a no-op +/// subclass stays fail-closed even though JNI wires a structurally complete +/// callback table. Throws and returns `None` when the declaration cannot be +/// read or the global reference made. +fn bridge_persistence(env: &mut JNIEnv, persistence_bridge: &JObject) -> Option { + let version = match env + .call_method( + persistence_bridge, + "persistenceCapabilitiesVersion", + "()I", + &[], + ) + .and_then(|value| value.i()) + { + Ok(value) => value as u32, + Err(_) => { + let _ = env.exception_clear(); + throw_sdk_exception(env, 99, "reading persistence capability version failed"); + return None; + } + }; + let bits = match env + .call_method( + persistence_bridge, + "persistenceCapabilitiesBits", + "()J", + &[], + ) + .and_then(|value| value.j()) + { + Ok(value) => value as u64, + Err(_) => { + let _ = env.exception_clear(); + throw_sdk_exception(env, 99, "reading persistence capability mask failed"); + return None; + } + }; + let global = match env.new_global_ref(persistence_bridge) { + Ok(global) => global, + Err(_) => { + throw_sdk_exception(env, 99, "NewGlobalRef(persistence bridge) failed"); + return None; + } + }; + let context = Box::into_raw(Box::new(KotlinPersistenceCtx::new(global))); + let mut callbacks: PersistenceCallbacks = build_vtable(context as *mut c_void); + restrict_account_writers(env, persistence_bridge, &mut callbacks); + let extension = build_extension(env, persistence_bridge); + Some(BridgePersistence { + context, + callbacks, + extension, + declared: PersistenceCapabilitiesFFI { + version, + reserved: 0, + bits, + }, + }) +} + /// The raw manager `Handle` for a bundle, as jlong (for the FFI sync / /// wallet-accessor calls). 0 if the bundle pointer is 0. #[no_mangle] From 80e945f857a1c15f0c26799e8afd01f28d27f840 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Tue, 10 Nov 2026 21:29:21 +0700 Subject: [PATCH 256/269] fix(swift-sdk): read a kept scan state record under its stored network when the handler has none A handler configured with no network, as configure(sdkPointer: modelContainer:network:) makes by default, guessed testnet in two places: the wallet list, and the per-wallet load once the wallet's row was gone. A marked position record kept for a removed mainnet or devnet wallet is stored under its real network, so both reads came back empty. Rust then got no retained marker and could restore the identity without its hold. - The wallet list and the per-wallet load filter on the handler's network when it has one, else on the wallet row's. - When neither is known, they read the wallet's rows under every stored network instead of guessing one, so a kept record never reads as absent. - A scan state write with no network known refuses, rather than filing the state under a guessed network. - Tests: - testShouldHandBackAKeptMainnetRecordToAHandlerWithNoNetwork: a nil-network handler, a mainnet wallet's marked record, and the wallet's removal; the record comes back through both reads and their C shims, and a testnet-scoped handler still does not see it. - testShouldRefuseToStoreScanStateWithNoNetworkToStoreItUnder. Co-Authored-By: Claude Opus 5.5 --- .../PlatformWalletPersistenceHandler.swift | 50 ++++++++++---- ...vateContactScanStatePersistenceTests.swift | 68 +++++++++++++++++++ 2 files changed, 105 insertions(+), 13 deletions(-) diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift index 40fc2e9aab3..9f7f44b0cc5 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift @@ -5195,9 +5195,15 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { } /// The network the scan state of `walletId` is stored under, as for the - /// identity rows it belongs with. - private func scanStateNetworkRaw(walletId: Data) -> UInt32 { - (self.network ?? walletNetwork(walletId: walletId) ?? .testnet).rawValue + /// identity rows it belongs with: this handler's, else the wallet row's. + /// Nil when neither is known (a handler configured with no network, and + /// the wallet's row already gone). A write then refuses rather than file + /// the state under a guessed network, and a read takes the wallet's rows + /// under every stored network: a position record kept marked for a + /// removed wallet carries the network it was stored under, and guessing + /// one would read a kept record as absent. + private func scanStateNetworkRaw(walletId: Data) -> UInt32? { + (self.network ?? walletNetwork(walletId: walletId))?.rawValue } /// Apply a round's scan state. Identities the round removed lose theirs @@ -5214,7 +5220,10 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { throw PlatformWalletError.walletOperation( "Private contact request scan state requires a changeset") } - let networkRaw = scanStateNetworkRaw(walletId: walletId) + guard let networkRaw = scanStateNetworkRaw(walletId: walletId) else { + throw PlatformWalletError.walletOperation( + "No network to store the private contact request scan state under") + } for identityId in clearedIdentities { for row in try backgroundContext.fetch(FetchDescriptor( predicate: #Predicate { @@ -5281,12 +5290,18 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { /// request scan state for, a removed wallet whose marked position /// records outlived it included. Rust reads each one its wallet-list /// load does not return and hands its marked records to the wallet as - /// retained. Throws when the store cannot be read. + /// retained. A handler configured with no network lists the wallets of + /// every stored network rather than guess one: a kept record carries the + /// network it was stored under. Throws when the store cannot be read. func loadPrivateContactScanStateWalletIds() throws -> [Data] { try onQueue { - let networkRaw = (self.network ?? .testnet).rawValue - let positions = try backgroundContext.fetch(FetchDescriptor( - predicate: #Predicate { $0.networkRaw == networkRaw })) + let positions: [PersistentPrivateContactRequestsPosition] + if let networkRaw = self.network?.rawValue { + positions = try backgroundContext.fetch(FetchDescriptor( + predicate: #Predicate { $0.networkRaw == networkRaw })) + } else { + positions = try backgroundContext.fetch(FetchDescriptor()) + } var seen = Set() return positions.compactMap { seen.insert($0.walletId).inserted ? $0.walletId : nil } } @@ -5297,11 +5312,20 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { func loadPrivateContactScanState(walletId: Data) throws -> (positions: [PrivateContactRequestsPositionRow], addresses: [PrivateShieldedAddressRow]) { try onQueue { - let networkRaw = scanStateNetworkRaw(walletId: walletId) - let positions = try backgroundContext.fetch(FetchDescriptor( - predicate: #Predicate { $0.networkRaw == networkRaw && $0.walletId == walletId })) - let addresses = try backgroundContext.fetch(FetchDescriptor( - predicate: #Predicate { $0.networkRaw == networkRaw && $0.walletId == walletId })) + let positions: [PersistentPrivateContactRequestsPosition] + let addresses: [PersistentPrivateShieldedAddress] + if let networkRaw = scanStateNetworkRaw(walletId: walletId) { + positions = try backgroundContext.fetch(FetchDescriptor( + predicate: #Predicate { $0.networkRaw == networkRaw && $0.walletId == walletId })) + addresses = try backgroundContext.fetch(FetchDescriptor( + predicate: #Predicate { $0.networkRaw == networkRaw && $0.walletId == walletId })) + } else { + // No network known: every stored scope, not a guessed one. + positions = try backgroundContext.fetch(FetchDescriptor( + predicate: #Predicate { $0.walletId == walletId })) + addresses = try backgroundContext.fetch(FetchDescriptor( + predicate: #Predicate { $0.walletId == walletId })) + } return ( positions.map { PrivateContactRequestsPositionRow( diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/PrivateContactScanStatePersistenceTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/PrivateContactScanStatePersistenceTests.swift index e90dac0b75a..42f439a0bb6 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/PrivateContactScanStatePersistenceTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/PrivateContactScanStatePersistenceTests.swift @@ -166,6 +166,74 @@ final class PrivateContactScanStatePersistenceTests: XCTestCase { XCTAssertTrue(try handler.loadPrivateContactScanStateWalletIds().isEmpty) } + /// A handler configured with no network, as `configure(sdkPointer: + /// modelContainer:network:)` makes by default: a marked record of a + /// mainnet wallet, stored under mainnet, still reads back after the + /// wallet's removal, through the wallet list and the per-wallet load and + /// through their C shims, rather than reading as absent under a guessed + /// testnet scope. A testnet-scoped handler still does not see it. + func testShouldHandBackAKeptMainnetRecordToAHandlerWithNoNetwork() throws { + let container = try DashModelContainer.createInMemory() + let context = ModelContext(container) + context.insert(PersistentWallet(walletId: walletId, network: .mainnet)) + try context.save() + let handler = PlatformWalletPersistenceHandler(modelContainer: container) + let fingerprint = Data(repeating: 3, count: 32) + let marked = Position( + identityId: owner, position: 12, keyFingerprint: fingerprint, privatePaymentsUnresolved: true) + try round(handler, positions: [marked]) + XCTAssertEqual( + try ModelContext(container).fetch(FetchDescriptor()) + .map(\.networkRaw), + [Network.mainnet.rawValue], + "stored under the wallet's own network") + + try handler.deleteWalletData(walletId: walletId) + XCTAssertEqual(try handler.loadPrivateContactScanStateWalletIds(), [walletId]) + XCTAssertEqual(try handler.loadPrivateContactScanState(walletId: walletId).positions, [marked]) + + let ext = handler.makePersistenceCallbacksExtension() + let list = try XCTUnwrap(ext.on_load_private_contact_scan_state_wallets_fn) + let freeList = try XCTUnwrap(ext.on_load_private_contact_scan_state_wallets_free_fn) + let load = try XCTUnwrap(ext.on_load_private_contact_scan_state_fn) + let free = try XCTUnwrap(ext.on_load_private_contact_scan_state_free_fn) + let shimContext = Unmanaged.passUnretained(handler).toOpaque() + var ids: UnsafePointer? + var idsCount: UInt = 0 + XCTAssertEqual(list(shimContext, &ids, &idsCount), 0) + XCTAssertEqual(idsCount, 1) + freeList(shimContext, ids, idsCount) + var positions: UnsafePointer? + var positionsCount: UInt = 0 + var addresses: UnsafePointer? + var addressesCount: UInt = 0 + let rc = walletId.withUnsafeBytes { wallet in + load(shimContext, wallet.bindMemory(to: UInt8.self).baseAddress, + &positions, &positionsCount, &addresses, &addressesCount) + } + XCTAssertEqual(rc, 0) + XCTAssertEqual(positionsCount, 1) + XCTAssertTrue(try XCTUnwrap(positions).pointee.private_payments_unresolved) + free(shimContext, positions, positionsCount, addresses, addressesCount) + + let testnet = PlatformWalletPersistenceHandler(modelContainer: container, network: .testnet) + XCTAssertTrue(try testnet.loadPrivateContactScanStateWalletIds().isEmpty) + XCTAssertTrue(try testnet.loadPrivateContactScanState(walletId: walletId).positions.isEmpty) + } + + /// A handler with no network and no wallet row to take one from refuses + /// to store scan state rather than file it under a guessed network. + func testShouldRefuseToStoreScanStateWithNoNetworkToStoreItUnder() throws { + let container = try DashModelContainer.createInMemory() + let handler = PlatformWalletPersistenceHandler(modelContainer: container) + handler.beginChangeset(walletId: walletId) + XCTAssertThrowsError(try handler.persistPrivateContactScanState( + walletId: walletId, + positions: [.init(identityId: owner, position: 1, keyFingerprint: Data(repeating: 3, count: 32))], + addresses: [], removedAddresses: [], clearedIdentities: [])) + _ = handler.endChangeset(walletId: walletId, success: false) + } + /// The wallet-list shim hands Rust the listed ids as 32-byte rows and /// its free shim releases them; an empty list crosses as null. func testShouldListTheWalletsWithScanStateThroughTheCallbackShims() throws { From 93b2abd007af2e4f1f17917a46a3dcce58d08442 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Tue, 10 Nov 2026 21:47:40 +0700 Subject: [PATCH 257/269] fix(platform-wallet): fence contact request cleanup to the wallet generation A handle taken before its wallet was removed and created again under the same id could still act on the replacement: its reservation's release and drop stored a private payment marker for the replacement's identity, its managed-identity steps reserved on the replacement, and a public claim could be taken there. Each such step now acts only while the wallet is still the handle's generation: a send is refused with WalletRemovedBeforeSend before anything is paid, and cleanup is a no-op. Co-Authored-By: Claude Opus 5.5 --- .../identity/network/contact_requests.rs | 30 ++- .../network/private_contact_requests.rs | 244 +++++++++++++++++- 2 files changed, 252 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 3c5f0e51905..9923b21152c 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 @@ -18,6 +18,7 @@ use super::sdk_writer::SendContactRequestParams; use super::*; use crate::broadcaster::TransactionBroadcaster; 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; @@ -27,7 +28,7 @@ use key_wallet_manager::WalletManager; use std::sync::Arc; use tokio::sync::RwLock; -use super::private_contact_requests::{apply_detached, with_journaled_marker}; +use super::private_contact_requests::{apply_detached, generation_info_mut, with_journaled_marker}; use dash_sdk::platform::dashpay::PrivateContactRequest; use dash_sdk::platform::private_document_entries::PrivateDocumentEntries; use dash_sdk::platform::private_document_scan::PrivateDocumentScan; @@ -581,6 +582,10 @@ pub(super) enum ReplyOutcome { pub(super) struct PublicContactClaim { wallet_manager: Arc>>, wallet_id: WalletId, + /// The wallet generation the claim was taken on: its release acts only + /// on that generation, so a wallet removed and re-created under its id + /// since is left alone. + generation: Arc, sender: Identifier, recipient: Identifier, /// The attempt the claim was taken under: only it releases the claim. @@ -596,6 +601,7 @@ impl PublicContactClaim { release_public_claim( &mut wm, &self.wallet_id, + &self.generation, &self.sender, &self.recipient, self.attempt, @@ -611,24 +617,28 @@ impl Drop for PublicContactClaim { } let (wallet_id, sender, recipient, attempt) = (self.wallet_id, self.sender, self.recipient, self.attempt); + let generation = Arc::clone(&self.generation); apply_detached( &self.wallet_manager, "a dropped public contact request's claim", - move |wm| release_public_claim(wm, &wallet_id, &sender, &recipient, attempt), + move |wm| { + release_public_claim(wm, &wallet_id, &generation, &sender, &recipient, attempt) + }, ); } } -/// Release the claim `attempt` holds on `sender`'s pair with `recipient`. +/// Release the claim `attempt` holds on `sender`'s pair with `recipient`, on +/// the wallet generation `generation` only. fn release_public_claim( wm: &mut WalletManager, wallet_id: &WalletId, + generation: &Arc, sender: &Identifier, recipient: &Identifier, attempt: u64, ) { - if let Some(managed) = wm - .get_wallet_info_mut(wallet_id) + if let Some(managed) = generation_info_mut(wm, wallet_id, generation) .and_then(|info| info.identity_manager.managed_identity_mut(sender)) { managed.release_public_contact_request(recipient, attempt); @@ -847,9 +857,12 @@ impl DashPayView<'_, B> { store_read: bool, ) -> Result, PlatformWalletError> { 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)))? + // A handle whose wallet was removed, or re-created under its id, since + // claims nothing on the replacement. + let managed = generation_info_mut(&mut wm, &self.wallet_id, &self.generation) + .ok_or_else(|| { + PlatformWalletError::WalletRemovedBeforeSend(hex::encode(self.wallet_id)) + })? .identity_manager .managed_identity_mut(sender) .ok_or(PlatformWalletError::IdentityNotFound(*sender))?; @@ -876,6 +889,7 @@ impl DashPayView<'_, B> { Ok(Some(PublicContactClaim { wallet_manager: Arc::clone(&self.wallet_manager), wallet_id: self.wallet_id, + generation: Arc::clone(&self.generation), sender: *sender, recipient: *recipient, attempt, 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 270a8430be7..e2b92df9595 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 @@ -351,6 +351,10 @@ struct PrivateReplyReservation { wallet_manager: Arc>>, persister: WalletPersister, wallet_id: WalletId, + /// The wallet generation the reservation was taken on: its release acts + /// only on that generation, so a wallet removed and re-created under its + /// id since is left alone. + generation: Arc, sender: Identifier, recipient: Identifier, attempt: u64, @@ -377,11 +381,18 @@ impl PrivateReplyReservation { release_reservation( &mut wm, &self.wallet_id, + &self.generation, &self.sender, &self.recipient, self.attempt, ); - store_private_payment_marker(&self.persister, &wm, &self.wallet_id, &self.sender); + store_private_payment_marker( + &self.persister, + &wm, + &self.wallet_id, + &self.generation, + &self.sender, + ); self.kept = true; } } @@ -394,17 +405,30 @@ impl Drop for PrivateReplyReservation { let (wallet_id, sender, recipient, attempt) = (self.wallet_id, self.sender, self.recipient, self.attempt); let persister = self.persister.clone(); + let generation = Arc::clone(&self.generation); apply_detached( &self.wallet_manager, "a cancelled private contact request's reservation", move |wm| { - release_reservation(wm, &wallet_id, &sender, &recipient, attempt); - store_private_payment_marker(&persister, wm, &wallet_id, &sender); + release_reservation(wm, &wallet_id, &generation, &sender, &recipient, attempt); + store_private_payment_marker(&persister, wm, &wallet_id, &generation, &sender); }, ); } } +/// A stale handle's refusal before anything was paid: its wallet was removed, +/// or re-created under its id, since the handle was taken +/// (`DashPayView::with_managed` reports it as not found). +fn removed_before_send(error: PlatformWalletError) -> PlatformWalletError { + match error { + PlatformWalletError::WalletNotFound(wallet_id) => { + PlatformWalletError::WalletRemovedBeforeSend(wallet_id) + } + other => other, + } +} + /// 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 @@ -432,17 +456,30 @@ pub(super) fn apply_detached( }); } +/// `wallet_id`'s wallet in `wm` while it is still `generation`: `None` once +/// the wallet was removed, or re-created under its id, since. A guard that +/// releases what one generation took acts only through this, so it never +/// touches a replacement's state. +pub(super) fn generation_info_mut<'w>( + wm: &'w mut WalletManager, + wallet_id: &WalletId, + generation: &Arc, +) -> Option<&'w mut PlatformWalletInfo> { + wm.get_wallet_info_mut(wallet_id) + .filter(|info| Arc::ptr_eq(&info.generation, generation)) +} + /// Release the reservation `attempt` holds on `sender`'s private request to -/// `recipient`. +/// `recipient`, on the wallet generation `generation` only. fn release_reservation( wm: &mut WalletManager, wallet_id: &WalletId, + generation: &Arc, sender: &Identifier, recipient: &Identifier, attempt: u64, ) { - if let Some(managed) = wm - .get_wallet_info_mut(wallet_id) + if let Some(managed) = generation_info_mut(wm, wallet_id, generation) .and_then(|info| info.identity_manager.managed_identity_mut(sender)) { managed.release_private_reply(recipient, attempt); @@ -642,10 +679,14 @@ pub(crate) async fn restore_journaled_private_payments( /// 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. +/// +/// Only while the wallet is still `generation`: a replacement under the id +/// stores its own records. fn store_private_payment_marker( persister: &WalletPersister, wm: &WalletManager, wallet_id: &WalletId, + generation: &Arc, owner: &Identifier, ) { if !persister.persists_private_contact_scan_state() { @@ -653,6 +694,7 @@ fn store_private_payment_marker( } if let Some(managed) = wm .get_wallet_info(wallet_id) + .filter(|info| Arc::ptr_eq(&info.generation, generation)) .and_then(|info| info.identity_manager.managed_identity(owner)) { store_private_position( @@ -1018,7 +1060,8 @@ impl DashPayView<'_, B> { } Some(managed.reserve_private_reply(*recipient_identity_id)) }) - .await?; + .await + .map_err(removed_before_send)?; let Some(reserved) = reserved else { return Ok(None); }; @@ -1029,6 +1072,7 @@ impl DashPayView<'_, B> { wallet_manager: Arc::clone(&self.wallet_manager), persister: self.persister.clone(), wallet_id: self.wallet_id, + generation: Arc::clone(&self.generation), sender: *sender_identity_id, recipient: *recipient_identity_id, attempt, @@ -1308,7 +1352,13 @@ impl DashPayView<'_, B> { /// 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( + /// + /// Stored only while the wallet is still this handle's generation, under + /// the wallet lock a removal takes: a handle whose wallet was removed, or + /// re-created under its id, since is refused with + /// [`PlatformWalletError::WalletRemovedBeforeSend`], nothing stored or + /// paid. + async fn store_private_payment_marker_before_paying( &self, owner: &Identifier, marker: PrivateContactRequestsPosition, @@ -1316,6 +1366,15 @@ impl DashPayView<'_, B> { if !self.persister.persists_private_contact_scan_state() { return Err(PlatformWalletError::PrivateContactRecoveryUnsupported); } + let wm = self.wallet_manager.read().await; + if !wm + .get_wallet_info(&self.wallet_id) + .is_some_and(|info| Arc::ptr_eq(&info.generation, &self.generation)) + { + return Err(PlatformWalletError::WalletRemovedBeforeSend(hex::encode( + self.wallet_id, + ))); + } let mut changeset = ContactChangeSet::default(); changeset .private_contact_requests_positions @@ -1334,6 +1393,7 @@ impl DashPayView<'_, B> { } self.persister .note_private_marker_stored(*owner, Some(marker.private_payments_unresolved)); + drop(wm); Ok(()) } @@ -1501,7 +1561,13 @@ impl DashPayView<'_, B> { } } for identity_id in changed { - store_private_payment_marker(&self.persister, &wm, &self.wallet_id, &identity_id); + store_private_payment_marker( + &self.persister, + &wm, + &self.wallet_id, + &self.generation, + &identity_id, + ); } Ok(()) } @@ -1513,9 +1579,8 @@ impl DashPayView<'_, B> { 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)))? + let managed = self + .current_wallet_info_mut(&mut wm)? .identity_manager .managed_identity_mut(identity_id) .ok_or(PlatformWalletError::IdentityNotFound(*identity_id))?; @@ -1652,12 +1717,14 @@ impl DashPayView<'_, B> { ); managed.private_contact_requests_position_record() }) - .await?; + .await + .map_err(removed_before_send)?; // 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)?; + self.store_private_payment_marker_before_paying(sender_identity_id, marker) + .await?; // What the record read back must carry, computed before paying. let shared_secret = crypto .ecdh_shared_secret(&sender_enc_path, &recipient_enc_pubkey) @@ -5731,6 +5798,155 @@ mod tests { .await } + /// The position records stored for `owner` so far. + fn positions_stored(persistence: &RecordingPersistence, owner: &Identifier) -> usize { + persistence + .stored + .lock() + .expect("stored") + .iter() + .filter(|changeset| { + changeset.contacts.as_ref().is_some_and(|contacts| { + contacts + .private_contact_requests_positions + .contains_key(owner) + }) + }) + .count() + } + + /// A private send whose wallet is removed and created again under its id + /// while the send is being prepared, after it reserved the pair, is + /// refused before anything is paid, and neither its release nor its + /// cleanup on drop touches the replacement: the replacement's own + /// reservation of the pair stays, and no marker is stored for it. A + /// handle taken before the replacement reserves nothing on it, and claims + /// nothing on it. + #[tokio::test] + async fn should_leave_a_wallet_created_again_alone_from_a_stale_private_send() { + let alice = identity(ALICE_PHRASE, ALICE); + let bob = identity(BOB_PHRASE, BOB); + let alice_signer = signer(ALICE_PHRASE, &alice); + let persistence = RecordingPersistence::attesting( + PersistenceCapabilities::PRIVATE_CONTACT_SCAN_STATE_RESTART, + ); + let manager = manager_on(Arc::clone(&persistence)).await; + #[cfg(feature = "shielded")] + let _store = { + let dir = tempfile::tempdir().expect("temporary shielded store"); + manager + .configure_shielded(dir.path().join("tree.sqlite")) + .await + .expect("the shielded store is configured"); + dir + }; + let replace = |first: &Arc| { + let wallet_id = first.wallet_id(); + let manager = &manager; + let alice = &alice; + async move { + manager.remove_wallet(&wallet_id).await.expect("removed"); + let again = create_holding(manager, ALICE_PHRASE, alice).await; + assert_eq!(again.wallet_id(), wallet_id); + // The replacement reserves the pair itself. + { + let iw = again.identity(); + let mut wm = iw.wallet_manager.write().await; + let reserved = wm + .get_wallet_info_mut(&wallet_id) + .expect("wallet info") + .identity_manager + .managed_identity_mut(&alice.id()) + .expect("Alice") + .reserve_private_reply(identity(BOB_PHRASE, BOB).id()); + assert!(reserved.is_some()); + } + again + } + }; + + for dropped in [false, true] { + let first = create_holding(&manager, ALICE_PHRASE, &alice).await; + let reached = Arc::new(tokio::sync::Notify::new()); + let release = Arc::new(tokio::sync::Notify::new()); + // The second derivation, as the send prepares its request, after + // it reserved the pair. + let mut pausing = HookedCrypto::new(ALICE_PHRASE); + pausing.before_receiving_xpub = + Some(pause_on_call(2, Arc::clone(&reached), Arc::clone(&release))); + let paid = Arc::new(Mutex::new(Vec::new())); + let view = first.identity().dashpay(); + let (alice_id, bob_id) = (alice.id(), bob.id()); + let mut send = Box::pin(view.send_private_contact_request( + &alice_id, + &bob_id, + None, + AutoAcceptProofSource::None, + None, + &alice_signer, + &pausing, + keep_entry(&paid), + )); + tokio::select! { + sent = &mut send => panic!("the send ran past its pause: {sent:?}"), + _ = reached.notified() => {} + } + assert!( + pending_to(&first, ALICE, &bob).await, + "the pair is reserved" + ); + let again = replace(&first).await; + let stored = positions_stored(&persistence, &alice.id()); + if dropped { + drop(send); + } else { + release.notify_one(); + let sent = send.await; + assert!( + matches!(sent, Err(PlatformWalletError::WalletRemovedBeforeSend(_))), + "{sent:?}" + ); + } + tokio::task::yield_now().await; + assert!(paid.lock().expect("paid").is_empty(), "nothing is paid"); + assert!( + pending_to(&again, ALICE, &bob).await, + "the replacement's reservation stays ({dropped})" + ); + assert_eq!( + positions_stored(&persistence, &alice.id()), + stored, + "no marker is stored for the replacement ({dropped})" + ); + + // A handle taken before the replacement acts on nothing of it. + let reserved = view + .with_managed(&alice.id(), |managed| { + managed.reserve_private_reply(bob.id()) + }) + .await; + assert!( + matches!(reserved, Err(PlatformWalletError::WalletNotFound(_))), + "{reserved:?}" + ); + let claimed = view + .claim_public_pair(&alice.id(), &bob.id(), false, false) + .await; + assert!( + matches!( + claimed, + Err(PlatformWalletError::WalletRemovedBeforeSend(_)) + ), + "{:?}", + claimed.err() + ); + manager + .remove_wallet(&again.wallet_id()) + .await + .expect("removed"); + } + } + /// A public request is bound to its wallet's generation: while one claims /// its pair under the wallet's lifecycle gate, paused here at its check /// that the wallet is still live, the wallet cannot be removed; once the From 6d6744f77f948d3780eeb9ce7dac81e0a361c9d0 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Tue, 10 Nov 2026 21:47:40 +0700 Subject: [PATCH 258/269] fix(platform-wallet): discharge a settled private payment record only once durable A restoration discharged a journal record whose request the wallet tracks even when that request was stored but not yet flushed. A backend that does not commit inline is now flushed first, and a failed flush leaves the record for a later restoration. A record whose read-back still waits for its flush is left to that path, which a restoration now retries first. Co-Authored-By: Claude Opus 5.5 --- .../network/private_contact_requests.rs | 187 ++++++++++++++++++ .../src/wallet/persister.rs | 8 + .../src/wallet/platform_wallet.rs | 48 ++++- 3 files changed, 238 insertions(+), 5 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 e2b92df9595..fb1d3ea50ff 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 @@ -3923,6 +3923,193 @@ mod tests { .expect("the hold")); } + /// A restoration discharges a record its sender's tracked request settles + /// only once what records that request is durable. A newer request to the + /// pair, stored but not flushed as a flush fails transiently, leaves the + /// record; once a flush succeeds the next restoration discharges it. A + /// read-back whose flush failed transiently is left to its own path, and a + /// flush that then fails for good leaves the record journaled after a + /// restart. + #[cfg(feature = "shielded")] + #[tokio::test] + async fn should_discharge_a_settled_record_only_once_its_request_is_durable() { + use platform_encryption::ACCOUNT_REFERENCE_VERSION_SHIFT; + + let alice = identity(ALICE_PHRASE, ALICE); + let bob = identity(BOB_PHRASE, BOB); + let alice_crypto = SeedCryptoProvider::from_seed(seed(ALICE_PHRASE), Network::Testnet); + + // A newer request to the pair, buffered. + let fixture = ExpiryFixture::new().await; + fixture + .pay_unconfirmed(ExpiryFixture::ANCHOR, ExpiryFixture::ANCHOR_SET_HEIGHT) + .await; + fixture + .wallet + .restore_private_contact_payments(&fixture.coordinator) + .await + .expect("restored"); + let journal = fixture.journal_snapshot().await; + let reference = journal[0].1.payment.account_reference; + assert_eq!(platform_encryption::account_reference_version(reference), 0); + fixture + .persistence + .flush_fails + .store(true, Ordering::SeqCst); + { + let iw = fixture.wallet.identity(); + let mut wm = iw.wallet_manager.write().await; + wm.get_wallet_info_mut(&fixture.wallet.wallet_id()) + .expect("wallet info") + .identity_manager + .managed_identity_mut(&alice.id()) + .expect("Alice") + .add_sent_contact_request( + public_request( + &alice, + &bob, + reference + (1 << ACCOUNT_REFERENCE_VERSION_SHIFT), + ), + &iw.persister, + ) + .expect("stored, not flushed"); + } + fixture + .wallet + .restore_private_contact_payments(&fixture.coordinator) + .await + .expect("restored"); + assert_eq!( + fixture.journaled().await, + 1, + "kept while the request is buffered" + ); + fixture + .persistence + .flush_fails + .store(false, Ordering::SeqCst); + fixture + .wallet + .restore_private_contact_payments(&fixture.coordinator) + .await + .expect("restored"); + assert_eq!(fixture.journaled().await, 0, "discharged once flushed"); + + // A read-back awaiting its flush, which then succeeds. + let fixture = ExpiryFixture::new().await; + let entry = fixture + .pay_unconfirmed(ExpiryFixture::ANCHOR, ExpiryFixture::ANCHOR_SET_HEIGHT) + .await; + fixture + .wallet + .restore_private_contact_payments(&fixture.coordinator) + .await + .expect("restored"); + publish(&fixture.wallet, 0, vec![entry.to_vec()]).await; + fixture + .persistence + .flush_fails + .store(true, Ordering::SeqCst); + fixture + .wallet + .identity() + .dashpay() + .sync_private_contact_requests(&alice_crypto, 4) + .await + .expect("the entry is read back"); + fixture + .wallet + .restore_private_contact_payments(&fixture.coordinator) + .await + .expect("restored"); + assert_eq!( + fixture.journaled().await, + 1, + "its read-back waits for a flush" + ); + fixture + .persistence + .flush_fails + .store(false, Ordering::SeqCst); + fixture + .wallet + .restore_private_contact_payments(&fixture.coordinator) + .await + .expect("restored"); + assert_eq!( + fixture.journaled().await, + 0, + "discharged once its read-back is flushed" + ); + + // A read-back awaiting its flush, which then fails for good. + let fixture = ExpiryFixture::new().await; + let entry = fixture + .pay_unconfirmed(ExpiryFixture::ANCHOR, ExpiryFixture::ANCHOR_SET_HEIGHT) + .await; + fixture + .wallet + .restore_private_contact_payments(&fixture.coordinator) + .await + .expect("restored"); + publish(&fixture.wallet, 0, vec![entry.to_vec()]).await; + fixture + .persistence + .flush_fails + .store(true, Ordering::SeqCst); + fixture + .wallet + .identity() + .dashpay() + .sync_private_contact_requests(&alice_crypto, 4) + .await + .expect("the entry is read back"); + assert!(fixture + .wallet + .persister() + .read_back_awaiting_flush(&journal_commitment(&fixture).await)); + fixture + .wallet + .restore_private_contact_payments(&fixture.coordinator) + .await + .expect("restored"); + assert_eq!( + fixture.journaled().await, + 1, + "its read-back waits for a flush" + ); + fixture + .persistence + .flush_fails + .store(false, Ordering::SeqCst); + fixture + .persistence + .flush_fails_fatally + .store(true, Ordering::SeqCst); + fixture + .wallet + .restore_private_contact_payments(&fixture.coordinator) + .await + .expect("restored"); + assert_eq!(fixture.journaled().await, 1, "the buffer was lost"); + let wallet_id = fixture.wallet.wallet_id(); + assert_eq!( + FileBackedShieldedStore::open_path(&fixture.db_path, 100) + .expect("reopened") + .private_payment_journal(wallet_id) + .expect("the journal") + .len(), + 1, + "still journaled after a restart" + ); + } + + /// The commitment of `fixture`'s one journal record. + #[cfg(feature = "shielded")] + async fn journal_commitment(fixture: &ExpiryFixture) -> [u8; 32] { + fixture.journal_snapshot().await[0].1.commitment + } + /// A restoration that finds a journal record its sender's tracked request /// already records, as a private sync read it back in a process that ended /// before the shielded sync discharged it, discharges the record. diff --git a/packages/rs-platform-wallet/src/wallet/persister.rs b/packages/rs-platform-wallet/src/wallet/persister.rs index bb3e29bc480..4f30a4c2e4c 100644 --- a/packages/rs-platform-wallet/src/wallet/persister.rs +++ b/packages/rs-platform-wallet/src/wallet/persister.rs @@ -345,6 +345,14 @@ impl WalletPersister { self.note_read_back_private_entries(pending); } + /// Whether the read-back of `commitment` waits for a flush that failed + /// transiently ([`Self::note_read_back_once_durable`]): that path + /// discharges its journal record once a flush succeeds. + #[cfg(feature = "shielded")] + pub(crate) fn read_back_awaiting_flush(&self, commitment: &[u8; 32]) -> bool { + self.unflushed_read_back_entries().contains(commitment) + } + #[cfg(feature = "shielded")] fn unflushed_read_back_entries(&self) -> std::sync::MutexGuard<'_, BTreeSet<[u8; 32]>> { self.unflushed_read_back_private_entries diff --git a/packages/rs-platform-wallet/src/wallet/platform_wallet.rs b/packages/rs-platform-wallet/src/wallet/platform_wallet.rs index 3c3cdca430b..0797bb46ca1 100644 --- a/packages/rs-platform-wallet/src/wallet/platform_wallet.rs +++ b/packages/rs-platform-wallet/src/wallet/platform_wallet.rs @@ -3078,6 +3078,27 @@ impl PrivatePaymentRestore { restored } + /// Whether the requests the wallet tracks are durable now: at once for a + /// backend that commits inline, as each was stored before it was + /// tracked; after a flush that succeeds otherwise. + fn settled_requests_durable(&self) -> bool { + if self.persister.store_commits_inline() { + return true; + } + match self.persister.flush() { + Ok(()) => true, + Err(error) => { + tracing::warn!( + wallet_id = %hex::encode(self.wallet_id), + %error, + "the requests settling private payments are not flushed; their journal \ + records stay for a later restoration" + ); + false + } + } + } + fn restores_lock(&self) -> std::sync::MutexGuard<'_, PrivatePaymentRestores> { self.restores .lock() @@ -3157,6 +3178,10 @@ impl PrivatePaymentRestore { ))); } let dashpay_id = dashpay_contract()?.id(); + // Read-backs whose flush failed transiently are noted once a flush + // succeeds now, so the journal read below discharges their records. + self.persister + .note_read_back_once_durable(std::iter::empty()); let (payments, journal_authoritative) = { let mut store = if wait { self.coordinator.store().write().await @@ -3180,11 +3205,24 @@ impl PrivatePaymentRestore { // A record whose request the tracked one records or supersedes was // read back by an earlier process, which ended before it discharged // it: discharged now, so its store is not read for it on every launch. - // A busy store leaves it for the next restoration. Not once a flush of - // the wallet's records failed for good in this process: the tracked - // request may be in memory only, and a restart would then find neither - // it nor the record. - if !settled.is_empty() && !self.persister.buffered_writes_lost() { + // A busy store leaves it for the next restoration. + // + // Only once what records that request is durable, as a restart would + // otherwise find neither it nor the record. A backend that commits + // inline stored it before the wallet tracked it. Any other is flushed + // first, and a flush that fails leaves the records for a later + // restoration; so does a flush that failed for good earlier in this + // process, after which the tracked request may be in memory only. A + // record whose read-back still waits for its flush is left to that + // path, which discharges it once a flush succeeds. + let settled: Vec<[u8; 32]> = settled + .into_iter() + .filter(|commitment| !self.persister.read_back_awaiting_flush(commitment)) + .collect(); + if !settled.is_empty() + && !self.persister.buffered_writes_lost() + && self.settled_requests_durable() + { let store = if wait { Some(self.coordinator.store().write().await) } else { From 828de98a8ae30e578efdfd7b7118f1b42511fa3b Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Tue, 10 Nov 2026 21:49:14 +0700 Subject: [PATCH 259/269] docs(platform-wallet): say which shield keeps its own broadcast handling The README said shields from Platform addresses and from an identity both keep their own handling. A shield from Platform addresses now broadcasts in single dispatches and reports ShieldedSpendUnconfirmed (FFI code 18) once a dispatch may have reached a node; only the identity-funded shield keeps its own handling. Co-Authored-By: Claude Opus 5.5 --- packages/rs-platform-wallet/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/rs-platform-wallet/README.md b/packages/rs-platform-wallet/README.md index 0c956800f43..43aab155d82 100644 --- a/packages/rs-platform-wallet/README.md +++ b/packages/rs-platform-wallet/README.md @@ -145,7 +145,7 @@ A build without the `shielded` feature keeps no shielded store, journal or resto ### 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 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. An identity creation reports `ShieldedBroadcastUnconfirmed` (FFI code 17) instead, carrying the identity's derived id. Earlier versions reported these verdicts and transport failures as `ShieldedBroadcastFailed` (FFI code 16). 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. +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. An identity creation reports `ShieldedBroadcastUnconfirmed` (FFI code 17) instead, carrying the identity's derived id. Earlier versions reported these verdicts and transport failures as `ShieldedBroadcastFailed` (FFI code 16). 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. A shield from Platform addresses broadcasts the same way and reports `ShieldedSpendUnconfirmed` (FFI code 18), not `AddressNonceMismatch` (FFI code 21) or `ShieldedBroadcastFailed`, once a dispatch may have reached a node and its result does not read its inputs spent; it consumes no notes and keeps no record of its signed transition, so its activity row stays pending until the scan sees its note: a retry would sign a second shield with the inputs' next nonces. Only a shield from an identity keeps its own handling (see "Identity-funded shield recovery" above). ## Shielded balance API migration From 3f8a9147bb28fa57a06c11bc2bf6da3a76a059e3 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Tue, 10 Nov 2026 22:56:53 +0700 Subject: [PATCH 260/269] feat(sdk): report the height a transition's inputs were seen consumed TransitionOutcome::ExecutedOrSuperseded now carries consumed_by_height, the lowest height of a read that showed one of the transition's inputs consumed: the transition cannot execute after it. A caller that knows what the transition would have written can then read it at or above that height and tell a transition that never wrote it from one that did. A single-nonce transition, such as a document create, is never CannotExecute (that needs one input taken while another is free), so this is the only proof that one will not write its document. The outcome API is unreleased (#5823), so the variant changes in place. Co-Authored-By: Claude Opus 5.5 --- .../src/platform/transition/outcome/judge.rs | 46 +++++++++++++------ .../src/platform/transition/outcome/mod.rs | 41 ++++++++++++++--- 2 files changed, 66 insertions(+), 21 deletions(-) diff --git a/packages/rs-sdk/src/platform/transition/outcome/judge.rs b/packages/rs-sdk/src/platform/transition/outcome/judge.rs index 4fc68998213..c5b21af3bec 100644 --- a/packages/rs-sdk/src/platform/transition/outcome/judge.rs +++ b/packages/rs-sdk/src/platform/transition/outcome/judge.rs @@ -12,7 +12,8 @@ //! //! So a free input read at height `h` proves the transition had not executed by `h`, and a //! consumed one read at `h` proves it cannot execute after `h`. No read says which transition -//! took a consumed input, so none proves this one executed. +//! took a consumed input, so none proves this one executed; a read of what it would have +//! written, at or above that `h`, does. /// One proven read of one input: whether it is consumed, and the height the proof was signed at. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -27,7 +28,10 @@ pub(super) struct InputRead { #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(super) enum Consumption { /// Every input is consumed. - All, + All { + /// The lowest height among the reads: the transition cannot execute after it. + consumed_by: u64, + }, /// An input was consumed while another was still free, as of a read at or after the read /// that showed it consumed. The transition consumes all of its inputs at once, so it did not /// execute, and the consumed input keeps it from ever executing. @@ -35,7 +39,11 @@ pub(super) enum Consumption { /// Some inputs are consumed and others free, but every free one was read at a lower height /// than every consumed one. The transition may have executed between the reads; it cannot /// execute any more. - Unordered, + Unordered { + /// The lowest height among the reads that showed an input consumed: the transition + /// cannot execute after it. + consumed_by: u64, + }, /// No input is consumed: the transition had not executed by `free_at`, the highest read. None { /// The highest height among the reads. @@ -57,9 +65,9 @@ pub(super) fn consumption(reads: &[InputRead]) -> Consumption { .map(|read| read.height) .max(); match (lowest_consumed, highest_free) { - (Some(_), None) => Consumption::All, + (Some(consumed_by), None) => Consumption::All { consumed_by }, (Some(consumed), Some(free)) if consumed <= free => Consumption::Partial, - (Some(_), Some(_)) => Consumption::Unordered, + (Some(consumed_by), Some(_)) => Consumption::Unordered { consumed_by }, (None, free) => Consumption::None { free_at: free.unwrap_or_default(), }, @@ -71,7 +79,11 @@ pub(super) fn consumption(reads: &[InputRead]) -> Consumption { pub(super) enum InputsVerdict { /// Every input is consumed, or the inputs are consumed in an order the reads cannot place: /// it can no longer execute, and the reads do not show whether it did. - ConsumedUnattributed, + ConsumedUnattributed { + /// The lowest height of a read that showed an input consumed: the transition cannot + /// execute after it. + consumed_by: u64, + }, /// An input another transition took: it never executed and never will. InputUsed, /// A shielded spend's anchor, proven recorded by an earlier height, is gone from its pool, @@ -110,9 +122,10 @@ pub(super) fn judge_shielded( match consumption(nullifiers) { // Every note it spends is spent, but other transitions can spend the same notes, all // together or each on its own, so the reads do not say this one executed. - Consumption::All => InputsVerdict::ConsumedUnattributed, + Consumption::All { consumed_by } | Consumption::Unordered { consumed_by } => { + InputsVerdict::ConsumedUnattributed { consumed_by } + } Consumption::Partial => InputsVerdict::InputUsed, - Consumption::Unordered => InputsVerdict::ConsumedUnattributed, Consumption::None { free_at } => { // A missing anchor proves pruned only from a read at or above a height by which the // anchor was recorded: a node below that height may simply not have recorded it yet, @@ -146,7 +159,9 @@ pub(super) fn judge_shielded( /// whether this transition took it. `reads` must not be empty. pub(super) fn judge_nonces(reads: &[InputRead]) -> InputsVerdict { match consumption(reads) { - Consumption::All | Consumption::Unordered => InputsVerdict::ConsumedUnattributed, + Consumption::All { consumed_by } | Consumption::Unordered { consumed_by } => { + InputsVerdict::ConsumedUnattributed { consumed_by } + } Consumption::Partial => InputsVerdict::InputUsed, Consumption::None { free_at } => InputsVerdict::NotYet { checked_at: free_at, @@ -227,7 +242,10 @@ mod tests { #[test] fn should_report_all_consumed_when_every_read_shows_its_input_taken() { - assert_eq!(consumption(&[consumed(10), consumed(12)]), Consumption::All); + assert_eq!( + consumption(&[consumed(10), consumed(12)]), + Consumption::All { consumed_by: 10 } + ); } #[test] @@ -255,7 +273,7 @@ mod tests { // Free at 10, consumed at 11: this transition may have taken both at 11. assert_eq!( consumption(&[free(10), consumed(11)]), - Consumption::Unordered + Consumption::Unordered { consumed_by: 11 } ); } @@ -264,7 +282,7 @@ mod tests { // Other transitions may have spent the same notes, together or one by one. assert_eq!( judge_shielded(&[consumed(20), consumed(20)], &[pruned(30)], Some(10)), - InputsVerdict::ConsumedUnattributed + InputsVerdict::ConsumedUnattributed { consumed_by: 20 } ); } @@ -284,7 +302,7 @@ mod tests { &[recorded(19), recorded(19)], None ), - InputsVerdict::ConsumedUnattributed + InputsVerdict::ConsumedUnattributed { consumed_by: 21 } ); } @@ -350,7 +368,7 @@ mod tests { // A paid failure consumes the nonce too, and so does any other transition signed with it. assert_eq!( judge_nonces(&[consumed(5)]), - InputsVerdict::ConsumedUnattributed + InputsVerdict::ConsumedUnattributed { consumed_by: 5 } ); } diff --git a/packages/rs-sdk/src/platform/transition/outcome/mod.rs b/packages/rs-sdk/src/platform/transition/outcome/mod.rs index 6c447727d56..cd954d2d070 100644 --- a/packages/rs-sdk/src/platform/transition/outcome/mod.rs +++ b/packages/rs-sdk/src/platform/transition/outcome/mod.rs @@ -92,8 +92,14 @@ pub enum TransitionOutcome { /// For a shielded spend every nullifier is spent; for a transition that takes nonces every /// nonce is used. /// - /// Never retry or release anything on this answer: it may be this very transition. - ExecutedOrSuperseded, + /// Never retry or release anything on this answer alone: it may be this very transition. + /// What it would have written, proved absent by a read at or above `consumed_by_height`, + /// shows it did not execute, or failed with a paid error. + ExecutedOrSuperseded { + /// The lowest height of a read that showed one of its inputs consumed: it cannot + /// execute after this height. + consumed_by_height: u64, + }, /// It did not execute and never will, for the given reason. CannotExecute(CannotExecuteReason), /// It had not executed when last read, and it may still execute. @@ -323,7 +329,11 @@ struct Reads { /// The answer the reads of one transition give. fn outcome_from(reads: Reads) -> Result { Ok(match reads.verdict { - InputsVerdict::ConsumedUnattributed => TransitionOutcome::ExecutedOrSuperseded, + InputsVerdict::ConsumedUnattributed { consumed_by } => { + TransitionOutcome::ExecutedOrSuperseded { + consumed_by_height: consumed_by, + } + } InputsVerdict::InputUsed => { TransitionOutcome::CannotExecute(CannotExecuteReason::InputUsed) } @@ -776,7 +786,10 @@ mod sdk_tests { .state_transition_outcome(&shielded_transfer(), OutcomeEvidence::default()) .await .expect("expected an outcome"); - assert_eq!(outcome, TransitionOutcome::ExecutedOrSuperseded); + assert!( + matches!(outcome, TransitionOutcome::ExecutedOrSuperseded { .. }), + "{outcome:?}" + ); assert!(outcome.is_final()); assert!(!outcome.proves_not_executed()); } @@ -937,7 +950,10 @@ mod sdk_tests { .state_transition_outcome(&credit_transfer(5), OutcomeEvidence::default()) .await .expect("expected an outcome"); - assert_eq!(outcome, TransitionOutcome::ExecutedOrSuperseded); + assert!( + matches!(outcome, TransitionOutcome::ExecutedOrSuperseded { .. }), + "{outcome:?}" + ); assert!(outcome.is_final()); assert!(!outcome.proves_not_executed()); } @@ -1037,7 +1053,10 @@ mod sdk_tests { .state_transition_outcome(&address_transfer(), OutcomeEvidence::default()) .await .expect("expected an outcome"); - assert_eq!(outcome, TransitionOutcome::ExecutedOrSuperseded); + assert!( + matches!(outcome, TransitionOutcome::ExecutedOrSuperseded { .. }), + "{outcome:?}" + ); } #[tokio::test] @@ -1133,16 +1152,24 @@ mod sdk_tests { ); } + /// The answer carries the height of the read that showed the nonce used, so a read of the + /// document at or above it can tell whether this batch wrote it. #[tokio::test] async fn should_not_attribute_a_document_batch_whose_contract_nonce_is_used() { let mut sdk = mock_sdk(); expect_contract_nonce(&mut sdk, 17).await; + sdk.mock().set_response_height(42); let outcome = sdk .state_transition_outcome(&document_batch(), OutcomeEvidence::default()) .await .expect("expected an outcome"); - assert_eq!(outcome, TransitionOutcome::ExecutedOrSuperseded); + assert_eq!( + outcome, + TransitionOutcome::ExecutedOrSuperseded { + consumed_by_height: 42 + } + ); } } From 8cd8e6d30f9dea458168a92054e55389944b7f0c Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Tue, 10 Nov 2026 22:56:53 +0700 Subject: [PATCH 261/269] feat(sdk): sign a contact request without broadcasting it Sdk::sign_contact_request returns the signed contactRequest create transition (SignedContactRequest), so a caller can keep the bytes and broadcast the same transition again on a retry instead of signing a second request under a new identity contract nonce. Sdk::send_contact_request is now sign, broadcast and wait for the same transition, as before. Co-Authored-By: Claude Opus 5.5 --- .../src/platform/dashpay/contact_request.rs | 95 +++++++++++++++---- packages/rs-sdk/src/platform/dashpay/mod.rs | 1 + .../src/platform/transition/broadcast.rs | 2 +- .../src/platform/transition/put_document.rs | 2 +- 4 files changed, 81 insertions(+), 19 deletions(-) diff --git a/packages/rs-sdk/src/platform/dashpay/contact_request.rs b/packages/rs-sdk/src/platform/dashpay/contact_request.rs index 2e3fe81e91c..0859c649f01 100644 --- a/packages/rs-sdk/src/platform/dashpay/contact_request.rs +++ b/packages/rs-sdk/src/platform/dashpay/contact_request.rs @@ -2,7 +2,9 @@ //! //! Implements DIP-15 DashPay contact request functionality -use crate::platform::transition::put_document::PutDocument; +use crate::platform::transition::broadcast::{wait_after_broadcast, BroadcastStateTransition}; +use crate::platform::transition::put_document::prepare_document_state_transition; +use crate::platform::transition::waitable::Waitable; use crate::platform::Document; use crate::{Error, Sdk}; use dpp::data_contract::accessors::v0::DataContractV0Getters; @@ -18,6 +20,7 @@ use dpp::prelude::Identifier; use dpp::secp256k1::rand::rngs::StdRng; use dpp::secp256k1::rand::{Rng, RngCore, SeedableRng}; use dpp::secp256k1::{PublicKey, SecretKey}; +use dpp::state_transition::StateTransition; use platform_encryption::{ derive_shared_key_ecdh, encrypt_account_label, encrypt_extended_public_key, COMPACT_XPUB_LEN, }; @@ -148,6 +151,20 @@ pub struct SendContactRequestResult { pub account_reference: u32, } +/// A contact request signed and not yet broadcast +/// ([`Sdk::sign_contact_request`]). +#[derive(Debug, Clone)] +pub struct SignedContactRequest { + /// The signed `contactRequest` create transition. Broadcasting these same + /// bytes again is a retry of this request, never a second one: the + /// transition carries its identity contract nonce. + pub state_transition: StateTransition, + /// The recipient's identity ID + pub recipient_id: Identifier, + /// The account reference + pub account_reference: u32, +} + /// Whether `purpose` is acceptable for the `senderKeyIndex` key of a contact /// request **we are about to mint**. The sender always references its own /// ENCRYPTION key. @@ -494,6 +511,52 @@ impl Sdk { ecdh_provider: EcdhProvider, get_extended_public_key: H, ) -> Result + where + F: FnOnce(&IdentityPublicKey, u32) -> Fut, + Fut: std::future::Future>, + G: FnOnce(&PublicKey) -> Gut, + Gut: std::future::Future>, + H: FnOnce(u32) -> Hut, + Hut: std::future::Future, Error>>, + { + let SignedContactRequest { + state_transition, + recipient_id, + account_reference, + } = self + .sign_contact_request(input, ecdh_provider, get_extended_public_key) + .await?; + + // Submit the document to the platform, and wait for the proof of the + // same signed transition. + let broadcast = state_transition.broadcast(self, None).await; + let platform_document = wait_after_broadcast( + broadcast, + ::wait_for_response(self, state_transition, None), + ) + .await?; + + // Return the result with recipient ID and account reference we saved earlier + Ok(SendContactRequestResult { + document: platform_document, + recipient_id, + account_reference, + }) + } + + /// Sign a contact request without broadcasting it: what + /// [`Self::send_contact_request`] submits, for a caller that keeps the + /// signed transition so that a retry broadcasts the same bytes again + /// rather than signing a second request. + /// + /// The transition takes the sender's next identity contract nonce for + /// DashPay, as a send does. + pub async fn sign_contact_request, F, Fut, G, Gut, H, Hut>( + &self, + input: SendContactRequestInput, + ecdh_provider: EcdhProvider, + get_extended_public_key: H, + ) -> Result where F: FnOnce(&IdentityPublicKey, u32) -> Fut, Fut: std::future::Future>, @@ -550,22 +613,20 @@ impl Sdk { moderated_by: None, }); - // Submit the document to the platform - let platform_document = document - .put_to_platform_and_wait_for_response( - self, - contact_request_document_type.to_owned_document_type(), - Some(entropy.0), - input.identity_public_key, - None, // token payment info - &input.signer, - None, // settings - ) - .await?; - - // Return the result with recipient ID and account reference we saved earlier - Ok(SendContactRequestResult { - document: platform_document, + let state_transition = prepare_document_state_transition( + &document, + self, + contact_request_document_type.to_owned_document_type(), + Some(entropy.0), + input.identity_public_key, + None, // token payment info + &input.signer, + None, // settings + ) + .await?; + + Ok(SignedContactRequest { + state_transition, recipient_id, account_reference, }) diff --git a/packages/rs-sdk/src/platform/dashpay/mod.rs b/packages/rs-sdk/src/platform/dashpay/mod.rs index 4c663a40376..83352890a63 100644 --- a/packages/rs-sdk/src/platform/dashpay/mod.rs +++ b/packages/rs-sdk/src/platform/dashpay/mod.rs @@ -15,6 +15,7 @@ pub use contact_request::{ recipient_key_purpose_is_acceptable_on_receive, recipient_key_purpose_is_valid, sender_key_purpose_is_acceptable_on_receive, ContactRequestInput, ContactRequestResult, EcdhProvider, RecipientIdentity, SendContactRequestInput, SendContactRequestResult, + SignedContactRequest, }; pub use contact_request_queries::{ContactRequestDocuments, ResumableContactRequests}; pub use invitation::{ diff --git a/packages/rs-sdk/src/platform/transition/broadcast.rs b/packages/rs-sdk/src/platform/transition/broadcast.rs index 8cb64874b68..387989b8ae2 100644 --- a/packages/rs-sdk/src/platform/transition/broadcast.rs +++ b/packages/rs-sdk/src/platform/transition/broadcast.rs @@ -299,7 +299,7 @@ fn finish_broadcast( } /// Check the SAME signed transition after possible submission. No replacement is built here. -pub(super) async fn wait_after_broadcast( +pub(crate) async fn wait_after_broadcast( broadcast: Result<(), Error>, wait: impl Future>, ) -> Result { diff --git a/packages/rs-sdk/src/platform/transition/put_document.rs b/packages/rs-sdk/src/platform/transition/put_document.rs index a3fb0a34ddb..665b51b8c2f 100644 --- a/packages/rs-sdk/src/platform/transition/put_document.rs +++ b/packages/rs-sdk/src/platform/transition/put_document.rs @@ -310,7 +310,7 @@ fn ensure_entropy_matches_document_id( /// Construct and sign once; dispatch is chosen by the calling API. #[allow(clippy::too_many_arguments)] -async fn prepare_document_state_transition>( +pub(crate) async fn prepare_document_state_transition>( document: &Document, sdk: &Sdk, document_type: DocumentType, From e81837592792aa2f29d36cd7b096228e5ceaf409 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Tue, 10 Nov 2026 22:56:53 +0700 Subject: [PATCH 262/269] fix(platform-wallet): hold a public contact request whose outcome is unconfirmed A public contact request (a send, an accept, the in-kind accept's public reply, a QR send, an auto-accept, a rotation) released its pair however its send ended. After a broadcast that may have been delivered, the SDK's unconfirmed outcome left nothing holding the pair, so a second accept signed and submitted a second transition while the first could still execute. The send now signs, keeps the signed transition, and broadcasts it by the rule shielded spends use. Once a dispatch may have reached an endpoint and the document is not confirmed, the pair is held by the wallet as an unconfirmed public request, bound to the wallet generation, and the host still gets the SDK's unconfirmed outcome (FFI 20). While it stands, a public or private request or accept to the pair is refused with PublicContactRequestUnconfirmed, with nothing signed or paid, and the sender identity cannot be removed. DashPayView::public_contact_request_unconfirmed reads it per pair. The public contact request sync, and each send or accept to the pair, settles it from proved reads: the request found by DashPay's unique index is recorded as sent; its identity contract nonce proved used, with the request proved absent at or above that height, frees the pair; otherwise the same signed bytes are broadcast again. Before signing, a send adopts a request already on Platform that it would repeat. Co-Authored-By: Claude Opus 5.5 --- packages/rs-platform-wallet/README.md | 8 + packages/rs-platform-wallet/src/error.rs | 14 + .../identity/network/contact_requests.rs | 535 +++++++++++++-- .../network/private_contact_requests.rs | 636 +++++++++++++++++- .../src/wallet/identity/network/sdk_writer.rs | 18 +- .../managed_identity/contact_requests.rs | 58 +- .../state/managed_identity/dashpay.rs | 58 +- .../identity/state/manager/lifecycle.rs | 13 +- 8 files changed, 1275 insertions(+), 65 deletions(-) diff --git a/packages/rs-platform-wallet/README.md b/packages/rs-platform-wallet/README.md index 43aab155d82..7e38a91f9f4 100644 --- a/packages/rs-platform-wallet/README.md +++ b/packages/rs-platform-wallet/README.md @@ -147,6 +147,14 @@ A build without the `shielded` feature keeps no shielded store, journal or resto 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. An identity creation reports `ShieldedBroadcastUnconfirmed` (FFI code 17) instead, carrying the identity's derived id. Earlier versions reported these verdicts and transport failures as `ShieldedBroadcastFailed` (FFI code 16). 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. A shield from Platform addresses broadcasts the same way and reports `ShieldedSpendUnconfirmed` (FFI code 18), not `AddressNonceMismatch` (FFI code 21) or `ShieldedBroadcastFailed`, once a dispatch may have reached a node and its result does not read its inputs spent; it consumes no notes and keeps no record of its signed transition, so its activity row stays pending until the scan sees its note: a retry would sign a second shield with the inputs' next nonces. Only a shield from an identity keeps its own handling (see "Identity-funded shield recovery" above). +### Public contact requests whose broadcast may have been delivered + +Every public `contactRequest` (a send, a public accept, the in-kind accept's public reply, a send from a QR code, an auto-accept and a rotation) is sent by one path, which signs the transition, keeps the signed bytes, and broadcasts them by the rule shielded spends use: only a refusal before anything was sent reports its error with nothing held. Once a dispatch may have reached an endpoint, the send waits for the result (not after an endpoint's verdict, which no proof backs), records the request when its document is confirmed, and otherwise reports the SDK's unconfirmed outcome (`StateTransitionResultUnconfirmed`, FFI code 20) and holds the pair as an unconfirmed public request. Before signing, the send reads the request it would make by DashPay's `ownerIdUserIdAndAccountRef` unique index (the sender, the recipient and the account reference); one found, sent by an earlier process that ended before recording it or by another device of the sender, is adopted as sent, with nothing signed. A read that fails sends on. + +While an unconfirmed public request holds a pair, a public or private request to it, an accept included, is refused with `PublicContactRequestUnconfirmed` (FFI code 76), with nothing signed or paid, and the sender identity cannot be removed. `DashPayView::public_contact_request_unconfirmed` reads one pair's state, beside `DashPayView::private_pair_held`, so a host shows a public request awaiting its outcome apart from a private one pending. The public contact request sync settles it, as does each send or accept to the pair before it decides: `Sdk::state_transition_outcome` reads the transition's identity contract nonce, proved, and then the request is read by the unique index, proved. A request found is recorded as sent, as a confirmed send records it, and frees the pair. With none found, a nonce proved used at a height at or below the height of the read that missed the request shows the transition never wrote it and never will, and frees the pair; a new request may then be sent. Otherwise the transition may still execute: the same signed bytes are broadcast again and the pair stays held. No retry signs a second transition. + +The hold is in memory, bound to the wallet generation that took it: a wallet removed and created again under its id starts with none, and a handle taken before settles nothing of the new wallet's. Nothing about it is persisted. A process that starts afresh reads the sender's DashPay identity contract nonce from Platform, so while the earlier transition has not executed, and no other DashPay transition of the sender has since, a new request takes the same nonce and at most one of the two executes. That alone does not cover a restart after another DashPay transition of the sender executed with a later nonce: Platform then keeps the earlier nonce open as a missing revision (up to 24 back), and the new request takes a later one. What keeps a second request off Platform then is the unique index: a request built from the same recorded requests carries the same account reference (derived from the sender's encryption key, the friendship key, the account index and the version past the last recorded request), so Platform writes at most one of the two, and the read before signing adopts one already written. A restart does not hold back a private request to the pair while the earlier public one may still execute: the private store is not under that index. + ## Shielded balance API migration Custom `ShieldedStore` implementations must also implement the private diff --git a/packages/rs-platform-wallet/src/error.rs b/packages/rs-platform-wallet/src/error.rs index 9262403b168..f9ed4a5b60f 100644 --- a/packages/rs-platform-wallet/src/error.rs +++ b/packages/rs-platform-wallet/src/error.rs @@ -699,6 +699,20 @@ pub enum PlatformWalletError { )] PrivateContactRequestPending(Identifier), + /// A public contact request to this identity was broadcast and may have + /// been delivered, and no proof settled its outcome yet: the wallet keeps + /// its signed transition and sends no other contact request to the + /// identity, public or private, until a proved read shows the request on + /// Platform (it is then recorded as sent) or shows the transition can no + /// longer write it. A public contact request sync settles it, broadcasting + /// the same transition again while it may still execute. Unconfirmed, not + /// failed: it is not submitted again. + #[error( + "a public contact request to {0} was broadcast and its outcome is not proved yet; sync \ + contact requests to settle it" + )] + PublicContactRequestUnconfirmed(Identifier), + /// DashPay's private contact request store was not read to its end for /// this identity with the keys Platform proves it holds, before a contact /// request from it, public or private, or an acceptance, was decided: a 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 9923b21152c..eb1f822bbf9 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 @@ -29,11 +29,21 @@ use std::sync::Arc; use tokio::sync::RwLock; use super::private_contact_requests::{apply_detached, generation_info_mut, with_journaled_marker}; -use dash_sdk::platform::dashpay::PrivateContactRequest; +use crate::broadcast_outcome::{ + broadcast_tracking_delivery, carries_consensus_rejection, BroadcastDelivery, +}; +use dash_sdk::drive::query::{WhereClause, WhereOperator}; +use dash_sdk::platform::dashpay::{PrivateContactRequest, SignedContactRequest}; use dash_sdk::platform::private_document_entries::PrivateDocumentEntries; use dash_sdk::platform::private_document_scan::PrivateDocumentScan; +use dash_sdk::platform::transition::outcome::{OutcomeEvidence, TransitionOutcome}; +use dash_sdk::platform::transition::waitable::Waitable; +use dash_sdk::platform::{DocumentQuery, FetchMany}; +use dpp::document::Document; use dpp::identity::KeyID; use dpp::platform_value::BinaryData; +use dpp::state_transition::StateTransition; +use key_wallet::bip32::ExtendedPubKey; use std::collections::BTreeMap; // --------------------------------------------------------------------------- @@ -575,6 +585,51 @@ pub(super) enum ReplyOutcome { AlreadyReplied, } +/// The proved query for the `contactRequest` from `sender` to `recipient` +/// carrying `account_reference`: equality on every property of DashPay's +/// `ownerIdUserIdAndAccountRef` unique index, as DPNS reads a preorder. +pub(super) fn sent_contact_request_query( + sender: &Identifier, + recipient: &Identifier, + account_reference: u32, +) -> Result { + let mut query = DocumentQuery::new(dashpay_contract()?, "contactRequest").map_err(|e| { + PlatformWalletError::InvalidIdentityData(format!( + "Failed to build the contact request query: {e}" + )) + })?; + query.where_clauses = vec![ + WhereClause { + field: "$ownerId".to_string(), + operator: WhereOperator::Equal, + value: Value::Identifier(sender.to_buffer()), + }, + WhereClause { + field: "toUserId".to_string(), + operator: WhereOperator::Equal, + value: Value::Identifier(recipient.to_buffer()), + }, + WhereClause { + field: "accountReference".to_string(), + operator: WhereOperator::Equal, + value: Value::U32(account_reference), + }, + ]; + query.limit = 1; + Ok(query) +} + +/// `error` under the SDK's unconfirmed-outcome envelope +/// (`StateTransitionResultUnconfirmed`), once: the transition it reports on +/// may have been delivered. +fn unconfirmed(error: dash_sdk::Error) -> dash_sdk::Error { + if error.is_state_transition_result_unconfirmed() { + error + } else { + dash_sdk::Error::StateTransitionResultUnconfirmed(Box::new(error)) + } +} + /// 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 @@ -704,6 +759,16 @@ impl DashPayView<'_, B> { /// the signer and only the (public) xpub, the shared secret, and the masked /// reference cross back. /// + /// A broadcast that may have been delivered and is not confirmed reports + /// the SDK's unconfirmed outcome (`StateTransitionResultUnconfirmed`) and + /// leaves the pair held as an unconfirmed public request, its signed + /// transition kept, until a proved read settles it + /// (`DashPayView::settle_unconfirmed_public_request`): until then a request + /// to the recipient is refused + /// ([`PlatformWalletError::PublicContactRequestUnconfirmed`]). A request + /// already on Platform that this one would repeat is adopted as sent, + /// with nothing signed. + /// /// A recipient whose requests with this identity came or went privately is /// refused ([`PlatformWalletError::PrivateContactRequestNeedsPrivateReply`]). /// So is one a private request is pending to @@ -735,7 +800,8 @@ impl DashPayView<'_, B> { // 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. + // the send ends, however it ends, a request that may have been + // delivered holding the pair from then on as unconfirmed. self.send_public_contact_request( sender_identity_id, recipient_identity_id, @@ -789,6 +855,15 @@ impl DashPayView<'_, B> { // callers nest it in their own. let read = Box::pin(self.read_private_store_before_deciding(sender_identity_id, crypto)).await; + // A public request to the recipient whose outcome is unconfirmed is + // settled first where proof allows; while it stands the claim refuses. + Box::pin( + self.settle_unconfirmed_public_request_logged( + sender_identity_id, + recipient_identity_id, + ), + ) + .await; let claim = { let _lifecycle = self.generation.payment_guard().await; if !self.is_current_generation().await { @@ -811,16 +886,17 @@ impl DashPayView<'_, B> { None => return Ok(None), } }; - let sent = self - .send_claimed_contact_request( - sender_identity_id, - recipient_identity_id, - account_label, - auto_accept_proof, - signer, - crypto, - ) - .await; + // Boxed: the send's future (its reads, the broadcast and the result + // wait) is large, and its callers nest it in their own. + let sent = Box::pin(self.send_claimed_contact_request( + sender_identity_id, + recipient_identity_id, + account_label, + auto_accept_proof, + signer, + crypto, + )) + .await; claim.release().await; sent.map(Some) } @@ -873,6 +949,14 @@ impl DashPayView<'_, B> { if store_read && !managed.dashpay().private_contact_requests_read() { return Err(PlatformWalletError::PrivateContactStoreNotRead(*sender)); } + if managed + .dashpay() + .public_contact_request_unconfirmed(recipient) + { + return Err(PlatformWalletError::PublicContactRequestUnconfirmed( + *recipient, + )); + } if managed.dashpay().private_reply_pending(recipient) || self.persister.private_pair_journaled(sender, recipient) { @@ -935,6 +1019,52 @@ impl DashPayView<'_, B> { ) .await?; + // 4d. A request this one repeats may be on Platform already: DashPay's + // unique index keys a request by its sender, recipient and account + // reference, which a request built from the same tracked requests + // repeats. One this wallet signed in a process that ended before + // recording it, or one another device of the sender sent, is + // adopted: recorded as sent, with nothing signed. A read that fails + // sends on: the unique index refuses this request if that one is + // there, and the refused request is held as unconfirmed until a read + // finds the one on Platform. + match self + .find_sent_contact_request(sender_identity_id, recipient_identity_id, account_reference) + .await + { + Ok((Some(request), _)) => { + let _lifecycle = self.generation.payment_guard().await; + if !self.is_current_generation().await { + return Err(PlatformWalletError::WalletRemovedBeforeSend(hex::encode( + self.wallet_id, + ))); + } + tracing::info!( + sender = %sender_identity_id, + recipient = %recipient_identity_id, + account_reference, + "contact request already on Platform; adopting it instead of sending another" + ); + return self + .record_sent_contact_request( + sender_identity_id, + recipient_identity_id, + request, + None, + account_index, + contact_xpub_ext, + ) + .await; + } + Ok((None, _)) => {} + Err(error) => tracing::warn!( + sender = %sender_identity_id, + recipient = %recipient_identity_id, + %error, + "could not read whether the contact request is on Platform already; sending it" + ), + } + // 5. Build the signing key reference for document signing. let identity_public_key = sender_identity // Contact-request send writes a document state transition, @@ -967,22 +1097,22 @@ impl DashPayView<'_, B> { .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`. The host signs through `GatedSigner`, which - // takes the wallet generation's lifecycle gate once the signature - // returns and keeps it, to the end of this call, through the - // broadcast the SDK makes right after and the bookkeeping below. + // 7. Sign through the write helper. All inputs are resolved above; the + // helper assembles the SDK `EcdhProvider` + xpub closure and calls + // `Sdk::sign_contact_request`, keeping the seven-generic SDK + // signature out of this call site (see `sdk_writer.rs`). The host + // signs through `GatedSigner`, which takes the wallet generation's + // lifecycle gate once the signature returns and keeps it, to the end + // of this call, through the broadcast right after and the + // bookkeeping below. let gated = GatedSigner { signer, wallet: self, gate: std::sync::Mutex::new(SigningGate::Unsigned), }; - let result = self + let signed = self .sdk_writer - .send_contact_request(SendContactRequestParams { + .sign_contact_request(SendContactRequestParams { sender_identity: sender_identity.clone(), recipient_identity, sender_key_index, @@ -1002,7 +1132,55 @@ impl DashPayView<'_, B> { false => error, })?; - // 8. Mirror the local-state bookkeeping in `send_contact_request`. + // 8. Broadcast the signed bytes, keeping what every dispatch proved, + // by the rule shielded spends use (`broadcast_tracking_delivery`): + // only a refusal before anything was sent shows the request can + // never execute. A dispatch that reached an endpoint may have + // delivered it whatever the endpoint answered, as no proof backs + // the answer, and the request may execute later under its identity + // contract nonce, while a second request would take the next one. + // So the request is then held as unconfirmed, the pair with it, + // until a proved read settles it + // (`DashPayView::settle_unconfirmed_public_request`), and the host + // gets the SDK's unconfirmed outcome. An endpoint's verdict skips + // the wait for a result that would only time out. + let SignedContactRequest { + state_transition, .. + } = signed; + let delivery = Box::pin(broadcast_tracking_delivery(&self.sdk, &state_transition)).await; + let confirmed = match delivery { + BroadcastDelivery::NeverDelivered(error) => { + return Err(PlatformWalletError::Sdk(error)) + } + BroadcastDelivery::MaybeDelivered(error) if carries_consensus_rejection(&error) => { + Err(error) + } + BroadcastDelivery::Admitted | BroadcastDelivery::MaybeDelivered(_) => { + Box::pin(::wait_for_response( + &self.sdk, + state_transition.clone(), + None, + )) + .await + } + }; + let document = match confirmed { + Ok(document) => document, + Err(error) => { + self.hold_unconfirmed_public_request( + sender_identity_id, + recipient_identity_id, + state_transition, + account_reference, + account_index, + contact_xpub_ext, + ) + .await; + return Err(PlatformWalletError::Sdk(unconfirmed(error))); + } + }; + + // 9. Record the request as a confirmed send records it. // // Store the REAL 96-byte ciphertext off the broadcast // document (not a zero placeholder) so the persisted / @@ -1016,8 +1194,7 @@ impl DashPayView<'_, B> { // 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 + let encrypted_public_key = document .properties() .get("encryptedPublicKey") .and_then(|v: &Value| v.to_binary_bytes().ok()) @@ -1030,54 +1207,310 @@ impl DashPayView<'_, B> { })?; let mut contact_request = ContactRequest::new( *sender_identity_id, - result.recipient_id, + *recipient_identity_id, sender_key_index, recipient_key_index, - result.account_reference, + account_reference, encrypted_public_key, - result.document.created_at_core_block_height().unwrap_or(0), - result.document.created_at().unwrap_or(0), + document.created_at_core_block_height().unwrap_or(0), + 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 + contact_request.encrypted_account_label = document .properties() .get("encryptedAccountLabel") .and_then(|v: &Value| v.to_binary_bytes().ok()); + // Register our receiving (friendship) account from the xpub the signer + // already derived above: same friendship key, no second derivation and + // no resident seed. + self.record_sent_contact_request( + sender_identity_id, + recipient_identity_id, + contact_request, + None, + account_index, + contact_xpub_ext, + ) + .await + } + + /// Record `request` as sent from `sender` to `recipient` and register our + /// receiving (friendship) account for the pair from `contact_xpub`, as a + /// send that saw its document confirmed does. `settles` is the attempt of + /// the unconfirmed public request to the recipient this request settles, + /// released in the same lock it is recorded in. Only on this handle's + /// wallet generation: the caller holds the generation's lifecycle gate. + async fn record_sent_contact_request( + &self, + sender: &Identifier, + recipient: &Identifier, + request: ContactRequest, + settles: Option, + account_index: u32, + contact_xpub: ExtendedPubKey, + ) -> Result { { 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 + let managed = generation_info_mut(&mut wm, &self.wallet_id, &self.generation) + .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_identity_mut(sender) + .ok_or(PlatformWalletError::IdentityNotFound(*sender))?; managed - .add_sent_contact_request(contact_request.clone(), &self.persister) + .add_sent_contact_request(request.clone(), &self.persister) .map_err(|e| { PlatformWalletError::Persistence(format!( "sent contact request not persisted: {e}" )) })?; + if let Some(attempt) = settles { + managed.settle_unconfirmed_public_request(recipient, attempt); + } } + self.register_contact_account(sender, recipient, account_index, contact_xpub) + .await?; + Ok(request) + } - // 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?; + /// Hold the pair `sender`, `recipient` for the public request + /// `transition` signs, whose broadcast may have been delivered + /// (`ManagedIdentity::hold_unconfirmed_public_request`). Only on this + /// handle's wallet generation: a wallet removed, or re-created under its + /// id, since is left alone. + #[allow(clippy::too_many_arguments)] + async fn hold_unconfirmed_public_request( + &self, + sender: &Identifier, + recipient: &Identifier, + transition: StateTransition, + account_reference: u32, + account_index: u32, + contact_xpub: ExtendedPubKey, + ) { + let mut wm = self.wallet_manager.write().await; + if let Some(managed) = generation_info_mut(&mut wm, &self.wallet_id, &self.generation) + .and_then(|info| info.identity_manager.managed_identity_mut(sender)) + { + managed.hold_unconfirmed_public_request( + *recipient, + transition, + account_reference, + account_index, + contact_xpub, + ); + } + } + + /// The `contactRequest` from `sender` to `recipient` carrying + /// `account_reference`, as a proved read shows it, and the height the read + /// was proved at: `None` when the read proves there is none. DashPay's + /// `ownerIdUserIdAndAccountRef` unique index keys it, so there is at most + /// one, and as the type's documents are neither mutable nor deletable, + /// one found stays. + pub(super) async fn find_sent_contact_request( + &self, + sender: &Identifier, + recipient: &Identifier, + account_reference: u32, + ) -> Result<(Option, u64), PlatformWalletError> { + let query = sent_contact_request_query(sender, recipient, account_reference)?; + let (documents, metadata) = Document::fetch_many_with_metadata(&self.sdk, query, None) + .await + .map_err(PlatformWalletError::Sdk)?; + let request = documents + .values() + .flatten() + .filter(|document| { + document.owner_id() == *sender + && document + .properties() + .get("toUserId") + .and_then(|value: &Value| value.to_identifier().ok()) + == Some(*recipient) + }) + .find_map(|document| { + Self::parse_sent_contact_request_doc(document, *sender, *recipient) + }) + .filter(|request| request.account_reference == account_reference); + Ok((request, metadata.height)) + } + + /// Whether a public contact request from `sender` to `recipient` was + /// broadcast and may have been delivered, with no proof settling its + /// outcome yet: until one does, no other contact request to the recipient + /// is sent, public or private + /// ([`PlatformWalletError::PublicContactRequestUnconfirmed`]). A public + /// contact request sync settles it. The per-contact read a host shows next + /// to `DashPayView::private_pair_held`, so it tells a public request + /// awaiting its outcome from a private one pending. + pub async fn public_contact_request_unconfirmed( + &self, + sender: &Identifier, + recipient: &Identifier, + ) -> Result { + let wm = self.wallet_manager.read().await; + Ok(wm + .get_wallet_info(&self.wallet_id) + .ok_or_else(|| PlatformWalletError::WalletNotFound(hex::encode(self.wallet_id)))? + .identity_manager + .managed_identity(sender) + .ok_or(PlatformWalletError::IdentityNotFound(*sender))? + .dashpay() + .public_contact_request_unconfirmed(recipient)) + } + + /// Settle the unconfirmed public request from `sender` to `recipient`, + /// if this handle's wallet generation holds one, as far as proved reads + /// allow: + /// + /// - its document found by a proved read (the sender, the recipient and + /// its account reference, DashPay's unique index): recorded as sent, as + /// a send that saw it confirmed records it, and the pair released; + /// - otherwise, the transition proved unable to execute + /// (`TransitionOutcome::CannotExecute`), or its identity contract nonce + /// proved used at a height the read that missed the document was proved + /// at or above (`TransitionOutcome::ExecutedOrSuperseded`): it never + /// wrote the document and never will, so the pair is released and a new + /// request may be sent; + /// - otherwise it may still execute: the same signed bytes are broadcast + /// again, whatever that answers, and the pair stays held. + /// + /// The nonce is read before the document: a nonce used at height `h` + /// proves the transition cannot execute after `h`, so only a document + /// read at or above `h` that misses it shows it never wrote it. + pub(super) async fn settle_unconfirmed_public_request( + &self, + sender: &Identifier, + recipient: &Identifier, + ) -> Result<(), PlatformWalletError> { + let held = { + let wm = self.wallet_manager.read().await; + wm.get_wallet_info(&self.wallet_id) + .filter(|info| Arc::ptr_eq(&info.generation, &self.generation)) + .and_then(|info| info.identity_manager.managed_identity(sender)) + .and_then(|managed| { + managed + .dashpay() + .unconfirmed_public_request(recipient) + .cloned() + }) + }; + let Some(held) = held else { + return Ok(()); + }; + let outcome = self + .sdk + .state_transition_outcome(&held.transition, OutcomeEvidence::default()) + .await + .map_err(PlatformWalletError::Sdk)?; + let (found, read_at) = self + .find_sent_contact_request(sender, recipient, held.account_reference) + .await?; + let settled = match outcome { + TransitionOutcome::CannotExecute(_) => true, + TransitionOutcome::ExecutedOrSuperseded { consumed_by_height } => { + read_at >= consumed_by_height + } + TransitionOutcome::StillPossible { .. } => false, + }; + if found.is_none() && !settled { + if matches!(outcome, TransitionOutcome::StillPossible { .. }) { + // The same bytes, so never a second request; an endpoint's + // answer proves nothing either way. + let delivery = broadcast_tracking_delivery(&self.sdk, &held.transition).await; + tracing::debug!( + sender = %sender, + recipient = %recipient, + ?delivery, + "unconfirmed public contact request broadcast again" + ); + } + return Ok(()); + } + let _lifecycle = self.generation.payment_guard().await; + if !self.is_current_generation().await { + return Ok(()); + } + match found { + Some(request) => { + self.record_sent_contact_request( + sender, + recipient, + request, + Some(held.attempt), + held.account_index, + held.contact_xpub, + ) + .await?; + } + None => { + let mut wm = self.wallet_manager.write().await; + if let Some(managed) = + generation_info_mut(&mut wm, &self.wallet_id, &self.generation) + .and_then(|info| info.identity_manager.managed_identity_mut(sender)) + { + managed.settle_unconfirmed_public_request(recipient, held.attempt); + } + } + } + Ok(()) + } - Ok(contact_request) + /// [`Self::settle_unconfirmed_public_request`], logging a read that could + /// not settle it: the request stays held. + pub(super) async fn settle_unconfirmed_public_request_logged( + &self, + sender: &Identifier, + recipient: &Identifier, + ) { + // Boxed: its reads and broadcast make a large future, which sends and + // syncs nest in their own. + if let Err(error) = + Box::pin(self.settle_unconfirmed_public_request(sender, recipient)).await + { + tracing::warn!( + sender = %sender, + recipient = %recipient, + %error, + "an unconfirmed public contact request could not be settled; it stays held" + ); + } + } + + /// Settle every unconfirmed public request this handle's wallet + /// generation holds ([`Self::settle_unconfirmed_public_request`]). + async fn settle_unconfirmed_public_requests(&self) { + let pairs: Vec<(Identifier, Identifier)> = { + let wm = self.wallet_manager.read().await; + wm.get_wallet_info(&self.wallet_id) + .filter(|info| Arc::ptr_eq(&info.generation, &self.generation)) + .map(|info| { + info.identity_manager + .all_identities() + .iter() + .filter_map(|identity| { + info.identity_manager.managed_identity(&identity.id()) + }) + .flat_map(|managed| { + let sender = managed.identity.id(); + managed + .dashpay() + .unconfirmed_public_request_recipients() + .into_iter() + .map(move |recipient| (sender, recipient)) + }) + .collect() + }) + .unwrap_or_default() + }; + for (sender, recipient) in pairs { + self.settle_unconfirmed_public_request_logged(&sender, &recipient) + .await; + } } /// Steps 1 to 4c of a contact request send, shared by the document send @@ -2113,6 +2546,10 @@ impl DashPayView<'_, B> { pub async fn sync_contact_requests_reporting( &self, ) -> Result { + // Public requests whose outcome is unconfirmed are settled first, so + // one proved on Platform is recorded, and one that can no longer + // execute frees its pair, before anything is decided from this pass. + self.settle_unconfirmed_public_requests().await; // Snapshot each identity's high-water cursors up front so the // incremental query bound is read before any mutation this sweep. let identities: Vec<(Identifier, Option, Option)> = { 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 fb1d3ea50ff..3425470a24a 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 @@ -899,7 +899,11 @@ impl DashPayView<'_, B> { /// 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. A - /// public request to the recipient is refused the same way. + /// public request to the recipient is refused the same way. While a public + /// request to the recipient is unconfirmed (its broadcast may have been + /// delivered, and no proof settled it yet), the send is refused with + /// [`PlatformWalletError::PublicContactRequestUnconfirmed`] before anything + /// is reserved or paid. /// /// 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 @@ -1039,6 +1043,16 @@ impl DashPayView<'_, B> { } Box::pin(self.read_private_store_and_keys(sender_identity_id, crypto)).await?; self.release_discharged_private_requests().await?; + // A public request to the recipient whose outcome is unconfirmed is + // settled first where proof allows; while it stands nothing is + // reserved. + Box::pin( + self.settle_unconfirmed_public_request_logged( + sender_identity_id, + recipient_identity_id, + ), + ) + .await; // Held beyond the sender identity: a journaled payment's pair outlives // the identity's removal, and holds for a sender loaded after it was // restored, until its record is discharged. @@ -1055,17 +1069,25 @@ impl DashPayView<'_, B> { { return None; } + if managed + .dashpay() + .public_contact_request_unconfirmed(recipient_identity_id) + { + return Some(Err(PlatformWalletError::PublicContactRequestUnconfirmed( + *recipient_identity_id, + ))); + } if held_by_wallet { - return Some(None); + return Some(Ok(None)); } - Some(managed.reserve_private_reply(*recipient_identity_id)) + Some(Ok(managed.reserve_private_reply(*recipient_identity_id))) }) .await .map_err(removed_before_send)?; let Some(reserved) = reserved else { return Ok(None); }; - let attempt = reserved.ok_or(PlatformWalletError::PrivateContactRequestPending( + let attempt = reserved?.ok_or(PlatformWalletError::PrivateContactRequestPending( *recipient_identity_id, ))?; let mut reservation = PrivateReplyReservation { @@ -2695,6 +2717,7 @@ impl DashPayView<'_, B> { #[cfg(test)] mod tests { + use super::super::contact_requests::sent_contact_request_query; use super::*; use crate::changeset::{ ClientStartState, ClientWalletStartState, IdentityManagerStartState, @@ -2728,13 +2751,18 @@ mod tests { use dash_sdk::platform::ContextProvider; use dpp::address_funds::AddressWitness; use dpp::data_contract::{DataContract, TokenConfiguration}; + use dpp::document::{Document, DocumentV0}; 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}; + use dpp::platform_value::Value; + use dpp::state_transition::batch_transition::BatchTransition; + use dpp::state_transition::StateTransition; use dpp::version::PlatformVersion; use dpp::ProtocolError; use key_wallet::account::account_collection::DashpayAccountKey; + use key_wallet::bip32::DerivationPath; use key_wallet::mnemonic::Mnemonic; use key_wallet::wallet::initialization::WalletAccountCreationOptions; use key_wallet::Network; @@ -5985,6 +6013,606 @@ mod tests { .await } + /// A signer counting the signatures it makes. + #[derive(Debug)] + struct CountingSigner { + inner: simple_signer::signer::SimpleSigner, + signed: Arc, + } + + #[async_trait::async_trait] + impl Signer for CountingSigner { + async fn sign( + &self, + key: &IdentityPublicKey, + data: &[u8], + ) -> Result { + self.signed.fetch_add(1, Ordering::SeqCst); + self.inner.sign(key, data).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) + } + } + + /// Answer `sender`'s DashPay identity contract nonce reads on `wallet`'s + /// SDK with `nonce`, in place of an earlier answer. + async fn expect_dashpay_nonce(wallet: &PlatformWallet, sender: &Identity, nonce: u64) { + use dash_sdk::query_types::IdentityContractNonceFetcher; + + let mut sdk = (*wallet.identity().sdk).clone(); + let key = ( + sender.id(), + dashpay_contract().expect("bundled DashPay").id(), + ); + sdk.mock() + .remove_fetch_expectation::(key) + .await; + sdk.mock() + .expect_fetch(key, Some(IdentityContractNonceFetcher(nonce))) + .await + .expect("the nonce read"); + } + + /// Answer the read of the request from `sender` to `recipient` carrying + /// `account_reference` on `wallet`'s SDK with `document`, `None` proving + /// there is none. + async fn expect_sent_request( + wallet: &PlatformWallet, + sender: &Identity, + recipient: &Identity, + account_reference: u32, + document: Option, + ) { + use dash_sdk::query_types::Documents; + use dpp::document::DocumentV0Getters; + + let mut sdk = (*wallet.identity().sdk).clone(); + let mut documents = Documents::new(); + if let Some(document) = document { + documents.insert(document.id(), Some(document)); + } + sdk.mock() + .expect_fetch_many::( + sent_contact_request_query(&sender.id(), &recipient.id(), account_reference) + .expect("the query"), + Some(documents), + ) + .await + .expect("the request read"); + } + + /// `sender`'s `contactRequest` document to `recipient` carrying + /// `account_reference`, as Platform holds it. + fn sent_request_document( + sender: &Identity, + recipient: &Identity, + account_reference: u32, + ) -> Document { + Document::V0(DocumentV0 { + id: Identifier::from([0x5E; 32]), + owner_id: sender.id(), + properties: BTreeMap::from([ + ( + "toUserId".to_string(), + Value::Identifier(recipient.id().to_buffer()), + ), + ("senderKeyIndex".to_string(), Value::U32(ENCRYPTION_KEY_ID)), + ( + "recipientKeyIndex".to_string(), + Value::U32(DECRYPTION_KEY_ID), + ), + ( + "accountReference".to_string(), + Value::U32(account_reference), + ), + ("encryptedPublicKey".to_string(), Value::Bytes(vec![7; 96])), + ]), + created_at: Some(BLOCK_TIME_MS), + created_at_core_block_height: Some(100), + ..Default::default() + }) + } + + /// The unconfirmed public request from `sender` to `recipient` on + /// `wallet`: its transition and account reference. + async fn unconfirmed_to( + wallet: &PlatformWallet, + sender: [u8; 32], + recipient: &Identity, + ) -> Option<(StateTransition, u32)> { + managed(wallet, sender, |managed| { + managed + .dashpay() + .unconfirmed_public_request(&recipient.id()) + .map(|held| (held.transition.clone(), held.account_reference)) + }) + .await + } + + /// How many times `transition`'s own bytes were broadcast on `wallet`'s + /// SDK. + async fn broadcasts_of(wallet: &PlatformWallet, transition: &StateTransition) -> usize { + use dash_sdk::platform::transition::broadcast_request::BroadcastRequestForStateTransition; + + let mut sdk = (*wallet.identity().sdk).clone(); + let request = transition + .broadcast_request_for_state_transition() + .expect("encodes"); + let count = sdk.mock().request_count(&request).await; + count + } + + /// Track `request` as received by its recipient on `wallet`. + async fn track_incoming(wallet: &PlatformWallet, 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(&request.recipient_id) + .expect("the recipient") + .add_incoming_contact_request(request.clone(), &iw.persister) + .expect("the incoming request"); + } + + /// A public accept whose broadcast may have been delivered reports the + /// SDK's unconfirmed outcome (FFI code 20) and leaves its pair held as an + /// unconfirmed public request, which the per-pair read reports apart from + /// a private request pending. A public sync that cannot read the request + /// leaves it held, and a second accept is refused with nothing signed. + /// Proof ends it: the request proved on Platform is recorded as sent and + /// the pair released; its nonce proved used, with no request on Platform + /// at or above that height, releases the pair, and a fresh accept signs + /// and submits again. + #[tokio::test] + async fn should_hold_an_unconfirmed_public_accept_until_proof_settles_it() { + let alice = identity(ALICE_PHRASE, ALICE); + let bob = identity(BOB_PHRASE, BOB); + let alice_crypto = SeedCryptoProvider::from_seed(seed(ALICE_PHRASE), Network::Testnet); + let signed = Arc::new(AtomicUsize::new(0)); + let alice_signer = CountingSigner { + inner: signer(ALICE_PHRASE, &alice), + signed: Arc::clone(&signed), + }; + let request = public_request(&bob, &alice, 0); + + for recorded in [true, false] { + let (_manager, alice_wallet) = + wallet(ALICE_PHRASE, &alice, &[&alice, &bob], vec![]).await; + track_incoming(&alice_wallet, &request).await; + expect_dashpay_nonce(&alice_wallet, &alice, 3).await; + let view = alice_wallet.identity().dashpay(); + + let accepted = view + .accept_contact_request_with_external_signer(&request, &alice_signer, &alice_crypto) + .await; + assert!( + matches!(&accepted, Err(PlatformWalletError::Sdk(error)) + if error.is_state_transition_result_unconfirmed()), + "{accepted:?}" + ); + assert!(view + .public_contact_request_unconfirmed(&alice.id(), &bob.id()) + .await + .expect("the read")); + assert!( + !view + .private_pair_held(&alice.id(), &bob.id()) + .await + .expect("the read"), + "no private request is pending" + ); + let (_, reference) = unconfirmed_to(&alice_wallet, ALICE, &bob) + .await + .expect("held"); + + // The sync cannot read the request: still held. + let _ = view.sync_contact_requests_reporting().await; + assert!(view + .public_contact_request_unconfirmed(&alice.id(), &bob.id()) + .await + .expect("the read")); + let signatures = signed.load(Ordering::SeqCst); + let again = view + .accept_contact_request_with_external_signer(&request, &alice_signer, &alice_crypto) + .await; + assert!( + matches!(&again, Err(PlatformWalletError::PublicContactRequestUnconfirmed(recipient)) + if *recipient == bob.id()), + "{again:?}" + ); + assert_eq!( + signed.load(Ordering::SeqCst), + signatures, + "nothing is signed" + ); + + if recorded { + expect_sent_request( + &alice_wallet, + &alice, + &bob, + reference, + Some(sent_request_document(&alice, &bob, reference)), + ) + .await; + let _ = view.sync_contact_requests_reporting().await; + assert!(!view + .public_contact_request_unconfirmed(&alice.id(), &bob.id()) + .await + .expect("the read")); + managed(&alice_wallet, ALICE, |managed| { + assert_eq!( + managed + .dashpay() + .outgoing_request(&bob.id()) + .map(|request| request.account_reference), + Some(reference), + "recorded as sent" + ); + }) + .await; + } else { + // The nonce it took (4) is used, and no request is on Platform. + expect_dashpay_nonce(&alice_wallet, &alice, 4).await; + expect_sent_request(&alice_wallet, &alice, &bob, reference, None).await; + let _ = view.sync_contact_requests_reporting().await; + assert!(!view + .public_contact_request_unconfirmed(&alice.id(), &bob.id()) + .await + .expect("the read")); + managed(&alice_wallet, ALICE, |managed| { + assert!(managed.dashpay().outgoing_request(&bob.id()).is_none()); + }) + .await; + let fresh = view + .accept_contact_request_with_external_signer( + &request, + &alice_signer, + &alice_crypto, + ) + .await; + assert!( + signed.load(Ordering::SeqCst) > signatures, + "a fresh accept signs: {fresh:?}" + ); + assert!( + matches!(&fresh, Err(PlatformWalletError::Sdk(error)) + if error.is_state_transition_result_unconfirmed()), + "this mock answers no broadcast: {fresh:?}" + ); + } + } + } + + /// While an unconfirmed public request may still execute, a sync + /// broadcasts its own signed bytes again and signs nothing. A nonce proved + /// used above the height the read that missed its document was proved at + /// does not release it: the request may have executed in between. Once + /// that read is at or above the nonce's height, it does. + #[tokio::test] + async fn should_broadcast_an_unconfirmed_public_request_again_as_its_own_bytes() { + use dash_sdk::query_types::{Documents, IdentityContractNonceFetcher}; + + let alice = identity(ALICE_PHRASE, ALICE); + let bob = identity(BOB_PHRASE, BOB); + let alice_crypto = SeedCryptoProvider::from_seed(seed(ALICE_PHRASE), Network::Testnet); + let signed = Arc::new(AtomicUsize::new(0)); + let alice_signer = CountingSigner { + inner: signer(ALICE_PHRASE, &alice), + signed: Arc::clone(&signed), + }; + let (_manager, alice_wallet) = wallet(ALICE_PHRASE, &alice, &[&alice, &bob], vec![]).await; + expect_dashpay_nonce(&alice_wallet, &alice, 3).await; + let view = alice_wallet.identity().dashpay(); + let sent = view + .send_contact_request_with_external_signer( + &alice.id(), + &bob.id(), + None, + AutoAcceptProofSource::None, + &alice_signer, + &alice_crypto, + ) + .await; + assert!( + matches!(&sent, Err(PlatformWalletError::Sdk(error)) + if error.is_state_transition_result_unconfirmed()), + "{sent:?}" + ); + let signatures = signed.load(Ordering::SeqCst); + let (transition, reference) = unconfirmed_to(&alice_wallet, ALICE, &bob) + .await + .expect("held"); + expect_sent_request(&alice_wallet, &alice, &bob, reference, None).await; + + let before = broadcasts_of(&alice_wallet, &transition).await; + assert!(before > 0, "the send broadcast it"); + let _ = view.sync_contact_requests_reporting().await; + let after = broadcasts_of(&alice_wallet, &transition).await; + assert!(after > before, "broadcast again"); + assert_eq!(signed.load(Ordering::SeqCst), signatures, "nothing signed"); + assert_eq!( + unconfirmed_to(&alice_wallet, ALICE, &bob).await, + Some((transition.clone(), reference)), + "the same request stays held" + ); + + // Its nonce used at 10, the request read missing at 9 (within the + // SDK's one-block tolerance, so not refused as stale): held, as the + // request may have executed at 10, and as the nonce is used, not + // broadcast again. + expect_dashpay_nonce(&alice_wallet, &alice, 4).await; + let mut sdk = (*alice_wallet.identity().sdk).clone(); + sdk.mock() + .set_response_height_of::(10) + .set_response_height_of::(9); + let _ = view.sync_contact_requests_reporting().await; + assert!(view + .public_contact_request_unconfirmed(&alice.id(), &bob.id()) + .await + .expect("the read")); + assert_eq!(broadcasts_of(&alice_wallet, &transition).await, after); + + sdk.mock().set_response_height_of::(10); + let _ = view.sync_contact_requests_reporting().await; + assert!(!view + .public_contact_request_unconfirmed(&alice.id(), &bob.id()) + .await + .expect("the read")); + } + + /// A handle taken before its wallet was removed and created again under + /// its id settles nothing of the replacement's unconfirmed public request, + /// whatever the proofs show, and claims nothing on its pair. + #[tokio::test] + async fn should_leave_a_replacement_s_unconfirmed_public_request_to_it() { + 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 = manager_on(Arc::new(NoPersistence)).await; + #[cfg(feature = "shielded")] + let _store = { + let dir = tempfile::tempdir().expect("temporary shielded store"); + manager + .configure_shielded(dir.path().join("tree.sqlite")) + .await + .expect("the shielded store is configured"); + dir + }; + let first = create_holding(&manager, ALICE_PHRASE, &alice).await; + let wallet_id = first.wallet_id(); + let stale = first.identity().dashpay(); + manager.remove_wallet(&wallet_id).await.expect("removed"); + let again = create_holding(&manager, ALICE_PHRASE, &alice).await; + assert_eq!(again.wallet_id(), wallet_id); + expect_dashpay_nonce(&again, &alice, 3).await; + let sent = again + .identity() + .dashpay() + .send_contact_request_with_external_signer( + &alice.id(), + &bob.id(), + None, + AutoAcceptProofSource::None, + &alice_signer, + &alice_crypto, + ) + .await; + assert!( + matches!(&sent, Err(PlatformWalletError::Sdk(error)) + if error.is_state_transition_result_unconfirmed()), + "{sent:?}" + ); + let (transition, reference) = unconfirmed_to(&again, ALICE, &bob).await.expect("held"); + + // Proofs that would settle it, as recorded: the stale handle acts on + // none of it. + expect_sent_request( + &again, + &alice, + &bob, + reference, + Some(sent_request_document(&alice, &bob, reference)), + ) + .await; + stale + .settle_unconfirmed_public_request(&alice.id(), &bob.id()) + .await + .expect("nothing to settle"); + assert_eq!( + unconfirmed_to(&again, ALICE, &bob).await, + Some((transition, reference)), + "the replacement's request stays held" + ); + managed(&again, ALICE, |managed| { + assert!(managed.dashpay().outgoing_request(&bob.id()).is_none()); + }) + .await; + let claimed = stale + .claim_public_pair(&alice.id(), &bob.id(), false, false) + .await; + assert!( + matches!( + claimed, + Err(PlatformWalletError::WalletRemovedBeforeSend(_)) + ), + "{:?}", + claimed.err() + ); + + // The replacement's own handle settles it. + again + .identity() + .dashpay() + .settle_unconfirmed_public_request(&alice.id(), &bob.id()) + .await + .expect("settled"); + assert!(unconfirmed_to(&again, ALICE, &bob).await.is_none()); + } + + /// A process that starts afresh holds no unconfirmed public request: a + /// request the earlier process broadcast, and that executed after it + /// ended, is found by the proved read before a new one is signed, and + /// adopted as sent with nothing signed or broadcast. + #[tokio::test] + async fn should_adopt_a_request_already_on_platform_instead_of_sending_another() { + let alice = identity(ALICE_PHRASE, ALICE); + let bob = identity(BOB_PHRASE, BOB); + let alice_crypto = SeedCryptoProvider::from_seed(seed(ALICE_PHRASE), Network::Testnet); + let signed = Arc::new(AtomicUsize::new(0)); + let alice_signer = CountingSigner { + inner: signer(ALICE_PHRASE, &alice), + signed: Arc::clone(&signed), + }; + // The earlier process: its request is unconfirmed when it ends. + let reference = { + let (_manager, earlier) = wallet(ALICE_PHRASE, &alice, &[&alice, &bob], vec![]).await; + expect_dashpay_nonce(&earlier, &alice, 3).await; + let _ = earlier + .identity() + .dashpay() + .send_contact_request_with_external_signer( + &alice.id(), + &bob.id(), + None, + AutoAcceptProofSource::None, + &alice_signer, + &alice_crypto, + ) + .await; + unconfirmed_to(&earlier, ALICE, &bob).await.expect("held").1 + }; + + // The new process, the request on Platform. + let (_manager, restarted) = wallet(ALICE_PHRASE, &alice, &[&alice, &bob], vec![]).await; + expect_dashpay_nonce(&restarted, &alice, 4).await; + expect_sent_request( + &restarted, + &alice, + &bob, + reference, + Some(sent_request_document(&alice, &bob, reference)), + ) + .await; + let signatures = signed.load(Ordering::SeqCst); + let adopted = restarted + .identity() + .dashpay() + .send_contact_request_with_external_signer( + &alice.id(), + &bob.id(), + None, + AutoAcceptProofSource::None, + &alice_signer, + &alice_crypto, + ) + .await + .expect("adopted"); + assert_eq!(adopted.account_reference, reference); + assert_eq!(signed.load(Ordering::SeqCst), signatures, "nothing signed"); + assert!(unconfirmed_to(&restarted, ALICE, &bob).await.is_none()); + managed(&restarted, ALICE, |managed| { + assert_eq!( + managed + .dashpay() + .outgoing_request(&bob.id()) + .map(|request| request.account_reference), + Some(reference) + ); + }) + .await; + } + + /// An unconfirmed public request holds its pair against a private request + /// too, by its own refusal and with nothing paid, and keeps its sender + /// from being removed, which would drop the hold. + #[tokio::test] + async fn should_refuse_a_private_request_and_a_removal_while_a_public_one_is_unconfirmed() { + let alice = identity(ALICE_PHRASE, ALICE); + let bob = identity(BOB_PHRASE, BOB); + let alice_signer = signer(ALICE_PHRASE, &alice); + let alice_crypto = SeedCryptoProvider::from_seed(seed(ALICE_PHRASE), Network::Testnet); + let persistence = RecordingPersistence::attesting( + PersistenceCapabilities::PRIVATE_CONTACT_SCAN_STATE_RESTART, + ); + let manager = manager_on(Arc::clone(&persistence)).await; + #[cfg(feature = "shielded")] + let _store = { + let dir = tempfile::tempdir().expect("temporary shielded store"); + manager + .configure_shielded(dir.path().join("tree.sqlite")) + .await + .expect("the shielded store is configured"); + dir + }; + let alice_wallet = create_holding(&manager, ALICE_PHRASE, &alice).await; + let contact_xpub = alice_crypto + .receiving_xpub(&"m/9'/1'/15'/0'".parse::().expect("a path")) + .await + .expect("an xpub"); + { + 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") + .hold_unconfirmed_public_request( + bob.id(), + StateTransition::Batch(BatchTransition::V0(Default::default())), + 0, + 0, + contact_xpub, + ); + } + let paid = 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(&paid), + ) + .await; + assert!( + matches!(&sent, Err(PlatformWalletError::PublicContactRequestUnconfirmed(recipient)) + if *recipient == bob.id()), + "{sent:?}" + ); + assert!(paid.lock().expect("paid").is_empty(), "nothing is paid"); + + let iw = alice_wallet.identity(); + let mut wm = iw.wallet_manager.write().await; + let removed = wm + .get_wallet_info_mut(&alice_wallet.wallet_id()) + .expect("wallet info") + .identity_manager + .remove_identity(&alice.id(), &iw.persister); + assert!( + matches!(&removed, Err(PlatformWalletError::PublicContactRequestUnconfirmed(recipient)) + if *recipient == bob.id()), + "{:?}", + removed.err() + ); + } + /// The position records stored for `owner` so far. fn positions_stored(persistence: &RecordingPersistence, owner: &Identifier) -> usize { persistence diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/sdk_writer.rs b/packages/rs-platform-wallet/src/wallet/identity/network/sdk_writer.rs index 765074e7b7a..cb4bdf12651 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/sdk_writer.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/sdk_writer.rs @@ -1,9 +1,9 @@ //! Concrete helper over the SDK's DashPay write/broadcast surface. //! //! The two operations `IdentityWallet` performs over the SDK — -//! [`Sdk::send_contact_request`](dash_sdk::Sdk::send_contact_request) +//! [`Sdk::sign_contact_request`](dash_sdk::Sdk::sign_contact_request) //! and a document put — cannot be expressed as plain method calls at -//! the call sites: `send_contact_request` is generic over **seven** +//! the call sites: `sign_contact_request` is generic over **seven** //! type parameters (the signer plus three ECDH/xpub closure pairs) and //! the document put rides on the signer-generic //! [`PutDocument`](dash_sdk::platform::transition::put_document::PutDocument) @@ -28,7 +28,7 @@ use dpp::identity::{Identity, IdentityPublicKey}; use dash_sdk::dapi_grpc::platform::v0::ResponseMetadata; use dash_sdk::platform::dashpay::{ ContactRequestInput, EcdhProvider, RecipientIdentity, SendContactRequestInput, - SendContactRequestResult, + SignedContactRequest, }; use crate::error::PlatformWalletError; @@ -150,7 +150,7 @@ pub(crate) struct PutDocumentParams<'a> { /// /// Held as a field on [`IdentityWallet`](super::IdentityWallet), /// forwarding to the real SDK. Its two methods erase the SDK's -/// generic write signatures (`send_contact_request`'s seven type +/// generic write signatures (`sign_contact_request`'s seven type /// params, the signer-generic `PutDocument`) behind concrete, by-value /// inputs so the call sites in `contact_requests.rs` / /// `contact_info.rs` stay simple. @@ -179,11 +179,13 @@ impl SdkWriter { } /// Build the ECDH provider + xpub closure from the pre-derived - /// inputs and broadcast the contact-request document. - pub(crate) async fn send_contact_request( + /// inputs and sign the contact-request document's create transition, + /// which the caller broadcasts: it keeps the signed bytes, so a retry + /// broadcasts the same transition rather than signing a second request. + pub(crate) async fn sign_contact_request( &self, params: SendContactRequestParams<'_>, - ) -> Result { + ) -> Result { let SendContactRequestParams { sender_identity, recipient_identity, @@ -247,7 +249,7 @@ impl SdkWriter { let xpub_bytes_clone = xpub_bytes.clone(); self.sdk - .send_contact_request(send_input, ecdh_provider, |_account_ref: u32| async move { + .sign_contact_request(send_input, ecdh_provider, |_account_ref: u32| async move { Ok::, dash_sdk::Error>(xpub_bytes_clone) }) .await 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 b5148aca61b..ac70cc4f35a 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,7 +5,7 @@ //! - Receiving contact requests (incoming) //! - Automatically establishing contacts when both parties send requests -use super::dashpay::PendingPrivateReply; +use super::dashpay::{PendingPrivateReply, UnconfirmedPublicRequest}; use super::ManagedIdentity; use crate::changeset::{ ContactChangeSet, ContactRequestEntry, PrivateContactRequestsPosition, @@ -18,6 +18,8 @@ use dpp::identity::accessors::IdentityGettersV0; use dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; use dpp::identity::{KeyType, Purpose}; use dpp::prelude::Identifier; +use dpp::state_transition::StateTransition; +use key_wallet::bip32::ExtendedPubKey; use platform_encryption::account_reference_version; use sha2::{Digest, Sha256}; use std::sync::atomic::{AtomicU64, Ordering}; @@ -1074,7 +1076,8 @@ 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. + // A public request being sent to the recipient, or one unconfirmed, + // holds the pair too. if self .dashpay .pending_private_replies @@ -1083,6 +1086,10 @@ impl ManagedIdentity { .dashpay .public_contact_request_claims .contains_key(&recipient) + || self + .dashpay + .unconfirmed_public_requests + .contains_key(&recipient) { return None; } @@ -1118,6 +1125,53 @@ impl ManagedIdentity { } } + /// Hold the pair with `recipient` for the public request `transition` + /// signs, whose broadcast may have been delivered, under a new attempt it + /// returns: until that attempt settles it + /// ([`Self::settle_unconfirmed_public_request`]), no other request to the + /// recipient is signed, public or private. A request already held for the + /// recipient is replaced, as only one can be signed while another is held. + pub(crate) fn hold_unconfirmed_public_request( + &mut self, + recipient: Identifier, + transition: StateTransition, + account_reference: u32, + account_index: u32, + contact_xpub: ExtendedPubKey, + ) -> u64 { + let attempt = next_contact_request_attempt(); + self.dashpay.unconfirmed_public_requests.insert( + recipient, + UnconfirmedPublicRequest { + attempt, + transition, + account_reference, + account_index, + contact_xpub, + }, + ); + attempt + } + + /// Settle the unconfirmed public request `attempt` holds to `recipient`: + /// a proved read showed its document on Platform, or showed the + /// transition can no longer write it. Nothing when another attempt holds + /// the recipient by now. + pub(crate) fn settle_unconfirmed_public_request( + &mut self, + recipient: &Identifier, + attempt: u64, + ) { + if self + .dashpay + .unconfirmed_public_requests + .get(recipient) + .is_some_and(|held| held.attempt == attempt) + { + self.dashpay.unconfirmed_public_requests.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_contact_request_attempt(); 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 b4b334e29a8..a9b1fdeca06 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 @@ -11,6 +11,8 @@ use crate::wallet::identity::{ ContactProfileEntry, ContactRequest, DashPayProfile, EstablishedContact, PaymentEntry, }; use dpp::prelude::Identifier; +use dpp::state_transition::StateTransition; +use key_wallet::bip32::ExtendedPubKey; use std::collections::{BTreeMap, BTreeSet}; /// How an identity and a contact exchanged private contact requests (protocol @@ -38,6 +40,25 @@ pub(crate) struct PendingPrivateReply { pub(crate) commitment: Option<[u8; 32]>, } +/// A public contact request to one recipient whose broadcast may have been +/// delivered, and whose outcome no proof settled yet +/// ([`DashPayState::public_contact_request_unconfirmed`]): the signed +/// transition, which a retry broadcasts again, and what recording the request +/// takes once it is proved on Platform. Only the attempt that held it settles +/// it. +#[derive(Debug, Clone)] +pub(crate) struct UnconfirmedPublicRequest { + pub(crate) attempt: u64, + pub(crate) transition: StateTransition, + /// The account reference the request carries: its document is looked up + /// by the sender, the recipient and it, DashPay's unique index. + pub(crate) account_reference: u32, + /// Our receiving (friendship) account for the pair, registered once the + /// request is recorded, as a send registers it. + pub(crate) account_index: u32, + pub(crate) contact_xpub: ExtendedPubKey, +} + /// DashPay social state carried by one [`ManagedIdentity`](super::ManagedIdentity). #[derive(Debug, Clone, Default)] pub struct DashPayState { @@ -180,6 +201,18 @@ pub struct DashPayState { /// same id cannot release a claim of the new wallet. In memory only. pub(super) public_contact_request_claims: BTreeMap>, + /// The public request to each recipient whose broadcast may have been + /// delivered while no proof settled its outcome: it may still execute, so + /// no other request to the recipient is signed, public or private, until a + /// proved read shows its document on Platform (it is then recorded as + /// sent) or shows the transition can no longer write it. A retry + /// broadcasts the same signed bytes. In memory only, bound to the wallet + /// generation that holds this state: a process that starts afresh reads + /// the identity contract nonce from Platform and finds a request already + /// on Platform before it sends, which is what keeps a second one from + /// executing then. + pub(super) unconfirmed_public_requests: BTreeMap, + /// Whether this identity was loaded with a private contact request /// payment that may be unresolved /// (`PrivateContactRequestsPosition::private_payments_unresolved`) and no @@ -374,15 +407,38 @@ impl DashPayState { /// A recipient this identity holds a contact request in flight to, if /// any: a private request it is paying for or has not read back /// ([`Self::private_reply_pending`]), or a public one claimed and not yet - /// recorded ([`Self::public_contact_request_claimed`]). + /// recorded ([`Self::public_contact_request_claimed`]) or unconfirmed + /// ([`Self::public_contact_request_unconfirmed`]). pub fn contact_request_in_flight(&self) -> Option { self.pending_private_replies .keys() .chain(self.public_contact_request_claims.keys()) + .chain(self.unconfirmed_public_requests.keys()) .next() .copied() } + /// Whether a public request to `contact` was broadcast and may have been + /// delivered, with no proof settling its outcome yet; see the field doc. + pub fn public_contact_request_unconfirmed(&self, contact: &Identifier) -> bool { + self.unconfirmed_public_requests.contains_key(contact) + } + + /// The unconfirmed public request to `contact`, if any + /// ([`Self::public_contact_request_unconfirmed`]). + pub(crate) fn unconfirmed_public_request( + &self, + contact: &Identifier, + ) -> Option<&UnconfirmedPublicRequest> { + self.unconfirmed_public_requests.get(contact) + } + + /// The recipients this identity holds an unconfirmed public request to + /// ([`Self::public_contact_request_unconfirmed`]). + pub(crate) fn unconfirmed_public_request_recipients(&self) -> Vec { + self.unconfirmed_public_requests.keys().copied().collect() + } + /// 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/identity/state/manager/lifecycle.rs b/packages/rs-platform-wallet/src/wallet/identity/state/manager/lifecycle.rs index e376103283e..294e836f9d8 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/state/manager/lifecycle.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/state/manager/lifecycle.rs @@ -213,7 +213,9 @@ impl IdentityManager { /// request channel is held: a contact request in flight, private or /// public, or a private payment that may still execute, whether its own /// state or the wallet's journal holds it - /// ([`PlatformWalletError::PrivateContactRequestPending`]), or one from an + /// ([`PlatformWalletError::PrivateContactRequestPending`]; a public one + /// whose outcome is unconfirmed is + /// [`PlatformWalletError::PublicContactRequestUnconfirmed`]), or one from an /// earlier process not restored yet /// ([`PlatformWalletError::PrivateContactPaymentsNotRestored`]). Removed /// and loaded again, the identity would start with none of those holds, @@ -226,6 +228,15 @@ impl IdentityManager { ) -> Result { if let Some(managed) = self.managed_identity(identity_id) { let dashpay = managed.dashpay(); + if let Some(recipient) = dashpay + .unconfirmed_public_request_recipients() + .first() + .copied() + { + return Err(PlatformWalletError::PublicContactRequestUnconfirmed( + recipient, + )); + } if let Some(recipient) = dashpay .contact_request_in_flight() .or_else(|| persister.private_sender_journaled(identity_id)) From 54ae054b783111553f2a5788de79ce59b3c1b6b4 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Tue, 10 Nov 2026 22:56:53 +0700 Subject: [PATCH 263/269] feat(platform-wallet-ffi): code 76 for an unconfirmed public contact request PlatformWalletError::PublicContactRequestUnconfirmed maps to ErrorPublicContactRequestUnconfirmed (76) in the blanket conversion and in map_spend_result, which the private send and accepts use, so a host can tell a public request awaiting its outcome from a private request pending (71). Co-Authored-By: Claude Opus 5.5 --- .../ERROR_CODE_REGISTRY.md | 1 + packages/rs-platform-wallet-ffi/src/error.rs | 38 +++++++++++++++++++ .../src/shielded_send.rs | 3 ++ 3 files changed, 42 insertions(+) diff --git a/packages/rs-platform-wallet-ffi/ERROR_CODE_REGISTRY.md b/packages/rs-platform-wallet-ffi/ERROR_CODE_REGISTRY.md index 83c5ed3214d..864882deb95 100644 --- a/packages/rs-platform-wallet-ffi/ERROR_CODE_REGISTRY.md +++ b/packages/rs-platform-wallet-ffi/ERROR_CODE_REGISTRY.md @@ -191,6 +191,7 @@ Fork-era numbers remain in the collision history, which is immutable record. | 73 | `ErrorPrivateContactRecoveryUnsupported` | DashPay private contact requests | Proposed. Maps `PlatformWalletError::PrivateContactRecoveryUnsupported`: the persister does not declare `PRIVATE_CONTACT_REQUEST_SCAN_STATE` (bit 15) 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. The FFI constant for bit 15 and the Swift and Kotlin typing are added by the FFI PR (#5812) and the Kotlin PR (#5813) | | 74 | `ErrorPrivateContactStoreNotRead` | DashPay private contact requests | Proposed. Maps `PlatformWalletError::PrivateContactStoreNotRead`: DashPay's private contact request store was not read to its end for the identity, with the keys Platform proves it holds, before a contact request or acceptance, public or private, was decided (every one reads it from the identity's cursor first). Nothing was reserved, sealed, paid or broadcast; calling again continues the read from where it stopped. A node behind the store already seen, or keys that kept changing, stop it. It was reported as 71 before, which says not to submit again. Mapped by the blanket conversion and kept by the shielded spend contract (`map_spend_result`). Swift and Kotlin typing is added by the FFI PR (#5812) and the Kotlin PR (#5813) | | 75 | `ErrorSdkPinnedBelowProtocolVersion` | DashPay private contact requests | Proposed. The SDK refused a request without sending it because the SDK itself is pinned below the protocol version the request needs (`dash_sdk::Error::ProtocolVersionNotActive` with `pinned`), whatever the network runs: for example every contact request's private store read with the SDK pinned below protocol version 15 on a network at 15. A network upgrade does not lift it; only raising or removing the pin does. Before, it shared 70 with a network proven below the version, which lifts by itself. Mapped by the blanket conversion. Swift and Kotlin typing is added by the FFI PR (#5812) and the Kotlin PR (#5813) | +| 76 | `ErrorPublicContactRequestUnconfirmed` | DashPay private contact requests | Proposed. Maps `PlatformWalletError::PublicContactRequestUnconfirmed`: a public contact request to that identity was broadcast and may have been delivered, and no proof settled its outcome yet, so the wallet holds the pair and signs nothing new to it, public or private. The send or accept that broadcast it reported 20; every later one is refused with 76 until a public contact request sync settles it (its document proved on Platform: recorded as sent; its transition proved unable to write it: the pair freed), broadcasting the same signed bytes again meanwhile. Not a private request pending (71). Mapped by the blanket conversion and by `map_spend_result`. Swift and Kotlin typing is added by the FFI PR (#5812) and the Kotlin PR (#5813) | **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 60e7c2fb21c..f6fa401a553 100644 --- a/packages/rs-platform-wallet-ffi/src/error.rs +++ b/packages/rs-platform-wallet-ffi/src/error.rs @@ -753,6 +753,18 @@ pub enum PlatformWalletFFIResultCode { /// [`Self::ErrorProtocolVersionNotActive`] (70). ErrorSdkPinnedBelowProtocolVersion = 75, + /// Maps `PlatformWalletError::PublicContactRequestUnconfirmed`: a public + /// contact request to that identity was broadcast and may have been + /// delivered (its own send or accept reported + /// [`Self::ErrorTransactionBroadcastUnconfirmed`] (20)), and no proof + /// settled its outcome yet. Nothing new was signed. Unconfirmed, not + /// failed: do not submit it again. A public contact request sync settles + /// it, recording the request once its document is proved on Platform and + /// freeing the pair once its transition is proved unable to write it. + /// A private request pending to the identity is + /// [`Self::ErrorPrivateContactRequestPending`] (71). + ErrorPublicContactRequestUnconfirmed = 76, + /// The named thing does not exist. /// /// Originally (and still mostly) the code for every `Option` returned as an @@ -1227,6 +1239,12 @@ impl From for PlatformWalletFFIResult { PlatformWalletError::PrivateContactRequestPending(..) => { PlatformWalletFFIResultCode::ErrorPrivateContactRequestPending } + // A public request to the contact was broadcast and its outcome is + // not proved yet: unconfirmed, not failed, so the host must not + // submit it again. + PlatformWalletError::PublicContactRequestUnconfirmed(..) => { + PlatformWalletFFIResultCode::ErrorPublicContactRequestUnconfirmed + } // The wallet's private payments from an earlier process are not // restored yet: wallet readiness, not one contact's pending request. PlatformWalletError::PrivateContactPaymentsNotRestored => { @@ -3079,6 +3097,26 @@ mod private_contact_request_error_tests { ); } + /// A public request to a contact whose outcome is unconfirmed holds the + /// pair by its own code, not as a private request pending, whichever path + /// reports it: the blanket conversion, and the shielded spend contract the + /// private send and accepts use. + #[test] + fn should_report_an_unconfirmed_public_request_by_its_own_code() { + let unconfirmed = || PlatformWalletError::PublicContactRequestUnconfirmed([7; 32].into()); + let result = PlatformWalletFFIResult::from(unconfirmed()); + assert_eq!( + result.code, + PlatformWalletFFIResultCode::ErrorPublicContactRequestUnconfirmed + ); + assert_eq!(result.code as u32, 76); + #[cfg(feature = "shielded")] + assert_eq!( + map_spend_result(Err(unconfirmed()), "private contact request").code, + PlatformWalletFFIResultCode::ErrorPublicContactRequestUnconfirmed + ); + } + /// A public contact request a loaded wallet refuses until its private /// payments are restored reaches the host by its own code, not as a /// contact's pending request. diff --git a/packages/rs-platform-wallet-ffi/src/shielded_send.rs b/packages/rs-platform-wallet-ffi/src/shielded_send.rs index 77109f7da91..df107b89a24 100644 --- a/packages/rs-platform-wallet-ffi/src/shielded_send.rs +++ b/packages/rs-platform-wallet-ffi/src/shielded_send.rs @@ -635,10 +635,13 @@ pub(crate) fn map_spend_result( // read before any payment keeps the blanket conversion's split: a // lasting refusal is `ErrorProtocolVersionNotActive`, a transient one // the catch-all. + // So does a public request to the pair whose outcome is unconfirmed, + // which holds the private send and accepts as it holds a public one. // So does a private store read that did not finish before deciding // (nothing reserved or sent), and a pair whose requests came or went // privately, which the in-kind accept can raise. Err(e @ PlatformWalletError::PrivateContactRequestPending(..)) + | Err(e @ PlatformWalletError::PublicContactRequestUnconfirmed(..)) | Err(e @ PlatformWalletError::PrivateContactStoreNotRead(..)) | Err(e @ PlatformWalletError::PrivateContactRequestNeedsPrivateReply(..)) | Err(e @ PlatformWalletError::PrivateContactPaymentsNotRestored) From e8d18c4420cfd7913c432eebe044dfa05c5daaaa Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Tue, 10 Nov 2026 23:45:21 +0700 Subject: [PATCH 264/269] feat(platform-wallet-ffi): read whether a public contact request awaits its outcome platform_wallet_get_public_contact_request_unconfirmed exposes DashPayView::public_contact_request_unconfirmed per pair, as platform_wallet_get_private_pair_held does for the private hold: it reads outside the registry guard, answers false for a free pair, NotFound for an unknown wallet and ErrorIdentityNotFound (7) for a sender the wallet does not hold. A host reads it beside the private hold, so a public send or accept that returned 20 is shown as awaiting its outcome rather than offered again. Co-Authored-By: Claude Opus 5.5 --- .../src/private_contact_request.rs | 109 +++++++++++++++++- 1 file changed, 108 insertions(+), 1 deletion(-) diff --git a/packages/rs-platform-wallet-ffi/src/private_contact_request.rs b/packages/rs-platform-wallet-ffi/src/private_contact_request.rs index d4eab49caa4..a9940b6ecc6 100644 --- a/packages/rs-platform-wallet-ffi/src/private_contact_request.rs +++ b/packages/rs-platform-wallet-ffi/src/private_contact_request.rs @@ -28,6 +28,10 @@ //! - [`platform_wallet_get_private_pair_held`]: whether a contact request //! from one of the wallet's identities to a contact is held for a private //! one, as a send decides it, so a host offers no second send or reply. +//! - [`platform_wallet_get_public_contact_request_unconfirmed`]: whether a +//! public contact request from one of the wallet's identities to a contact +//! was broadcast and its outcome is not proved yet, which holds the pair +//! as a private request pending does. //! - [`platform_wallet_get_private_payments_restore_failure`]: why the last //! restoration of the wallet's private payments failed, which //! `platform_wallet_get_private_contact_readiness` reports only as a flag. @@ -357,6 +361,56 @@ pub unsafe extern "C" fn platform_wallet_get_private_pair_held( PlatformWalletFFIResult::ok() } +/// Whether a public contact request from `sender_id`, one of the wallet's +/// identities, to `recipient_id` was broadcast and may have been delivered, +/// with no proof settling its outcome yet +/// (`DashPayView::public_contact_request_unconfirmed`), into +/// `out_unconfirmed`. The send or accept that broadcast it reported +/// `ErrorTransactionBroadcastUnconfirmed` (20); while it stands, every +/// contact request or acceptance to the recipient, public or private, is +/// refused with `ErrorPublicContactRequestUnconfirmed` (76), nothing signed, +/// so a host shows the pair as unconfirmed and offers neither. A public +/// contact request sync settles it: the request proved on Platform is +/// recorded as sent, and one proved unable to write it frees the pair. The +/// per-contact read beside `platform_wallet_get_private_pair_held`, so a +/// host tells a public request awaiting its outcome from a private one +/// pending. +/// +/// `out_unconfirmed` is reset to false first. An unknown `wallet_handle` is +/// `NotFound`; a sender the wallet does not hold is `ErrorIdentityNotFound` +/// (7). +/// +/// # Safety +/// - `sender_id` and `recipient_id` must each point to 32 readable bytes. +/// - `out_unconfirmed` must be a valid, writable pointer. +#[no_mangle] +pub unsafe extern "C" fn platform_wallet_get_public_contact_request_unconfirmed( + wallet_handle: Handle, + sender_id: *const u8, + recipient_id: *const u8, + out_unconfirmed: *mut bool, +) -> PlatformWalletFFIResult { + check_ptr!(out_unconfirmed); + unsafe { *out_unconfirmed = false }; + let sender_id = unwrap_result_or_return!(unsafe { read_identifier(sender_id) }); + let recipient_id = unwrap_result_or_return!(unsafe { read_identifier(recipient_id) }); + // Look the wallet up under the registry guard, but read outside it: the + // read waits on the wallet's lock, and a registry guard held across that + // wait would stall `platform_wallet_destroy`. + let option = + PLATFORM_WALLET_STORAGE.with_item(wallet_handle, |wallet| wallet.identity().clone()); + let identity = unwrap_option_or_return!(option); + let result = block_on_worker(async move { + identity + .dashpay() + .public_contact_request_unconfirmed(&sender_id, &recipient_id) + .await + }); + let unconfirmed = unwrap_result_or_return!(result); + unsafe { *out_unconfirmed = unconfirmed }; + PlatformWalletFFIResult::ok() +} + /// Why the last restoration of the wallet's private payments failed, while /// they are not restored for `identity_id` and none succeeded since /// (`PrivateContactReadiness::private_payments_restore_failure`, which @@ -1743,7 +1797,8 @@ mod tests { } /// A wallet whose identity is paying for nothing to a contact reads the - /// pair as free and has no restoration failure to give; an unknown wallet + /// pair as free, no public request to it as unconfirmed, and has no + /// restoration failure to give; an unknown wallet /// is not found and an identity the wallet does not hold is code 7, each /// leaving the output reset; a null output is refused. Holding a pair is the wallet's own /// (its tests pin the reservation and the journal mirror), so this pins @@ -1794,6 +1849,58 @@ mod tests { assert_eq!(free.code, PlatformWalletFFIResultCode::Success); assert!(!held, "nothing holds the pair"); + let mut unconfirmed = true; + let none = unsafe { + platform_wallet_get_public_contact_request_unconfirmed( + handle, + sender_id.as_ptr(), + recipient_id.as_ptr(), + &mut unconfirmed, + ) + }; + assert_eq!(none.code, PlatformWalletFFIResultCode::Success); + assert!( + !unconfirmed, + "no public request to the pair awaits its outcome" + ); + let mut unconfirmed = true; + let unknown_wallet = unsafe { + platform_wallet_get_public_contact_request_unconfirmed( + Handle::MAX, + sender_id.as_ptr(), + recipient_id.as_ptr(), + &mut unconfirmed, + ) + }; + assert_eq!(unknown_wallet.code, PlatformWalletFFIResultCode::NotFound); + assert!(!unconfirmed, "reset"); + let mut unconfirmed = true; + let unknown_sender = unsafe { + platform_wallet_get_public_contact_request_unconfirmed( + handle, + [0xC1u8; 32].as_ptr(), + recipient_id.as_ptr(), + &mut unconfirmed, + ) + }; + assert_eq!( + unknown_sender.code, + PlatformWalletFFIResultCode::ErrorIdentityNotFound + ); + assert!(!unconfirmed); + assert_eq!( + unsafe { + platform_wallet_get_public_contact_request_unconfirmed( + handle, + sender_id.as_ptr(), + recipient_id.as_ptr(), + std::ptr::null_mut(), + ) + } + .code, + PlatformWalletFFIResultCode::ErrorNullPointer + ); + let mut reason: *mut c_char = std::ptr::dangling_mut(); let none = unsafe { platform_wallet_get_private_payments_restore_failure( From 39fe96367cd8d806d9d41005b1ad8aea72198a2a Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Tue, 10 Nov 2026 23:45:21 +0700 Subject: [PATCH 265/269] feat(swift-sdk): type code 76 and read a public contact request's hold PlatformWalletResultCode.errorPublicContactRequestUnconfirmed (76) maps to PlatformWalletError.publicContactRequestUnconfirmed, apart from a private request pending (71), and ManagedPlatformWallet.publicContactRequestUnconfirmed wraps the new read. ErrorHandlingTests pins the code; the registry row names the export and the Swift types. Co-Authored-By: Claude Opus 5.5 --- .../ERROR_CODE_REGISTRY.md | 2 +- .../PlatformWallet/IdentityManager.swift | 4 ++- .../PlatformWallet/PlatformWalletResult.swift | 23 +++++++++++++++ .../PrivateContactRequests.swift | 29 ++++++++++++++++++- .../ErrorHandlingTests.swift | 16 ++++++++++ 5 files changed, 71 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 c8c8df6bd87..5aeb5333e2a 100644 --- a/packages/rs-platform-wallet-ffi/ERROR_CODE_REGISTRY.md +++ b/packages/rs-platform-wallet-ffi/ERROR_CODE_REGISTRY.md @@ -191,7 +191,7 @@ Fork-era numbers remain in the collision history, which is immutable record. | 73 | `ErrorPrivateContactRecoveryUnsupported` | DashPay private contact requests | Proposed. Maps `PlatformWalletError::PrivateContactRecoveryUnsupported`: the persister does not declare `PRIVATE_CONTACT_REQUEST_SCAN_STATE` (bit 15) 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. The FFI constant is `PLATFORM_WALLET_PERSISTENCE_CAPABILITY_PRIVATE_CONTACT_REQUEST_SCAN_STATE` (`1 << 15`). Swift types it (`PlatformWalletError.privateContactRecoveryUnsupported`, `ErrorHandlingTests` pin), SwiftExampleApp shows it as a storage configuration error, and the SwiftData handler declares bit 0 and bit 15 with the begin and end callbacks wired; Kotlin typing is added by the Kotlin PR (#5813) | | 74 | `ErrorPrivateContactStoreNotRead` | DashPay private contact requests | Proposed. Maps `PlatformWalletError::PrivateContactStoreNotRead`: DashPay's private contact request store was not read to its end for the identity, with the keys Platform proves it holds, before a contact request or acceptance, public or private, was decided (every one reads it from the identity's cursor first). Nothing was reserved, sealed, paid or broadcast; calling again continues the read from where it stopped. A node behind the store already seen, or keys that kept changing, stop it. It was reported as 71 before, which says not to submit again. Mapped by the blanket conversion and kept by the shielded spend contract (`map_spend_result`). Swift types it (`PlatformWalletError.privateContactStoreNotRead`, `ErrorHandlingTests` pin) and SwiftExampleApp offers a retry with no pending row; Kotlin typing is added by the Kotlin PR (#5813) | | 75 | `ErrorSdkPinnedBelowProtocolVersion` | DashPay private contact requests | Proposed. The SDK refused a request without sending it because the SDK itself is pinned below the protocol version the request needs (`dash_sdk::Error::ProtocolVersionNotActive` with `pinned`), whatever the network runs: for example every contact request's private store read with the SDK pinned below protocol version 15 on a network at 15. A network upgrade does not lift it; only raising or removing the pin does. Before, it shared 70 with a network proven below the version, which lifts by itself. Mapped by the blanket conversion. Swift types it (`PlatformWalletError.sdkPinnedBelowProtocolVersion`, `ErrorHandlingTests` pin) and SwiftExampleApp shows a developer-facing message; Kotlin typing is added by the Kotlin PR (#5813) | -| 76 | `ErrorPublicContactRequestUnconfirmed` | DashPay private contact requests | Proposed. Maps `PlatformWalletError::PublicContactRequestUnconfirmed`: a public contact request to that identity was broadcast and may have been delivered, and no proof settled its outcome yet, so the wallet holds the pair and signs nothing new to it, public or private. The send or accept that broadcast it reported 20; every later one is refused with 76 until a public contact request sync settles it (its document proved on Platform: recorded as sent; its transition proved unable to write it: the pair freed), broadcasting the same signed bytes again meanwhile. Not a private request pending (71). Mapped by the blanket conversion and by `map_spend_result`. Swift and Kotlin typing is added by the FFI PR (#5812) and the Kotlin PR (#5813) | +| 76 | `ErrorPublicContactRequestUnconfirmed` | DashPay private contact requests | Proposed. Maps `PlatformWalletError::PublicContactRequestUnconfirmed`: a public contact request to that identity was broadcast and may have been delivered, and no proof settled its outcome yet, so the wallet holds the pair and signs nothing new to it, public or private. The send or accept that broadcast it reported 20; every later one is refused with 76 until a public contact request sync settles it (its document proved on Platform: recorded as sent; its transition proved unable to write it: the pair freed), broadcasting the same signed bytes again meanwhile. Not a private request pending (71). Mapped by the blanket conversion and by `map_spend_result`. `platform_wallet_get_public_contact_request_unconfirmed` reads the hold per pair (`DashPayView::public_contact_request_unconfirmed`; 7 for a sender the wallet does not hold). Swift types it (`PlatformWalletError.publicContactRequestUnconfirmed`, `ErrorHandlingTests` pin) and wraps the read (`ManagedPlatformWallet.publicContactRequestUnconfirmed`); SwiftExampleApp shows the pair as being checked, with no request or accept offered, until the read says settled, and its refreshes run the DashPay sync that settles it; Kotlin typing is added by the Kotlin PR (#5813) | **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/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/IdentityManager.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/IdentityManager.swift index 91a65df6b4f..0ef387a8d28 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/IdentityManager.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/IdentityManager.swift @@ -33,7 +33,9 @@ public class IdentityManager { /// Remove an identity from the manager. Refused while its contact request /// channel is held: `PlatformWalletError.privateContactRequestPending` /// for a request in flight or a private payment that may still execute, - /// `.privateContactPaymentsNotRestored` for payments not restored yet. + /// `.publicContactRequestUnconfirmed` for a public request whose outcome + /// is not proved yet, `.privateContactPaymentsNotRestored` for payments + /// not restored yet. public func removeIdentity(_ identityId: Identifier) throws { try identityId.withFFIBytes { idPtr in try identity_manager_remove_identity(handle, idPtr).check() diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift index e83cc42bce2..ee130585d2d 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift @@ -316,6 +316,12 @@ public enum PlatformWalletResultCode: Int32, Sendable { /// did not send it, whatever the network runs. A network upgrade does not /// lift it; only raising or removing the pin does. case errorSdkPinnedBelowProtocolVersion = 75 + /// A public contact request to that identity was broadcast and may have + /// been delivered (its own send or accept reported + /// `errorTransactionBroadcastUnconfirmed`, 20), and no proof settled its + /// outcome yet. Nothing new was signed. Unconfirmed, not failed: do not + /// submit it again; a public contact request sync settles it. + case errorPublicContactRequestUnconfirmed = 76 /// The named thing does not exist. Besides the handle/lookup failures this /// has always covered, BOTH deferred-send paths report the /// wallet-was-REMOVED case here. @@ -465,6 +471,8 @@ public enum PlatformWalletResultCode: Int32, Sendable { self = .errorPrivateContactStoreNotRead case PLATFORM_WALLET_FFI_RESULT_CODE_ERROR_SDK_PINNED_BELOW_PROTOCOL_VERSION: self = .errorSdkPinnedBelowProtocolVersion + case PLATFORM_WALLET_FFI_RESULT_CODE_ERROR_PUBLIC_CONTACT_REQUEST_UNCONFIRMED: + self = .errorPublicContactRequestUnconfirmed case PLATFORM_WALLET_FFI_RESULT_CODE_NOT_FOUND: self = .notFound case PLATFORM_WALLET_FFI_RESULT_CODE_ERROR_UNKNOWN: @@ -723,6 +731,18 @@ public enum PlatformWalletError: LocalizedError { /// removing the SDK's protocol version pin lifts it: a developer's /// setting, not something the user or a network upgrade changes. case sdkPinnedBelowProtocolVersion(String) + /// A public contact request to that identity was broadcast and may have + /// been delivered, and no proof settled its outcome yet (code 76): the + /// wallet holds the pair, keeping the signed transition, and refuses + /// every contact request or acceptance to it, public or private, with + /// nothing signed. The attempt that broadcast it reported + /// `transactionBroadcastUnconfirmed` (20). Unconfirmed, not failed: do not + /// submit it again. A public contact request sync (the DashPay sync) + /// settles it: the request proved on Platform is recorded as sent, and one + /// proved unable to write it frees the pair. + /// `ManagedPlatformWallet.publicContactRequestUnconfirmed(senderId:recipientId:)` + /// reads whether the pair is still held so. + case publicContactRequestUnconfirmed(String) case walletAlreadyExists(String) /// Definitive shielded-broadcast failure: the shielded transition never /// left the wallet (refused before anything was sent), so it cannot @@ -956,6 +976,7 @@ public enum PlatformWalletError: LocalizedError { .privateContactRequestPending(let m), .privateContactPaymentsNotRestored(let m), .privateContactRecoveryUnsupported(let m), .privateContactStoreNotRead(let m), .sdkPinnedBelowProtocolVersion(let m), + .publicContactRequestUnconfirmed(let m), .walletAlreadyExists(let m), .shieldedBroadcastFailed(let m), .shieldedBroadcastUnconfirmed(let m), .shieldedSpendUnconfirmed(let m), .shieldedNoRecordedAnchor(let m), .shieldedInsufficientBalance(let m), @@ -1091,6 +1112,8 @@ public enum PlatformWalletError: LocalizedError { self = .privateContactStoreNotRead(detail) case .errorSdkPinnedBelowProtocolVersion: self = .sdkPinnedBelowProtocolVersion(detail) + case .errorPublicContactRequestUnconfirmed: + self = .publicContactRequestUnconfirmed(detail) case .errorWalletAlreadyExists: self = .walletAlreadyExists(detail) case .errorShieldedBroadcastFailed: self = .shieldedBroadcastFailed(detail) case .errorShieldedBroadcastUnconfirmed: self = .shieldedBroadcastUnconfirmed(detail) diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PrivateContactRequests.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PrivateContactRequests.swift index ec414443932..3ad72306ab0 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PrivateContactRequests.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PrivateContactRequests.swift @@ -152,6 +152,31 @@ extension ManagedPlatformWallet { return PrivateContactReadiness(ffi: ffi) } + /// Whether a public contact request from `senderId`, one of this + /// wallet's identities, to `recipientId` was broadcast and may have been + /// delivered, with no proof settling its outcome yet. The send or accept + /// that broadcast it threw `PlatformWalletError.transactionBroadcastUnconfirmed` + /// (20); while it stands, Rust refuses every contact request or + /// acceptance to the recipient, public or private + /// (`PlatformWalletError.publicContactRequestUnconfirmed`, 76), so show the + /// pair as unconfirmed and offer neither. A public contact request sync + /// (the DashPay sync) settles it. Read it beside + /// `privatePairHeld(senderId:recipientId:)`, which reads a private request + /// pending. Throws `PlatformWalletError.identityNotFound` (code 7) for a + /// sender the wallet does not hold. + public func publicContactRequestUnconfirmed(senderId: Identifier, recipientId: Identifier) throws -> Bool { + var unconfirmed = false + let handle = self.handle + let result = senderId.withFFIBytes { senderPtr in + recipientId.withFFIBytes { recipientPtr in + platform_wallet_get_public_contact_request_unconfirmed( + handle, senderPtr, recipientPtr, &unconfirmed) + } + } + try result.check() + return unconfirmed + } + /// Why the last restoration of this wallet's private payments failed, /// while they are not restored for `identityId` and none succeeded since, /// or `nil` when none failed (restored, or only not restored yet). What @@ -268,7 +293,9 @@ extension PlatformWalletManager { /// SDK could not confirm a transition it may have accepted, and /// `.privateContactRequestPending` when the /// pair is held for a private request (in flight, paid and not yet read - /// back, or kept by the journal): in each case do not send again. + /// back, or kept by the journal), or `.publicContactRequestUnconfirmed` + /// when a public request to the pair awaits its outcome: in each case do + /// not send again. /// `.privateContactStoreNotRead` sent nothing; calling again continues /// the read. `.privateContactPaymentsNotRestored` and /// `.privateContactRecoveryUnsupported` refuse before the read. diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/ErrorHandlingTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/ErrorHandlingTests.swift index cf636ae98e3..b3bbb2a7d64 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/ErrorHandlingTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/ErrorHandlingTests.swift @@ -588,6 +588,22 @@ final class ErrorHandlingTests: XCTestCase { XCTAssertEqual(message, detail) } + /// A public contact request whose outcome is unconfirmed holds the pair by + /// its own code and typed case, apart from a private request pending (71). + func testShouldReportAnUnconfirmedPublicRequestByItsOwnCode() { + let code = PlatformWalletResultCode( + ffi: PLATFORM_WALLET_FFI_RESULT_CODE_ERROR_PUBLIC_CONTACT_REQUEST_UNCONFIRMED + ) + XCTAssertEqual(code, .errorPublicContactRequestUnconfirmed) + XCTAssertEqual(code.rawValue, 76) + let detail = "a public contact request to 9fRYTJZW was broadcast and its outcome is not proved" + guard case .publicContactRequestUnconfirmed(let message) = + PlatformWalletError(code: code, message: detail) else { + return XCTFail("lost typed public-unconfirmed error") + } + XCTAssertEqual(message, detail) + } + func testShouldReadEveryFieldOfThePrivateContactReadiness() { var ffi = PrivateContactReadinessFFI() ffi.private_payments_restored = false From 7496810aa45478a5919989c8576c9de9072b50ba Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Tue, 10 Nov 2026 23:45:21 +0700 Subject: [PATCH 266/269] fix(swift-example-app): hold a public request that may have gone through until the wallet settles it The request list and Add Contact read only the private pair hold, so after a public accept or send that returned 20 the reload found nothing held and offered Accept or Send again while the first one could still execute. They now read both of the wallet's holds per contact (contactRequestHolds): while either holds the pair, no request or accept is offered, and a public hold shows "This request may have gone through; it is being checked." A 20 or a 76 from a send or accept shows the same at once and starts a DashPay sync, never a retry. The request list's pull-to-refresh runs that sync, which settles the hold first, and Add Contact gets a "Check again" button that does the same; both read again whenever a DashPay sync pass ends. The invitation claim's contact request reads 18, 20 and 76 the same way instead of as a failure. Co-Authored-By: Claude Opus 5.5 --- .../Views/DashPay/AddContactView.swift | 96 +++++++-- .../Views/DashPay/ClaimInvitationSheet.swift | 15 +- .../DashPay/ContactRequestOutcomes.swift | 138 +++++++++++-- .../Views/DashPay/ContactRequestsView.swift | 28 ++- .../Views/DashPay/ContactsView.swift | 47 ++--- .../ContactRequestOutcomesTests.swift | 183 ++++++++++++++++-- 6 files changed, 416 insertions(+), 91 deletions(-) diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/AddContactView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/AddContactView.swift index cd3dc75471a..13c981ddf13 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/AddContactView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/AddContactView.swift @@ -69,12 +69,14 @@ struct AddContactView: View { @State private var unconfirmedNotice: String? /// Whether the wallet holds the pair with the resolved recipient for a - /// private request, as a send decides it (`privateRepliesPending`): - /// in flight, paid and not yet read back, or kept by the shielded - /// store's journal until the payment is proven never to land. While it - /// does, Send is off and the footer offers a private sync, which reads a - /// landed request back. Read from the wallet, so reopening this sheet - /// keeps it. A failed read keeps Send off until a read succeeds. + /// private request, as a send decides it (in flight, paid and not yet + /// read back, or kept by the shielded store's journal until the payment + /// is proven never to land), or for a public request awaiting its + /// outcome (`contactRequestHolds`). While it does, Send is off and the + /// footer offers the sync that settles it: the private one, which reads a + /// landed request back, or the DashPay one, which settles a public + /// request. Read from the wallet, so reopening this sheet keeps it. A + /// failed read keeps Send off until a read succeeds. @State private var recipientPending = PrivateRepliesPendingSnapshot() /// Why the shielded store could not be opened, as last read with the @@ -88,6 +90,9 @@ struct AddContactView: View { /// A private sync the footer started is running. @State private var isSyncingPrivate = false + /// A DashPay sync the footer started is running. + @State private var isCheckingPublic = false + /// Send-collision flow. @State private var showCollisionAlert = false @State private var collisionRecipient: Identifier? @@ -130,11 +135,13 @@ struct AddContactView: View { } /// Whether the footer shows the recipient's pending state: the wallet's - /// private payments are being restored, a private request to them is - /// pending, or the wallet could not be read. + /// private payments are being restored, a request to them awaits its + /// outcome, a private request to them is pending, or the wallet could + /// not be read. private var recipientBlocked: Bool { guard let recipient = resolvedRecipient else { return false } return recipientPending.isRestoringPrivatePayments + || recipientPending.isAwaitingOutcome(recipient) || recipientPending.isPending(recipient) || recipientPending.isUnavailable } @@ -192,6 +199,12 @@ struct AddContactView: View { if !syncing { reloadRecipientPending() } } .onChange(of: shieldedService.isBound) { _, _ in reloadRecipientPending() } + // A DashPay sync settles a public request awaiting its outcome: + // read the recipient's holds again when one ends, the background + // pass included. + .onChange(of: walletManager.dashPaySyncIsSyncing) { _, syncing in + if !syncing { reloadRecipientPending() } + } .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .navigationBarLeading) { @@ -463,6 +476,7 @@ struct AddContactView: View { VStack(alignment: .leading, spacing: 6) { Text(ContactRequestSubmissionOutcome.rowMessage( pending: recipientPending.isPending(recipient), + awaitingOutcome: recipientPending.isAwaitingOutcome(recipient), restoring: recipientPending.isRestoringPrivatePayments, restoreFailure: recipientPending.restoreFailure, shieldedStoreFailure: shieldedStoreFailure, @@ -470,9 +484,25 @@ struct AddContactView: View { error: nil ) ?? "") .accessibilityIdentifier("dashpay.addContact.pending") - // A private sync settles a pending request; it does not - // restore private payments, so it is not offered for that. - if !recipientPending.isRestoringPrivatePayments { + // A DashPay sync settles a public request awaiting its + // outcome, and a private sync a pending private request; + // neither restores private payments, so neither is offered + // for that. + if recipientPending.isRestoringPrivatePayments { + EmptyView() + } else if recipientPending.isAwaitingOutcome(recipient) { + Button { + checkPublicRequest() + } label: { + if isCheckingPublic { + ProgressView() + } else { + Text("Check again") + } + } + .disabled(isCheckingPublic) + .accessibilityIdentifier("dashpay.addContact.checkPublic") + } else { Button { syncPrivateRequests() } label: { @@ -494,8 +524,8 @@ struct AddContactView: View { /// the wallet is asked to retry a restoration first when its payments are /// not restored, and the readiness shown is read after that), then /// whether it holds the pair with the resolved recipient for a private - /// request, and keep refreshing on a timer while the payments stay - /// unrestored. + /// request or for a public request awaiting its outcome, and keep + /// refreshing on a timer while the payments stay unrestored. private func reloadRecipientPending() { let contactIds = resolvedRecipient.map { [$0] } ?? [] let wallet = try? requireWallet() @@ -509,7 +539,7 @@ struct AddContactView: View { recipientPending.recordReadiness(refreshed.readiness, restoreFailure: refreshed.restoreFailure) shieldedStoreFailure = refreshed.storeFailure recipientPending.record( - privateRepliesPending(wallet: wallet, identityId: identityId, contactIds: contactIds), + contactRequestHolds(wallet: wallet, identityId: identityId, contactIds: contactIds), for: contactIds ) if PrivatePaymentsRecovery.recheckDelay(after: recipientPending.readiness, attempt: readinessRechecks) != nil { @@ -535,6 +565,19 @@ struct AddContactView: View { } } + /// Run a DashPay sync, which settles a public request awaiting its + /// outcome first (attaching to one already running), then re-read the + /// recipient's holds: Send comes back once the wallet frees the pair, + /// and a request found is recorded as sent. + private func checkPublicRequest() { + isCheckingPublic = true + Task { @MainActor in + defer { isCheckingPublic = false } + await attachOrStartSync(walletManager) + reloadRecipientPending() + } + } + private func previewDisplayName( recipient: Identifier, profile: DashPayProfile? @@ -633,21 +676,28 @@ struct AddContactView: View { kickDashPaySync(walletManager) dismiss() } catch { - switch ContactRequestSubmissionOutcome.of(error) { + let outcome = ContactRequestSubmissionOutcome.of(error) + recipientPending.recordOutcome(outcome, for: recipient) + switch outcome { case .mayHaveGoneThrough: // Pending, not failed: the next sync settles it. onSent(recipient, mode == .dpns ? selectedResult?.fullName : nil) kickDashPaySync(walletManager) unconfirmedNotice = ContactRequestSubmissionOutcome.mayHaveGoneThroughMessage + case .publicRequestUnconfirmed: + // Nothing was signed: an earlier public request to the + // recipient awaits its outcome. The footer shows it being + // checked, with the DashPay sync, never a retry. + kickDashPaySync(walletManager) case .privateRequestPending: // Nothing was sent: the wallet holds the pair. The footer // shows it as pending, with the private sync, until the // per-contact read says it is free. - recipientPending.recordRefusal(of: recipient) + break case .privatePaymentsNotRestored: // Nothing was sent: the footer shows the wallet setting up // its private payments until its readiness says restored. - recipientPending.recordRestoringRefusal() + break case .privateStoreNotRead: // Nothing was sent and nothing is pending: Send stays on, // and sending again continues the read. @@ -706,15 +756,21 @@ struct AddContactView: View { kickDashPaySync(walletManager) dismiss() } catch { - switch ContactRequestSubmissionOutcome.of(error) { + let outcome = ContactRequestSubmissionOutcome.of(error) + recipientPending.recordOutcome(outcome, for: recipient) + switch outcome { case .mayHaveGoneThrough: kickDashPaySync(walletManager) unconfirmedNotice = ContactRequestSubmissionOutcome.mayHaveGoneThroughMessage + case .publicRequestUnconfirmed: + // Nothing was signed: the footer shows the pair being + // checked, with the DashPay sync, never a retry. + kickDashPaySync(walletManager) case .privateRequestPending: // Nothing was sent: the footer offers the private sync. - recipientPending.recordRefusal(of: recipient) + break case .privatePaymentsNotRestored: - recipientPending.recordRestoringRefusal() + break case .privateStoreNotRead: errorMessage = ContactRequestSubmissionOutcome.privateStoreNotReadMessage case .failed(let message): diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ClaimInvitationSheet.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ClaimInvitationSheet.swift index 6953c89e694..a8ccb49c5ba 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ClaimInvitationSheet.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ClaimInvitationSheet.swift @@ -270,9 +270,18 @@ struct ClaimInvitationSheet: View { dismiss() } catch { // The identity is already registered; a failed contact request - // is non-fatal and re-sendable. Surface it but keep the sheet so - // the user sees the outcome. - errorMessage = "Identity claimed, but the contact request failed: \(error.localizedDescription)" + // is non-fatal. Surface it but keep the sheet so the user sees + // the outcome. One that may have gone through (18, 20), or that + // the wallet refused while an earlier one awaits its outcome + // (76), is not a failure: the sync settles it. + switch ContactRequestSubmissionOutcome.of(error) { + case .mayHaveGoneThrough, .publicRequestUnconfirmed: + kickDashPaySync(walletManager) + errorMessage = "Identity claimed. " + + ContactRequestSubmissionOutcome.publicRequestUnconfirmedMessage + default: + errorMessage = "Identity claimed, but the contact request failed: \(error.localizedDescription)" + } } } } diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ContactRequestOutcomes.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ContactRequestOutcomes.swift index 7a1c3608269..a1da0f89951 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ContactRequestOutcomes.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ContactRequestOutcomes.swift @@ -6,7 +6,12 @@ import SwiftDashSDK /// confirmed (code 18) may already be on chain (Rust keeps its notes /// reserved), and so may a state transition the SDK could not confirm after /// it may have been accepted (code 20), so the view keeps either pending and -/// never offers a fresh submission; the next sync settles it. A pair Rust reports as held for a +/// never offers a fresh submission; the next sync settles it. For a public +/// request the wallet holds the pair itself after a 20, the signed transition +/// kept, until a public contact request sync settles it, and refuses every +/// later request or accept to it with code 76: the view shows "may have gone +/// through; it is being checked" until the wallet's per-contact read says +/// settled. A pair Rust reports as held for a /// private request (code 71), which a public send or accept can also meet, /// sent nothing new: the view shows it as pending until the wallet's /// per-contact read says the pair is free. Unrestored private payments @@ -22,6 +27,10 @@ enum ContactRequestSubmissionOutcome: Equatable { case mayHaveGoneThrough /// The wallet holds the pair for a private request; nothing was sent. case privateRequestPending + /// The wallet holds the pair for a public request whose outcome is not + /// proved yet (code 76); nothing was signed. A public contact request + /// sync settles it. + case publicRequestUnconfirmed /// The wallet's private payments are not restored yet; nothing was sent. /// Restoring them, not a private sync, lifts it. case privatePaymentsNotRestored @@ -34,6 +43,11 @@ enum ContactRequestSubmissionOutcome: Equatable { static let mayHaveGoneThroughMessage = "This may have gone through. It shows up " + "after the next sync confirms it; do not submit it again." + /// A public request or accept that may have gone through, as the + /// wallet holds it until a sync settles its outcome. + static let publicRequestUnconfirmedMessage = "This request may have gone through; " + + "it is being checked. The next sync settles it." + static let privateRequestPendingMessage = "A private request to this contact is " + "pending. It clears once the request is read back from the private store, " + "or once its payment can no longer land, which can take a while. Sync " @@ -79,12 +93,14 @@ enum ContactRequestSubmissionOutcome: Equatable { } /// The message a request row shows: the restoring notice while the - /// wallet's private payments are not restored, the pending notice while - /// the wallet holds the pair, the unavailable notice while the wallet - /// could not be read, and whatever error an earlier attempt left - /// otherwise. + /// wallet's private payments are not restored, the being-checked notice + /// while a request to the contact awaits its outcome, the pending notice + /// while the wallet holds the pair for a private request, the + /// unavailable notice while the wallet could not be read, and whatever + /// error an earlier attempt left otherwise. static func rowMessage( pending: Bool, + awaitingOutcome: Bool = false, restoring: Bool = false, restoreFailure: String? = nil, shieldedStoreFailure: String? = nil, @@ -95,6 +111,7 @@ enum ContactRequestSubmissionOutcome: Equatable { return restoringMessage( restoreFailure: restoreFailure, shieldedStoreFailure: shieldedStoreFailure) } + if awaitingOutcome { return publicRequestUnconfirmedMessage } if pending { return privateRequestPendingMessage } if unavailable { return PrivateRepliesPendingSnapshot.unavailableMessage } return error @@ -107,6 +124,8 @@ enum ContactRequestSubmissionOutcome: Equatable { return .mayHaveGoneThrough case .privateContactRequestPending: return .privateRequestPending + case .publicContactRequestUnconfirmed: + return .publicRequestUnconfirmed case .privateContactPaymentsNotRestored: return .privatePaymentsNotRestored case .privateContactStoreNotRead: @@ -123,10 +142,12 @@ enum ContactRequestSubmissionOutcome: Equatable { } } -/// Which contacts the wallet holds a pair with for a private request, as the -/// views last read it (`privateRepliesPending`, one -/// `ManagedPlatformWallet.privatePairHeld` read per contact, which answers as -/// a send decides it). While one is held, Rust refuses another send or reply +/// Which contacts the wallet holds a pair with for a private request, and +/// which for a public request awaiting its outcome, as the views last read it +/// (`contactRequestHolds`: per contact, `ManagedPlatformWallet.privatePairHeld`, +/// which answers as a send decides it, and +/// `ManagedPlatformWallet.publicContactRequestUnconfirmed`). While either +/// holds a pair, Rust refuses another send or reply /// to that contact, so the views turn off what would submit one (Accept, /// Ignore, Send). A failed read is not "nothing pending": the last /// successful snapshot stays, and those controls stay off for every contact @@ -136,7 +157,13 @@ enum ContactRequestSubmissionOutcome: Equatable { /// pending at once; the next successful read covering it decides, as it /// reads what the refusal read. Controls for a pair re-enable only when that /// read says it is free: a payment that will never land frees it only once -/// the wallet proves so, which the read shows. +/// the wallet proves so, which the read shows. So does a send or accept that +/// may have gone through (18 or 20), or was refused as awaiting a public +/// request's outcome (76): the contact shows "being checked" at once, and a +/// read decides it the same way, so a reload whose sync did not settle the +/// wallet's hold keeps it so. A read always covers both holds: a read of the +/// private hold alone would offer a public request awaiting its outcome +/// again. /// /// Whether the wallet's private payments are restored is the wallet's /// readiness (`ManagedPlatformWallet.privateContactReadiness`), not @@ -148,7 +175,8 @@ struct PrivateRepliesPendingSnapshot: Equatable { static let unavailableMessage = "Could not read whether a private request to " + "this contact is pending. Sync private requests to check again." - /// The held contacts the last successful read found. + /// The contacts the last successful read found held for a private + /// request. private(set) var pending: Set = [] /// The contacts the last successful read covered. private(set) var covered: Set = [] @@ -157,6 +185,13 @@ struct PrivateRepliesPendingSnapshot: Equatable { /// Contacts a send or accept was refused for as pending (code 71) since /// the last successful read covering them. private(set) var refused: Set = [] + /// The contacts the last successful read found held for a public + /// request awaiting its outcome. + private(set) var publicUnconfirmed: Set = [] + /// Contacts a send or accept may have gone through to (18, 20), or was + /// refused for as awaiting a public request's outcome (76), since the + /// last successful read covering them. + private(set) var awaitingOutcome: Set = [] /// The wallet's readiness for the identity, as last read; nil until a /// read succeeds. private(set) var readiness: PrivateContactReadiness? @@ -167,20 +202,41 @@ struct PrivateRepliesPendingSnapshot: Equatable { /// the private payments are restored. private(set) var restoringRefused = false - /// Record a read of `contactIds`: the held ones, or nil when the read - /// failed. A successful read decides each contact it covered, a code 71 - /// refusal included. - mutating func record(_ read: Set?, for contactIds: [Data]) { + /// Record a read of both holds for `contactIds` (`contactRequestHolds`), + /// or nil when it failed. A successful read decides each contact it + /// covered, a refusal or a submission that may have gone through + /// included. + mutating func record(_ read: ContactRequestHolds?, for contactIds: [Data]) { guard let read else { available = false return } - pending = read + pending = read.privatelyHeld + publicUnconfirmed = read.publicUnconfirmed covered = Set(contactIds) refused.subtract(contactIds) + awaitingOutcome.subtract(contactIds) available = true } + /// Record how a send or accept to `contactId` that threw ended: a 71 + /// refusal marks it pending and a 72 refusal holds every contact, as + /// `recordRefusal(of:)` and `recordRestoringRefusal()` do; one that may + /// have gone through, or a 76 refusal, marks it awaiting its outcome. + /// Nothing else changes a hold. + mutating func recordOutcome(_ outcome: ContactRequestSubmissionOutcome, for contactId: Data) { + switch outcome { + case .privateRequestPending: + recordRefusal(of: contactId) + case .mayHaveGoneThrough, .publicRequestUnconfirmed: + awaitingOutcome.insert(contactId) + case .privatePaymentsNotRestored: + recordRestoringRefusal() + case .privateStoreNotRead, .failed: + break + } + } + /// Record that a send or accept to `contactId` was refused as pending /// (code 71). mutating func recordRefusal(of contactId: Data) { @@ -219,14 +275,60 @@ struct PrivateRepliesPendingSnapshot: Equatable { pending.contains(contactId) || refused.contains(contactId) } + /// Whether a request to `contactId` awaits its outcome: the last + /// successful read found the pair held for a public request awaiting + /// it, or a send or accept to the contact may have gone through, or was + /// refused so (76), since. + func isAwaitingOutcome(_ contactId: Data) -> Bool { + publicUnconfirmed.contains(contactId) || awaitingOutcome.contains(contactId) + } + /// Whether the last read failed. var isUnavailable: Bool { !available } /// Whether a send or reply to `contactId` may be offered: a successful - /// read covered it and found the pair free. + /// read covered it and found the pair free of both holds. func allowsSubmission(to contactId: Data) -> Bool { available && covered.contains(contactId) && !isPending(contactId) - && !isRestoringPrivatePayments + && !isAwaitingOutcome(contactId) && !isRestoringPrivatePayments + } +} + +/// One read of the wallet's two per-contact holds. +struct ContactRequestHolds: Equatable { + /// The contacts whose pair the wallet holds for a private request. + var privatelyHeld: Set = [] + /// The contacts whose pair the wallet holds for a public request + /// awaiting its outcome. + var publicUnconfirmed: Set = [] +} + +/// Read the wallet's two holds for each of `contactIds`: whether the pair is +/// held for a private request (`privatePairHeld`) and whether for a public +/// request awaiting its outcome (`publicUnconfirmed`). Empty when the wallet +/// does not manage the identity (code 7), as it then holds nothing for it; +/// nil when a read failed otherwise, which the views keep apart from +/// "nothing held" (`PrivateRepliesPendingSnapshot`). +func contactRequestHolds( + contactIds: [Data], + privatePairHeld: (Data) throws -> Bool, + publicUnconfirmed: (Data) throws -> Bool +) -> ContactRequestHolds? { + do { + var holds = ContactRequestHolds() + for contactId in contactIds { + if try privatePairHeld(contactId) { + holds.privatelyHeld.insert(contactId) + } + if try publicUnconfirmed(contactId) { + holds.publicUnconfirmed.insert(contactId) + } + } + return holds + } catch PlatformWalletError.identityNotFound(_) { + return ContactRequestHolds() + } catch { + return nil } } diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ContactRequestsView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ContactRequestsView.swift index 0e667e5d996..0d489a702cd 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ContactRequestsView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ContactRequestsView.swift @@ -48,7 +48,8 @@ struct ContactRequestsView: View { @State private var rowErrors: [Data: String] = [:] /// Contact ids whose pair the wallet holds for a private request, as a - /// send decides it (`privateRepliesPending`): the row stays pending, + /// send decides it, or for a public request awaiting its outcome + /// (`contactRequestHolds`): the row stays pending or "being checked", /// without its buttons, until the wallet's read says the pair is free. /// Read from the wallet on appear, after an accept and after each sync /// pass, so leaving and reopening the screen keeps it. A failed read @@ -158,6 +159,7 @@ struct ContactRequestsView: View { || !walletPending.allowsSubmission(to: row.contactIdentityId), errorMessage: ContactRequestSubmissionOutcome.rowMessage( pending: walletPending.isPending(row.contactIdentityId), + awaitingOutcome: walletPending.isAwaitingOutcome(row.contactIdentityId), restoring: walletPending.isRestoringPrivatePayments, restoreFailure: walletPending.restoreFailure, shieldedStoreFailure: shieldedStoreFailure, @@ -200,6 +202,8 @@ struct ContactRequestsView: View { } } .refreshable { + // The DashPay sync settles a public request awaiting its outcome + // first, so the reload below reads what it decided. await attachOrStartSync(walletManager) if let wallet = try? requireWallet() { privateRefreshNotice = await syncPrivateContactRequests(wallet) @@ -256,8 +260,9 @@ struct ContactRequestsView: View { /// Re-read from the wallet its readiness (`PrivatePaymentsRecovery.refresh`: /// the wallet is asked to retry a restoration first when its payments are /// not restored, and the readiness shown is read after that), then which - /// incoming rows' pairs it holds for a private request, and keep - /// refreshing on a timer while the payments stay unrestored. + /// incoming rows' pairs it holds for a private request or for a public + /// request awaiting its outcome, and keep refreshing on a timer while the + /// payments stay unrestored. private func reloadPendingReplies() { let contactIds = incomingPending.map(\.contactIdentityId) let wallet = try? requireWallet() @@ -271,7 +276,7 @@ struct ContactRequestsView: View { walletPending.recordReadiness(refreshed.readiness, restoreFailure: refreshed.restoreFailure) shieldedStoreFailure = refreshed.storeFailure walletPending.record( - privateRepliesPending(wallet: wallet, identityId: identityId, contactIds: contactIds), + contactRequestHolds(wallet: wallet, identityId: identityId, contactIds: contactIds), for: contactIds ) if PrivatePaymentsRecovery.recheckDelay(after: walletPending.readiness, attempt: readinessRechecks) != nil { @@ -338,21 +343,24 @@ struct ContactRequestsView: View { removedOverlayIds.insert(contactId) kickDashPaySync(walletManager) } catch { - switch ContactRequestSubmissionOutcome.of(error) { - case .mayHaveGoneThrough: + let outcome = ContactRequestSubmissionOutcome.of(error) + walletPending.recordOutcome(outcome, for: contactId) + switch outcome { + case .mayHaveGoneThrough, .publicRequestUnconfirmed: // As `SendViewModel` treats an unconfirmed spend: not a - // failure to retry. The wallet keeps the reply pending, - // and the row reads that; the next sync settles it. + // failure to retry. The wallet holds the pair (a private + // reply pending, or a public one awaiting its outcome, 76 + // when an earlier one still does), and the row reads that; + // the sync settles it. kickDashPaySync(walletManager) case .privateRequestPending: // Nothing was sent: the wallet holds the pair. The row // stays pending until its per-contact read says free. - walletPending.recordRefusal(of: contactId) kickDashPaySync(walletManager) case .privatePaymentsNotRestored: // The wallet is held until its private payments are // restored; every row waits for the readiness to say so. - walletPending.recordRestoringRefusal() + break case .privateStoreNotRead: // Nothing was sent and nothing is pending: the user may // accept again, which continues the read. diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ContactsView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ContactsView.swift index 656e5f63063..a2108686f70 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ContactsView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/DashPay/ContactsView.swift @@ -226,39 +226,32 @@ func kickDashPaySync(_ walletManager: PlatformWalletManager) { Task { _ = try? await walletManager.dashPaySyncNow() } } -/// The contacts among `contactIds` whose pair with `identityId` the wallet -/// holds for a private request, as a send decides it -/// (`ManagedPlatformWallet.privatePairHeld`, one read per contact): a private -/// request in flight, paid and not yet read back, or kept by the shielded -/// store's journal. While a pair is held, Rust refuses another send or reply -/// to it, so the views show it as pending and offer neither. Empty when the -/// wallet does not manage the identity, as it then holds nothing for it. Nil -/// when the wallet could not be read (no loaded wallet, or a read failed), -/// which the views keep apart from "nothing pending" -/// (`PrivateRepliesPendingSnapshot`). +/// The wallet's two holds on `identityId`'s pairs with `contactIds`, one +/// read of each per contact: held for a private request, as a send decides +/// it (`ManagedPlatformWallet.privatePairHeld`: a private request in flight, +/// paid and not yet read back, or kept by the shielded store's journal), and +/// held for a public request whose broadcast may have been delivered +/// (`ManagedPlatformWallet.publicContactRequestUnconfirmed`), which a DashPay +/// sync settles. While either holds a pair, Rust refuses another send or +/// reply to it, so the views offer neither. Empty when the wallet does not +/// manage the identity, as it then holds nothing for it. Nil when the wallet +/// could not be read (no loaded wallet, or a read failed), which the views +/// keep apart from "nothing held" (`PrivateRepliesPendingSnapshot`). @MainActor -func privateRepliesPending( +func contactRequestHolds( wallet: ManagedPlatformWallet?, identityId: Data, contactIds: [Data] -) -> Set? { - guard !contactIds.isEmpty else { return [] } +) -> ContactRequestHolds? { + guard !contactIds.isEmpty else { return ContactRequestHolds() } guard let wallet else { return nil } - do { - var pending = Set() - for contactId in contactIds { - if try wallet.privatePairHeld(senderId: identityId, recipientId: contactId) { - pending.insert(contactId) - } + return contactRequestHolds( + contactIds: contactIds, + privatePairHeld: { try wallet.privatePairHeld(senderId: identityId, recipientId: $0) }, + publicUnconfirmed: { + try wallet.publicContactRequestUnconfirmed(senderId: identityId, recipientId: $0) } - return pending - } catch PlatformWalletError.identityNotFound(_) { - // Code 7: the wallet does not hold the identity, so it holds nothing - // for it. - return [] - } catch { - return nil - } + ) } /// The wallet's readiness for `identityId`'s contact requests and, when it diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/ContactRequestOutcomesTests.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/ContactRequestOutcomesTests.swift index a2d140c1387..da9d4717e18 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/ContactRequestOutcomesTests.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/ContactRequestOutcomesTests.swift @@ -10,6 +10,10 @@ import SwiftDashSDK /// and a pinned SDK (75) are configuration messages, an unfinished private /// store read (74) is a retry with no pending row, and a network that does /// not serve the private store yet is quiet while real failures are shown. +/// A public request or accept that may have gone through (20), or one the +/// wallet refuses while an earlier one awaits its outcome (76), shows +/// "being checked" with no submission offered until the wallet's read of +/// the pair says settled. final class ContactRequestOutcomesTests: XCTestCase { /// Code 18: a note spend whose broadcast may have reached a node, a @@ -74,7 +78,7 @@ final class ContactRequestOutcomesTests: XCTestCase { let pendingContact = Data(repeating: 1, count: 32) let otherContact = Data(repeating: 2, count: 32) var snapshot = PrivateRepliesPendingSnapshot() - snapshot.record([pendingContact], for: [pendingContact, otherContact]) + snapshot.record(privatelyHeld([pendingContact]), for: [pendingContact, otherContact]) XCTAssertTrue(snapshot.isPending(pendingContact)) XCTAssertFalse(snapshot.allowsSubmission(to: pendingContact)) XCTAssertTrue(snapshot.allowsSubmission(to: otherContact)) @@ -85,7 +89,7 @@ final class ContactRequestOutcomesTests: XCTestCase { XCTAssertFalse(snapshot.allowsSubmission(to: pendingContact)) XCTAssertFalse(snapshot.allowsSubmission(to: otherContact)) - snapshot.record([], for: [pendingContact, otherContact]) + snapshot.record(privatelyHeld([]), for: [pendingContact, otherContact]) XCTAssertFalse(snapshot.isUnavailable) XCTAssertTrue(snapshot.allowsSubmission(to: pendingContact)) } @@ -97,7 +101,7 @@ final class ContactRequestOutcomesTests: XCTestCase { func testShouldReEnableARefusedPairOnlyWhenThePerContactReadSaysFree() { let contact = Data(repeating: 1, count: 32) var snapshot = PrivateRepliesPendingSnapshot() - snapshot.record([], for: [contact]) + snapshot.record(privatelyHeld([]), for: [contact]) snapshot.recordRefusal(of: contact) XCTAssertTrue(snapshot.isPending(contact)) XCTAssertFalse(snapshot.allowsSubmission(to: contact)) @@ -113,13 +117,13 @@ final class ContactRequestOutcomesTests: XCTestCase { XCTAssertFalse(snapshot.allowsSubmission(to: contact)) // The pair's read says held: still pending. - snapshot.record([contact], for: [contact]) + snapshot.record(privatelyHeld([contact]), for: [contact]) XCTAssertTrue(snapshot.isPending(contact)) XCTAssertFalse(snapshot.allowsSubmission(to: contact)) // A payment that will never land frees the pair once the wallet // proves so; the next read says free and the controls come back. - snapshot.record([], for: [contact]) + snapshot.record(privatelyHeld([]), for: [contact]) XCTAssertFalse(snapshot.isPending(contact)) XCTAssertTrue(snapshot.allowsSubmission(to: contact)) } @@ -130,7 +134,7 @@ final class ContactRequestOutcomesTests: XCTestCase { let other = Data(repeating: 2, count: 32) var snapshot = PrivateRepliesPendingSnapshot() snapshot.recordRefusal(of: refused) - snapshot.record([], for: [other]) + snapshot.record(privatelyHeld([]), for: [other]) XCTAssertTrue(snapshot.isPending(refused)) XCTAssertTrue(snapshot.allowsSubmission(to: other)) } @@ -145,7 +149,7 @@ final class ContactRequestOutcomesTests: XCTestCase { PlatformWalletError.privateContactStoreNotRead("not read to its end")), .privateStoreNotRead) var snapshot = PrivateRepliesPendingSnapshot() - snapshot.record([], for: [contact]) + snapshot.record(privatelyHeld([]), for: [contact]) XCTAssertFalse(snapshot.isPending(contact), "the view records no refusal for 74") XCTAssertTrue(snapshot.allowsSubmission(to: contact)) XCTAssertNotEqual( @@ -186,7 +190,7 @@ final class ContactRequestOutcomesTests: XCTestCase { func testShouldShowWhyARestorationFailedUntilTheReadinessSaysRestored() { let contact = Data(repeating: 1, count: 32) var snapshot = PrivateRepliesPendingSnapshot() - snapshot.record([], for: [contact]) + snapshot.record(privatelyHeld([]), for: [contact]) snapshot.recordReadiness( PrivateContactReadiness( privatePaymentsRestored: false, privateStoreRead: false, @@ -235,7 +239,7 @@ final class ContactRequestOutcomesTests: XCTestCase { let contact = Data(repeating: 1, count: 32) var snapshot = PrivateRepliesPendingSnapshot() snapshot.recordRestoringRefusal() - snapshot.record([], for: [contact]) + snapshot.record(privatelyHeld([]), for: [contact]) XCTAssertTrue(snapshot.isRestoringPrivatePayments) XCTAssertFalse(snapshot.allowsSubmission(to: contact)) @@ -261,7 +265,7 @@ final class ContactRequestOutcomesTests: XCTestCase { func testShouldShowRestoringFromTheReadinessAlone() { let contact = Data(repeating: 1, count: 32) var snapshot = PrivateRepliesPendingSnapshot() - snapshot.record([], for: [contact]) + snapshot.record(privatelyHeld([]), for: [contact]) snapshot.recordReadiness(PrivateContactReadiness( privatePaymentsRestored: false, privateStoreRead: false, unresolvedPrivatePayment: false)) XCTAssertTrue(snapshot.isRestoringPrivatePayments) @@ -273,7 +277,7 @@ final class ContactRequestOutcomesTests: XCTestCase { func testShouldOfferEveryContactASuccessfulEmptyReadCovered() { let contact = Data(repeating: 1, count: 32) var snapshot = PrivateRepliesPendingSnapshot() - snapshot.record([], for: [contact]) + snapshot.record(privatelyHeld([]), for: [contact]) XCTAssertFalse(snapshot.isUnavailable) XCTAssertFalse(snapshot.isPending(contact)) XCTAssertTrue(snapshot.allowsSubmission(to: contact)) @@ -299,7 +303,7 @@ final class ContactRequestOutcomesTests: XCTestCase { var restorationFails = true var configures = 0 var snapshot = PrivateRepliesPendingSnapshot() - snapshot.record([], for: [contact]) + snapshot.record(privatelyHeld([]), for: [contact]) let read = { () -> (readiness: PrivateContactReadiness?, restoreFailure: String?) in restorationFails ? (self.readiness(restored: false, failed: true), "the journal could not be read") @@ -390,7 +394,7 @@ final class ContactRequestOutcomesTests: XCTestCase { let contact = Data(repeating: 1, count: 32) var deferredDone = false var snapshot = PrivateRepliesPendingSnapshot() - snapshot.record([], for: [contact]) + snapshot.record(privatelyHeld([]), for: [contact]) let read = { () -> (readiness: PrivateContactReadiness?, restoreFailure: String?) in (self.readiness(restored: deferredDone), nil) } @@ -483,4 +487,157 @@ final class ContactRequestOutcomesTests: XCTestCase { "Private requests for 2 identities were read but not saved. Pull to refresh again." ) } + + /// Code 76: an earlier public request to the pair awaits its outcome, so + /// the wallet signed nothing. Not a failure to retry: the contact shows + /// "being checked" and no submission is offered. + func testShouldShowARefusedUnconfirmedPublicRequestAsBeingChecked() { + let contact = Data(repeating: 1, count: 32) + let outcome = ContactRequestSubmissionOutcome.of( + PlatformWalletError.publicContactRequestUnconfirmed("an earlier request awaits its outcome")) + XCTAssertEqual(outcome, .publicRequestUnconfirmed) + + var snapshot = PrivateRepliesPendingSnapshot() + snapshot.record(privatelyHeld([]), for: [contact]) + snapshot.recordOutcome(outcome, for: contact) + XCTAssertTrue(snapshot.isAwaitingOutcome(contact)) + XCTAssertFalse(snapshot.isPending(contact), "not a private request pending") + XCTAssertFalse(snapshot.allowsSubmission(to: contact)) + XCTAssertEqual(rowMessage(snapshot, contact, error: nil), + ContactRequestSubmissionOutcome.publicRequestUnconfirmedMessage) + } + + /// The public accept, reload and retry sequence: an accept that may have + /// gone through (20) holds the row at once; a reload whose sync did not + /// settle it reads the wallet's public hold, with nothing held privately, + /// and keeps the row held; a second accept is not offered, and one made + /// anyway is refused (76) with no error to retry; a failed read keeps it + /// held; once the sync settles the hold the read frees the pair and the + /// accept comes back. + func testShouldHoldAPublicAcceptThatMayHaveGoneThroughThroughAReloadAndARetryUntilTheWalletSettlesIt() { + let contact = Data(repeating: 1, count: 32) + var wallet = FakeContactRequestHolds() + var snapshot = PrivateRepliesPendingSnapshot() + snapshot.record(wallet.read([contact]), for: [contact]) + XCTAssertTrue(snapshot.allowsSubmission(to: contact), "the accept is offered") + + // The accept broadcasts and its result cannot be confirmed: the + // wallet holds the pair, the signed transition kept. + wallet.publicUnconfirmed.insert(contact) + let accepted = ContactRequestSubmissionOutcome.of( + PlatformWalletError.transactionBroadcastUnconfirmed("result could not be confirmed")) + XCTAssertEqual(accepted, .mayHaveGoneThrough) + snapshot.recordOutcome(accepted, for: contact) + XCTAssertFalse(snapshot.allowsSubmission(to: contact)) + XCTAssertEqual(rowMessage(snapshot, contact, error: nil), + ContactRequestSubmissionOutcome.publicRequestUnconfirmedMessage) + + // A reload whose sync did not settle it: nothing is held privately, + // the public hold stands, and the row stays held. + let reload = wallet.read([contact]) + XCTAssertEqual(reload?.privatelyHeld, [], "the private-pending read is empty") + snapshot.record(reload, for: [contact]) + XCTAssertTrue(snapshot.isAwaitingOutcome(contact)) + XCTAssertFalse(snapshot.allowsSubmission(to: contact), "a second accept is not offered") + XCTAssertEqual(rowMessage(snapshot, contact, error: nil), + ContactRequestSubmissionOutcome.publicRequestUnconfirmedMessage) + + // A second accept made anyway is refused before anything is signed. + let retried = ContactRequestSubmissionOutcome.of( + PlatformWalletError.publicContactRequestUnconfirmed("an earlier request awaits its outcome")) + snapshot.recordOutcome(retried, for: contact) + snapshot.record(wallet.read([contact]), for: [contact]) + XCTAssertFalse(snapshot.allowsSubmission(to: contact)) + XCTAssertEqual(rowMessage(snapshot, contact, error: nil), + ContactRequestSubmissionOutcome.publicRequestUnconfirmedMessage, + "a refusal while held is not an error to retry") + + // A read that fails keeps the row held, as the last read found it. + snapshot.record(nil, for: [contact]) + XCTAssertFalse(snapshot.allowsSubmission(to: contact)) + XCTAssertEqual(rowMessage(snapshot, contact, error: nil), + ContactRequestSubmissionOutcome.publicRequestUnconfirmedMessage) + + // The sync settles the hold (a proved-used nonce frees the pair; a + // request found is recorded as sent and the row leaves the list): + // the next read says free and the accept comes back. + wallet.publicUnconfirmed.remove(contact) + snapshot.record(wallet.read([contact]), for: [contact]) + XCTAssertFalse(snapshot.isAwaitingOutcome(contact)) + XCTAssertTrue(snapshot.allowsSubmission(to: contact)) + XCTAssertNil(rowMessage(snapshot, contact, error: nil)) + } + + /// An unconfirmed submission marks only its own contact, and a read that + /// does not cover it leaves the mark. + func testShouldKeepAnUnconfirmedSubmissionAReadDidNotCover() { + let unconfirmed = Data(repeating: 1, count: 32) + let other = Data(repeating: 2, count: 32) + var snapshot = PrivateRepliesPendingSnapshot() + snapshot.recordOutcome(.mayHaveGoneThrough, for: unconfirmed) + snapshot.record(privatelyHeld([]), for: [other]) + XCTAssertTrue(snapshot.isAwaitingOutcome(unconfirmed)) + XCTAssertTrue(snapshot.allowsSubmission(to: other)) + } + + /// The views' read covers both holds for every contact: the private one + /// and the public one. A sender the wallet does not manage (code 7) holds + /// nothing; any other failed read is nil, never "nothing held". + func testShouldReadBothHoldsAndTellAnUnmanagedIdentityFromAFailedRead() { + let privately = Data(repeating: 1, count: 32) + let publicly = Data(repeating: 2, count: 32) + let free = Data(repeating: 3, count: 32) + var wallet = FakeContactRequestHolds() + wallet.privatelyHeld = [privately] + wallet.publicUnconfirmed = [publicly] + XCTAssertEqual( + wallet.read([privately, publicly, free]), + ContactRequestHolds(privatelyHeld: [privately], publicUnconfirmed: [publicly])) + + wallet.failure = PlatformWalletError.identityNotFound("not managed by this wallet") + XCTAssertEqual(wallet.read([privately]), ContactRequestHolds()) + + wallet.failure = PlatformWalletError.walletOperation("read failed") + XCTAssertNil(wallet.read([privately])) + } +} + +/// A read of both holds that found `contacts` held for a private request and +/// none awaiting a public request's outcome. +private func privatelyHeld(_ contacts: Set) -> ContactRequestHolds { + ContactRequestHolds(privatelyHeld: contacts) +} + +/// The message the views show for `contact`, from `snapshot`. +private func rowMessage(_ snapshot: PrivateRepliesPendingSnapshot, _ contact: Data, error: String?) -> String? { + ContactRequestSubmissionOutcome.rowMessage( + pending: snapshot.isPending(contact), + awaitingOutcome: snapshot.isAwaitingOutcome(contact), + restoring: snapshot.isRestoringPrivatePayments, + restoreFailure: snapshot.restoreFailure, + unavailable: snapshot.isUnavailable, + error: error) +} + +/// The wallet's two per-contact holds, read through the views' helper +/// (`contactRequestHolds`) as the wallet answers them. +private struct FakeContactRequestHolds { + var privatelyHeld: Set = [] + var publicUnconfirmed: Set = [] + /// Thrown by every read when set. + var failure: Error? + + func read(_ contactIds: [Data]) -> ContactRequestHolds? { + contactRequestHolds( + contactIds: contactIds, + privatePairHeld: { contact in + if let failure { throw failure } + return privatelyHeld.contains(contact) + }, + publicUnconfirmed: { contact in + if let failure { throw failure } + return publicUnconfirmed.contains(contact) + } + ) + } } From 78bbe9ca93a72c9d6167ddf7513edd721b3eca28 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Tue, 10 Nov 2026 23:55:55 +0700 Subject: [PATCH 267/269] feat(kotlin-sdk): type code 76 and read a public contact request's hold DashpayNative.publicContactRequestUnconfirmed bridges platform_wallet_get_public_contact_request_unconfirmed, and Dashpay.publicContactRequestsUnconfirmed reads it per contact, empty for an identity the wallet does not manage, as privateRepliesPending does. Native code 76 maps to DashSdkError.PlatformWallet.PublicContactRequestUnconfirmed, apart from a private request pending (71); DashSdkErrorTest pins it, FfiSmokeTest binds the new symbol, and registry row 76 names the Kotlin types. Co-Authored-By: Claude Opus 5.5 --- .../dashfoundation/dashsdk/FfiSmokeTest.kt | 7 ++- .../dashsdk/errors/DashSdkError.kt | 17 ++++++++ .../dashsdk/ffi/DashpayNative.kt | 16 +++++++ .../dashfoundation/dashsdk/tokens/Dashpay.kt | 35 +++++++++++++++ .../dashsdk/errors/DashSdkErrorTest.kt | 16 +++++++ .../ERROR_CODE_REGISTRY.md | 2 +- packages/rs-unified-sdk-jni/src/dashpay.rs | 43 +++++++++++++++++++ 7 files changed, 133 insertions(+), 3 deletions(-) diff --git a/packages/kotlin-sdk/sdk/src/androidTest/kotlin/org/dashfoundation/dashsdk/FfiSmokeTest.kt b/packages/kotlin-sdk/sdk/src/androidTest/kotlin/org/dashfoundation/dashsdk/FfiSmokeTest.kt index a0dc7ac0c16..419de77c636 100644 --- a/packages/kotlin-sdk/sdk/src/androidTest/kotlin/org/dashfoundation/dashsdk/FfiSmokeTest.kt +++ b/packages/kotlin-sdk/sdk/src/androidTest/kotlin/org/dashfoundation/dashsdk/FfiSmokeTest.kt @@ -131,12 +131,15 @@ class FfiSmokeTest { ) } // The private contact read is benign on a miss (null, as Swift's - // nil); the pair-hold and restore-failure reads report an unknown - // wallet handle. + // nil); the pair-hold, public-unconfirmed and restore-failure reads + // report an unknown wallet handle. assertNull(DashpayNative.managedIdentityPrivateContact(0L, ByteArray(32))) assertThrows(DashSDKException::class.java) { DashpayNative.privatePairHeld(0L, ByteArray(32), ByteArray(32)) } + assertThrows(DashSDKException::class.java) { + DashpayNative.publicContactRequestUnconfirmed(0L, ByteArray(32), ByteArray(32)) + } assertThrows(DashSDKException::class.java) { DashpayNative.privatePaymentsRestoreFailure(0L, ByteArray(32)) } diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/errors/DashSdkError.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/errors/DashSdkError.kt index b77c17eef91..91547fb3e1d 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/errors/DashSdkError.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/errors/DashSdkError.kt @@ -779,6 +779,22 @@ sealed class DashSdkError( class SdkPinnedBelowProtocolVersion(message: String, cause: Throwable? = null) : PlatformWallet(message, cause) + /** + * `ErrorPublicContactRequestUnconfirmed` (native code 76). A public + * contact request to that identity was broadcast and may have been + * delivered, and no proof settled its outcome yet, so the wallet + * holds the pair and signed nothing new to it, public or private. + * The send or accept that broadcast it threw + * [TransactionBroadcastUnconfirmed] (20). Not retryable as it is: a + * DashPay sync settles it (the request found: recorded as sent; its + * nonce proved used: the pair freed), and + * `Dashpay.publicContactRequestsUnconfirmed` reads whether it still + * stands. Not a private request pending ([PrivateContactRequestPending]). + * ← Swift `PlatformWalletError.publicContactRequestUnconfirmed`. + */ + class PublicContactRequestUnconfirmed(message: String, cause: Throwable? = null) : + PlatformWallet(message, cause) + /** * Any other `PlatformWalletFFIResultCode` without a dedicated type. * Carries the platform-wallet [nativeCode] (already de-offset) and @@ -987,6 +1003,7 @@ sealed class DashSdkError( 73 -> PlatformWallet.PrivateContactRecoveryUnsupported(message, cause) // ErrorPrivateContactRecoveryUnsupported 74 -> PlatformWallet.PrivateContactStoreNotRead(message, cause) // ErrorPrivateContactStoreNotRead 75 -> PlatformWallet.SdkPinnedBelowProtocolVersion(message, cause) // ErrorSdkPinnedBelowProtocolVersion + 76 -> PlatformWallet.PublicContactRequestUnconfirmed(message, cause) // ErrorPublicContactRequestUnconfirmed else -> // @Deprecated fallback — see the code-6 arm; code 31 is the // real discriminator. diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/DashpayNative.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/DashpayNative.kt index 1e902276585..60f99620a5e 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/DashpayNative.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/DashpayNative.kt @@ -349,6 +349,22 @@ internal object DashpayNative { */ external fun privatePairHeld(walletHandle: Long, senderId: ByteArray, recipientId: ByteArray): Boolean + /** + * Whether a public contact request from [senderId], one of the wallet's + * identities, to [recipientId] was broadcast and may have been + * delivered, with no proof settling its outcome yet + * (`platform_wallet_get_public_contact_request_unconfirmed`). While it + * stands, the wallet refuses every contact request or acceptance to the + * recipient (code 76); a DashPay sync settles it. An unknown wallet + * handle throws (NotFound), as does a sender the wallet does not hold. + * ← Swift `ManagedPlatformWallet.publicContactRequestUnconfirmed(senderId:recipientId:)`. + */ + external fun publicContactRequestUnconfirmed( + walletHandle: Long, + senderId: ByteArray, + recipientId: ByteArray, + ): Boolean + /** * What a contact request from [identityId], one of the wallet's * identities, waits for, as the wallet decides it: four flags, diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/tokens/Dashpay.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/tokens/Dashpay.kt index 1e272e2e030..4060881efa7 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/tokens/Dashpay.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/tokens/Dashpay.kt @@ -511,6 +511,41 @@ class Dashpay internal constructor(private val walletHandle: Long, } } + /** + * The contacts among [contactIds] to which a public contact request from + * [ourIdentityId] was broadcast and may have been delivered, with no + * proof settling its outcome yet (`DashPayView::public_contact_request_unconfirmed`, + * one read per contact). The send or accept that broadcast it threw + * [DashSdkError.PlatformWallet.TransactionBroadcastUnconfirmed] (20); + * while it stands, the wallet refuses every contact request or acceptance + * to the contact, public or private + * ([DashSdkError.PlatformWallet.PublicContactRequestUnconfirmed], 76), so + * a host shows the pair as unconfirmed and offers neither. A DashPay sync + * settles it: a request found is recorded as sent, a nonce proved used + * frees the pair, and otherwise the same signed bytes are broadcast + * again. Read it beside [privateRepliesPending]. Empty when the identity + * isn't managed. + * ← Swift `ManagedPlatformWallet.publicContactRequestUnconfirmed(senderId:recipientId:)` + * (packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PrivateContactRequests.swift). + */ + suspend fun publicContactRequestsUnconfirmed( + ourIdentityId: ByteArray, + contactIds: List, + ): List = withContext(Dispatchers.IO) { + if (contactIds.isEmpty()) return@withContext emptyList() + mapNativeErrors { + // An identity the wallet does not manage holds nothing. + val managed = withManagedIdentity(ourIdentityId) { true } ?: false + if (!managed) { + emptyList() + } else { + contactIds.filter { + DashpayNative.publicContactRequestUnconfirmed(walletHandle, ourIdentityId, it) + } + } + } + } + /** * The incoming contact request from [senderId] to [ourIdentityId] in * local state, or null when there is none (or the identity isn't diff --git a/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/errors/DashSdkErrorTest.kt b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/errors/DashSdkErrorTest.kt index b11ed944f21..f56f0736e5e 100644 --- a/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/errors/DashSdkErrorTest.kt +++ b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/errors/DashSdkErrorTest.kt @@ -384,6 +384,22 @@ class DashSdkErrorTest { assertFalse("only raising the pin lifts it", mapped.isRetryable) } + /** + * A public request awaiting its outcome has its own code, apart from a + * private request pending (71): a DashPay sync, not a private one, + * settles it. + */ + @Test + fun shouldReportAnUnconfirmedPublicRequestByItsOwnCode() { + val detail = "a public contact request to this identity awaits its outcome" + val native = DashSDKException(DashSdkError.PLATFORM_WALLET_CODE_OFFSET + 76, detail) + val mapped = DashSdkError.fromNative(native) + assertTrue(mapped is DashSdkError.PlatformWallet.PublicContactRequestUnconfirmed) + assertFalse(mapped is DashSdkError.PlatformWallet.PrivateContactRequestPending) + assertEquals(detail, mapped.message) + assertFalse("a sync settles it, not a retry", mapped.isRetryable) + } + @Test fun platformShieldCapacityCode41MapsTyped() { val message = diff --git a/packages/rs-platform-wallet-ffi/ERROR_CODE_REGISTRY.md b/packages/rs-platform-wallet-ffi/ERROR_CODE_REGISTRY.md index 6f0b836ff87..0dee9fa8e70 100644 --- a/packages/rs-platform-wallet-ffi/ERROR_CODE_REGISTRY.md +++ b/packages/rs-platform-wallet-ffi/ERROR_CODE_REGISTRY.md @@ -191,7 +191,7 @@ Fork-era numbers remain in the collision history, which is immutable record. | 73 | `ErrorPrivateContactRecoveryUnsupported` | DashPay private contact requests | Proposed. Maps `PlatformWalletError::PrivateContactRecoveryUnsupported`: the persister does not declare `PRIVATE_CONTACT_REQUEST_SCAN_STATE` (bit 15) 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. The FFI constant is `PLATFORM_WALLET_PERSISTENCE_CAPABILITY_PRIVATE_CONTACT_REQUEST_SCAN_STATE` (`1 << 15`). Swift types it (`PlatformWalletError.privateContactRecoveryUnsupported`, `ErrorHandlingTests` pin), SwiftExampleApp shows it as a storage configuration error, and the SwiftData handler declares bit 0 and bit 15 with the begin and end callbacks wired; Kotlin types it (`DashSdkError.PlatformWallet.PrivateContactRecoveryUnsupported`, `DashSdkErrorTest` pin), KotlinExampleApp shows it as a storage configuration error, and the Room handler declares bit 0 and bit 15 with the begin and end callbacks wired | | 74 | `ErrorPrivateContactStoreNotRead` | DashPay private contact requests | Proposed. Maps `PlatformWalletError::PrivateContactStoreNotRead`: DashPay's private contact request store was not read to its end for the identity, with the keys Platform proves it holds, before a contact request or acceptance, public or private, was decided (every one reads it from the identity's cursor first). Nothing was reserved, sealed, paid or broadcast; calling again continues the read from where it stopped. A node behind the store already seen, or keys that kept changing, stop it. It was reported as 71 before, which says not to submit again. Mapped by the blanket conversion and kept by the shielded spend contract (`map_spend_result`). Swift types it (`PlatformWalletError.privateContactStoreNotRead`, `ErrorHandlingTests` pin) and SwiftExampleApp offers a retry with no pending row; Kotlin types it (`DashSdkError.PlatformWallet.PrivateContactStoreNotRead`, `DashSdkErrorTest` pin) and KotlinExampleApp offers a retry with no pending row | | 75 | `ErrorSdkPinnedBelowProtocolVersion` | DashPay private contact requests | Proposed. The SDK refused a request without sending it because the SDK itself is pinned below the protocol version the request needs (`dash_sdk::Error::ProtocolVersionNotActive` with `pinned`), whatever the network runs: for example every contact request's private store read with the SDK pinned below protocol version 15 on a network at 15. A network upgrade does not lift it; only raising or removing the pin does. Before, it shared 70 with a network proven below the version, which lifts by itself. Mapped by the blanket conversion. Swift types it (`PlatformWalletError.sdkPinnedBelowProtocolVersion`, `ErrorHandlingTests` pin) and SwiftExampleApp shows a developer-facing message; Kotlin types it (`DashSdkError.PlatformWallet.SdkPinnedBelowProtocolVersion`, `DashSdkErrorTest` pin) and KotlinExampleApp shows a developer-facing message | -| 76 | `ErrorPublicContactRequestUnconfirmed` | DashPay private contact requests | Proposed. Maps `PlatformWalletError::PublicContactRequestUnconfirmed`: a public contact request to that identity was broadcast and may have been delivered, and no proof settled its outcome yet, so the wallet holds the pair and signs nothing new to it, public or private. The send or accept that broadcast it reported 20; every later one is refused with 76 until a public contact request sync settles it (its document proved on Platform: recorded as sent; its transition proved unable to write it: the pair freed), broadcasting the same signed bytes again meanwhile. Not a private request pending (71). Mapped by the blanket conversion and by `map_spend_result`. `platform_wallet_get_public_contact_request_unconfirmed` reads the hold per pair (`DashPayView::public_contact_request_unconfirmed`; 7 for a sender the wallet does not hold). Swift types it (`PlatformWalletError.publicContactRequestUnconfirmed`, `ErrorHandlingTests` pin) and wraps the read (`ManagedPlatformWallet.publicContactRequestUnconfirmed`); SwiftExampleApp shows the pair as being checked, with no request or accept offered, until the read says settled, and its refreshes run the DashPay sync that settles it; Kotlin typing is added by the Kotlin PR (#5813) | +| 76 | `ErrorPublicContactRequestUnconfirmed` | DashPay private contact requests | Proposed. Maps `PlatformWalletError::PublicContactRequestUnconfirmed`: a public contact request to that identity was broadcast and may have been delivered, and no proof settled its outcome yet, so the wallet holds the pair and signs nothing new to it, public or private. The send or accept that broadcast it reported 20; every later one is refused with 76 until a public contact request sync settles it (its document proved on Platform: recorded as sent; its transition proved unable to write it: the pair freed), broadcasting the same signed bytes again meanwhile. Not a private request pending (71). Mapped by the blanket conversion and by `map_spend_result`. `platform_wallet_get_public_contact_request_unconfirmed` reads the hold per pair (`DashPayView::public_contact_request_unconfirmed`; 7 for a sender the wallet does not hold). Swift types it (`PlatformWalletError.publicContactRequestUnconfirmed`, `ErrorHandlingTests` pin) and wraps the read (`ManagedPlatformWallet.publicContactRequestUnconfirmed`); SwiftExampleApp shows the pair as being checked, with no request or accept offered, until the read says settled, and its refreshes run the DashPay sync that settles it; Kotlin types it (`DashSdkError.PlatformWallet.PublicContactRequestUnconfirmed`, `DashSdkErrorTest` pin), bridges the read (`DashpayNative.publicContactRequestUnconfirmed`, `Dashpay.publicContactRequestsUnconfirmed`), and KotlinExampleApp shows the pair as SwiftExampleApp does | **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-unified-sdk-jni/src/dashpay.rs b/packages/rs-unified-sdk-jni/src/dashpay.rs index 368735947d5..b240a4f261a 100644 --- a/packages/rs-unified-sdk-jni/src/dashpay.rs +++ b/packages/rs-unified-sdk-jni/src/dashpay.rs @@ -1421,6 +1421,49 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_DashpayNative_private }) } +/// Whether a public contact request from `senderId`, one of the wallet's +/// identities, to `recipientId` was broadcast and may have been delivered, +/// with no proof settling its outcome yet (bridges +/// `platform_wallet_get_public_contact_request_unconfirmed`). While it +/// stands, the wallet refuses every contact request or acceptance to the +/// recipient with code 76; a DashPay sync settles it. Throws on an unknown +/// wallet or sender, returning false. +/// ← Swift `ManagedPlatformWallet.publicContactRequestUnconfirmed(senderId:recipientId:)`. +#[no_mangle] +pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_DashpayNative_publicContactRequestUnconfirmed( + mut env: JNIEnv, + _class: JClass, + wallet_handle: jlong, + sender_id: JByteArray, + recipient_id: JByteArray, +) -> jboolean { + guard(&mut env, JNI_FALSE, |env| { + let Some(sender) = read_id32(env, &sender_id, "senderId") else { + return JNI_FALSE; + }; + let Some(recipient) = read_id32(env, &recipient_id, "recipientId") else { + return JNI_FALSE; + }; + let mut unconfirmed = false; + let result = unsafe { + platform_wallet_ffi::platform_wallet_get_public_contact_request_unconfirmed( + wallet_handle as Handle, + sender.as_ptr(), + recipient.as_ptr(), + &mut unconfirmed, + ) + }; + if take_pwffi_error(env, result) { + return JNI_FALSE; + } + if unconfirmed { + JNI_TRUE + } else { + JNI_FALSE + } + }) +} + /// `DashpayNative.privatePaymentsRestoreFailure`: why the last restoration /// of the wallet's private payments failed, while they are not restored for /// `identity_id` (bridges `platform_wallet_get_private_payments_restore_failure`), From 9cc0f89dff0fb87a596a3e2ad1b1a2a9dd732033 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Tue, 10 Nov 2026 23:55:55 +0700 Subject: [PATCH 268/269] fix(kotlin-example-app): hold a public request that may have gone through until the wallet settles it Requests and Add Contact read only the private pair hold, so after a public accept or send that returned 20 the reload found nothing held and offered Accept or Send again while the first one could still execute. They now read both of the wallet's holds per contact (contactRequestHolds): while either holds the pair, no request or accept is offered, and a public hold shows "This request may have gone through; it is being checked." A 20 or a 76 from a send or accept shows the same at once and starts a DashPay sync, never a retry. The Requests pull-to-refresh runs that sync, which settles the hold first, and Add Contact gets a "Check again" button that does the same; both read again whenever a DashPay sync pass ends. The invitation claim's contact request reads 18, 20 and 76 the same way instead of as a failure. As SwiftExampleApp does. The parity manifest adds the read to shared_symbols and the capability's shared APIs, describes the public hold for both hosts, lists its tests as verification, and says the readiness timer runs for as long as the screen is shown, which the round before left as a bounded timer. Co-Authored-By: Claude Opus 5.5 --- docs/sdk/sdk-parity-manifest.json | 32 ++- .../example/ui/dashpay/AddContactScreen.kt | 91 +++++--- .../ui/dashpay/ClaimInvitationSheet.kt | 15 +- .../ui/dashpay/ContactRequestOutcomes.kt | 143 +++++++++++-- .../ui/dashpay/ContactRequestsScreen.kt | 28 ++- .../example/ui/dashpay/DashPaySyncHelpers.kt | 44 ++-- .../ui/dashpay/ContactRequestOutcomesTest.kt | 201 ++++++++++++++++-- 7 files changed, 459 insertions(+), 95 deletions(-) diff --git a/docs/sdk/sdk-parity-manifest.json b/docs/sdk/sdk-parity-manifest.json index ccdcf7ebfc5..addcb451cca 100644 --- a/docs/sdk/sdk-parity-manifest.json +++ b/docs/sdk/sdk-parity-manifest.json @@ -50,6 +50,7 @@ "platform_wallet_get_private_contact_readiness": "packages/rs-platform-wallet-ffi/src/contact_request.rs", "platform_wallet_get_private_pair_held": "packages/rs-platform-wallet-ffi/src/private_contact_request.rs", "platform_wallet_get_private_payments_restore_failure": "packages/rs-platform-wallet-ffi/src/private_contact_request.rs", + "platform_wallet_get_public_contact_request_unconfirmed": "packages/rs-platform-wallet-ffi/src/private_contact_request.rs", "platform_wallet_manager_accept_contact_request_replying_in_kind": "packages/rs-platform-wallet-ffi/src/private_contact_request.rs", "platform_wallet_manager_accept_private_contact_request": "packages/rs-platform-wallet-ffi/src/private_contact_request.rs", "platform_wallet_manager_create_with_persistence_capabilities": "packages/rs-platform-wallet-ffi/src/manager.rs", @@ -786,7 +787,7 @@ }, { "id": "dashpay.private_contact_requests", - "title": "DashPay private contact requests: store sync, shielded send, accept replying in kind, private-reply accept, private contact and pair-hold reads, codes 68 to 75, readiness and restore failure", + "title": "DashPay private contact requests: store sync, shielded send, accept replying in kind, private-reply accept, private contact, pair-hold and public-unconfirmed reads, codes 68 to 76, readiness and restore failure", "area": "correctness", "shared_apis": [ "platform_wallet_sync_private_contact_requests", @@ -795,6 +796,7 @@ "platform_wallet_manager_accept_private_contact_request", "managed_identity_get_private_contact", "platform_wallet_get_private_pair_held", + "platform_wallet_get_public_contact_request_unconfirmed", "platform_wallet_get_private_contact_readiness", "platform_wallet_get_private_payments_restore_failure" ], @@ -807,13 +809,13 @@ "sdk": "partial", "example_app": "partial", "restart": "required", - "reason": "PrivateContactRequests.swift wraps the store sync, the shielded send, the accept replying in kind (the wallet replies privately to a request that came privately, and with a document otherwise; a public or adopted reply needs no shielded support or payment mnemonic read, and a build without shielded support refuses only a private reply, with code 69), the explicit private-reply accept, PrivateContactInfo and the wallet's pair-hold read (privatePairHeld, which answers as a send decides it, the shielded store's journal included), all signed with the identity signer the public calls take and counted as native manager operations; codes 68 to 75 are typed and pinned in ErrorHandlingTests. The sync, send and accepts have no automated or recorded gate: a private reply needs a protocol version 15 network with shielded funds. The private marker (core height 0) round-trips through the persisted coreHeightCreatedAt, but no test covers a restart; a paid reply's hold survives one in the shielded store's payment journal, which no host test exercises. The example app has the send-privately toggle, accepts with acceptContactRequestReplyingInKind, keeps a held or unconfirmed private send or reply pending by reading the wallet's per-pair hold (a failed read keeps the last snapshot and the controls off), re-enables a pair only when that read says free (a payment that never lands frees it only once the wallet proves it expired), offers a private sync for a pending request a public send or accept meets (code 71), and reports private refresh failures (quiet before protocol version 15), with no recorded gate. Code 72 (unrestored private payments) is typed apart from 71, and the readiness getter and the restore-failure reason are bound; the example app configures the shielded store when it activates a manager, before loading wallets; on each DashPay refresh while the store did not open or the readiness reads not restored it asks the wallet to retry (a repeat configure) and reads the readiness after that, refreshing again on a bounded timer while it stays unrestored; it shows \"setting up\", why the last restoration failed or why the store did not open, from 72 or the readiness, lifted only by a readiness read that says restored, never by a private sync. Code 73 (a store that cannot keep a private payment's recovery state) is typed and shown as a storage configuration error; the host declares atomic changesets with the scan state, so it should not occur. Code 74 (a private store read that did not finish) is a retry with no pending row, and code 75 (an SDK pinned below the protocol version) a developer-facing message. Code 20 (an SDK outcome left unconfirmed after a possible acceptance, which the spend contract now keeps) reads as may have gone through, as 18 does, with no retry offered." + "reason": "PrivateContactRequests.swift wraps the store sync, the shielded send, the accept replying in kind (the wallet replies privately to a request that came privately, and with a document otherwise; a public or adopted reply needs no shielded support or payment mnemonic read, and a build without shielded support refuses only a private reply, with code 69), the explicit private-reply accept, PrivateContactInfo and the wallet's pair-hold read (privatePairHeld, which answers as a send decides it, the shielded store's journal included), all signed with the identity signer the public calls take and counted as native manager operations; codes 68 to 76 are typed and pinned in ErrorHandlingTests. The sync, send and accepts have no automated or recorded gate: a private reply needs a protocol version 15 network with shielded funds. The private marker (core height 0) round-trips through the persisted coreHeightCreatedAt, but no test covers a restart; a paid reply's hold survives one in the shielded store's payment journal, which no host test exercises. The example app has the send-privately toggle, accepts with acceptContactRequestReplyingInKind, keeps a held or unconfirmed private send or reply pending by reading the wallet's per-pair hold (a failed read keeps the last snapshot and the controls off), re-enables a pair only when that read says free (a payment that never lands frees it only once the wallet proves it expired), offers a private sync for a pending request a public send or accept meets (code 71), and reports private refresh failures (quiet before protocol version 15), with no recorded gate. Code 72 (unrestored private payments) is typed apart from 71, and the readiness getter and the restore-failure reason are bound; the example app configures the shielded store when it activates a manager, before loading wallets; on each DashPay refresh while the store did not open or the readiness reads not restored it asks the wallet to retry (a repeat configure) and reads the readiness after that, refreshing again on a timer for as long as the view is shown while it stays unrestored; it shows \"setting up\", why the last restoration failed or why the store did not open, from 72 or the readiness, lifted only by a readiness read that says restored, never by a private sync. Code 73 (a store that cannot keep a private payment's recovery state) is typed and shown as a storage configuration error; the host declares atomic changesets with the scan state, so it should not occur. Code 74 (a private store read that did not finish) is a retry with no pending row, and code 75 (an SDK pinned below the protocol version) a developer-facing message. Code 20 (an SDK outcome left unconfirmed after a possible acceptance, which the spend contract now keeps) reads as may have gone through, as 18 does, with no retry offered. A public send or accept that returned 20 is held by the wallet until a DashPay sync settles it, and every later one to the pair is refused with code 76 (typed as publicContactRequestUnconfirmed): the example app reads that hold per pair (publicContactRequestUnconfirmed) beside the private one, shows the pair as being checked with no request or accept offered until the read says settled, shows 20 and 76 the same way at once, and runs the DashPay sync on the request list's refresh and Add Contact's Check again, reading again when any sync pass ends; no gate records it against a network." }, "kotlin": { "sdk": "partial", "example_app": "partial", "restart": "required", - "reason": "Dashpay.syncPrivateContactRequests, privateContact, privateRepliesPending (one pair-hold read per contact, as a send decides it, the shielded store's journal included), privateContactReadiness and privatePaymentsRestoreFailure, and PlatformWalletManager.sendPrivateContactRequest / acceptContactRequestReplyingInKind / acceptPrivateContactRequest bridge the same FFI (signed with the manager's identity signer, behind the manager's teardown gate); the in-kind accept is exported in every build, and codes 68 to 75 map to typed PlatformWallet errors (DashSdkErrorTest). JNI descriptors are covered only by the FfiSmokeTest symbol test; the flows need a protocol version 15 network with shielded funds and have no recorded gate, no test covers a restart, and a paid reply's hold survives one only in the shielded store's payment journal, which no host test exercises. The example app accepts with acceptContactRequestReplyingInKind, keeps a held or unconfirmed reply pending by reading the wallet's per-pair hold on its Requests rows and Add Contact (a failed read keeps the last snapshot and the controls off), re-enables a pair only when that read says free, offers a private sync for a pending request a public send or accept meets (code 71), and reports private refresh failures (quiet before protocol version 15); it has no send-privately toggle. Code 72 (unrestored private payments) is typed apart from 71, and the readiness getter and the restore-failure reason are bound; the example app configures the shielded store when it activates the manager, before loading wallets; on each DashPay refresh while the store did not open or the readiness reads not restored it asks the wallet to retry (a repeat configure) and reads the readiness after that, refreshing again on a bounded timer while it stays unrestored; it shows \"setting up\", why the last restoration failed or why the store did not open, from 72 or the readiness, lifted only by a readiness read that says restored, never by a private sync. Code 73 (a store that cannot keep a private payment's recovery state) is typed and shown as a storage configuration error; the host declares atomic changesets with the scan state (bit 15), so it should not occur. Code 74 (a private store read that did not finish) is a retry with no pending row, and code 75 (an SDK pinned below the protocol version) a developer-facing message. Code 20 (an SDK outcome left unconfirmed after a possible acceptance, which the spend contract now keeps) reads as may have gone through, as 18 does, with no retry offered." + "reason": "Dashpay.syncPrivateContactRequests, privateContact, privateRepliesPending (one pair-hold read per contact, as a send decides it, the shielded store's journal included), privateContactReadiness and privatePaymentsRestoreFailure, and PlatformWalletManager.sendPrivateContactRequest / acceptContactRequestReplyingInKind / acceptPrivateContactRequest bridge the same FFI (signed with the manager's identity signer, behind the manager's teardown gate); the in-kind accept is exported in every build, and codes 68 to 76 map to typed PlatformWallet errors (DashSdkErrorTest). JNI descriptors are covered only by the FfiSmokeTest symbol test; the flows need a protocol version 15 network with shielded funds and have no recorded gate, no test covers a restart, and a paid reply's hold survives one only in the shielded store's payment journal, which no host test exercises. The example app accepts with acceptContactRequestReplyingInKind, keeps a held or unconfirmed reply pending by reading the wallet's per-pair hold on its Requests rows and Add Contact (a failed read keeps the last snapshot and the controls off), re-enables a pair only when that read says free, offers a private sync for a pending request a public send or accept meets (code 71), and reports private refresh failures (quiet before protocol version 15); it has no send-privately toggle. Code 72 (unrestored private payments) is typed apart from 71, and the readiness getter and the restore-failure reason are bound; the example app configures the shielded store when it activates the manager, before loading wallets; on each DashPay refresh while the store did not open or the readiness reads not restored it asks the wallet to retry (a repeat configure) and reads the readiness after that, refreshing again on a timer for as long as the screen is shown while it stays unrestored; it shows \"setting up\", why the last restoration failed or why the store did not open, from 72 or the readiness, lifted only by a readiness read that says restored, never by a private sync. Code 73 (a store that cannot keep a private payment's recovery state) is typed and shown as a storage configuration error; the host declares atomic changesets with the scan state (bit 15), so it should not occur. Code 74 (a private store read that did not finish) is a retry with no pending row, and code 75 (an SDK pinned below the protocol version) a developer-facing message. Code 20 (an SDK outcome left unconfirmed after a possible acceptance, which the spend contract now keeps) reads as may have gone through, as 18 does, with no retry offered. A public send or accept that returned 20 is held by the wallet until a DashPay sync settles it, and every later one to the pair is refused with code 76 (typed as PublicContactRequestUnconfirmed): Dashpay.publicContactRequestsUnconfirmed reads that hold per pair, and the example app reads it beside the private one, shows the pair as being checked with no request or accept offered until the read says settled, shows 20 and 76 the same way at once, and runs the DashPay sync on the Requests refresh and Add Contact's Check again, reading again when any sync pass ends; no gate records it against a network." } }, "verification": [ @@ -857,6 +859,14 @@ "command": "cargo test -p platform-wallet-ffi --lib should_tell_a_private_request_from_other_accept_failures_by_code", "covers_restart": false }, + { + "host": "shared", + "kind": "unit", + "file": "packages/rs-platform-wallet-ffi/src/error.rs", + "id": "should_report_an_unconfirmed_public_request_by_its_own_code", + "command": "cargo test -p platform-wallet-ffi --lib should_report_an_unconfirmed_public_request_by_its_own_code", + "covers_restart": false + }, { "host": "swift", "kind": "unit", @@ -929,6 +939,14 @@ "command": "xcodebuild test -project packages/swift-sdk/SwiftExampleApp/SwiftExampleApp.xcodeproj -scheme SwiftExampleApp -destination 'platform=iOS Simulator,name=iPhone 17 Pro' -only-testing:SwiftExampleAppTests/ContactRequestOutcomesTests", "covers_restart": false }, + { + "host": "swift", + "kind": "unit", + "file": "packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/ContactRequestOutcomesTests.swift", + "id": "testShouldHoldAPublicAcceptThatMayHaveGoneThroughThroughAReloadAndARetryUntilTheWalletSettlesIt", + "command": "xcodebuild test -project packages/swift-sdk/SwiftExampleApp/SwiftExampleApp.xcodeproj -scheme SwiftExampleApp -destination 'platform=iOS Simulator,name=iPhone 17 Pro' -only-testing:SwiftExampleAppTests/ContactRequestOutcomesTests", + "covers_restart": false + }, { "host": "kotlin", "kind": "unit", @@ -936,6 +954,14 @@ "id": "shouldRetryARestorationThatFailedAfterTheStoreOpenedOnARefresh", "command": "cd packages/kotlin-sdk && ./gradlew :app:testDebugUnitTest --tests '*ContactRequestOutcomesTest*'", "covers_restart": false + }, + { + "host": "kotlin", + "kind": "unit", + "file": "packages/kotlin-sdk/KotlinExampleApp/app/src/test/java/org/dashfoundation/example/ui/dashpay/ContactRequestOutcomesTest.kt", + "id": "shouldKeepAPublicAcceptHeldAfterCode20ThroughAnEmptyPrivateReadAndAnUnsettledSync", + "command": "cd packages/kotlin-sdk && ./gradlew :app:testDebugUnitTest --tests '*ContactRequestOutcomesTest*'", + "covers_restart": false } ] }, diff --git a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/AddContactScreen.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/AddContactScreen.kt index a168d7e4ad5..a7bfcfccb1a 100644 --- a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/AddContactScreen.kt +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/AddContactScreen.kt @@ -105,18 +105,21 @@ fun AddContactScreen(identityIdHex: String, navController: NavHostController) { // submit again, as the send screen does for an unconfirmed spend. var unconfirmedNotice by remember { mutableStateOf(null) } // Whether the wallet holds the pair with the resolved recipient for a - // private request, as a send decides it (`privateRepliesPending`): in - // flight, paid and not yet read back, or kept by the shielded store's - // journal until the payment is proven never to land. While it does, Send - // and the collision accept are off and the screen offers a private sync, - // which reads a landed request back. Read from the wallet, so reopening - // this screen keeps it. A failed read keeps them off until a read - // succeeds. + // private request, as a send decides it (in flight, paid and not yet + // read back, or kept by the shielded store's journal until the payment is + // proven never to land), or for a public request awaiting its outcome + // (`contactRequestHolds`). While it does, Send and the collision accept + // are off and the screen offers the sync that settles it: the private + // one, which reads a landed request back, or the DashPay one, which + // settles a public request. Read from the wallet, so reopening this + // screen keeps it. A failed read keeps them off until a read succeeds. var recipientPending by remember { mutableStateOf(PrivateRepliesPendingSnapshot()) } // Why the shielded store could not be opened, as last read with the // pending state; null when it is open. var shieldedStoreFailure by remember { mutableStateOf(null) } var isSyncingPrivate by remember { mutableStateOf(false) } + // A DashPay sync the screen started is running. + var isCheckingPublic by remember { mutableStateOf(false) } var pendingReadVersion by remember { mutableStateOf(0) } // The automatic refreshes made since the wallet's private payments were // last read restored (`PrivatePaymentsRecovery.recheckDelayMs`). @@ -135,6 +138,7 @@ fun AddContactScreen(identityIdHex: String, navController: NavHostController) { val recipientBlocked = resolvedRecipient != null && ( recipientPending.isRestoringPrivatePayments || + recipientPending.isAwaitingOutcome(resolvedRecipient.toHex()) || recipientPending.isPending(resolvedRecipient.toHex()) || recipientPending.isUnavailable ) @@ -209,15 +213,14 @@ fun AddContactScreen(identityIdHex: String, navController: NavHostController) { configureAgain = { container.configureShieldedStoreAgain() }, ) shieldedStoreFailure = refreshed.storeFailure - val pendingRead = privateRepliesPending(wallet, idBytes, contactIds) + val pendingRead = contactRequestHolds(wallet, idBytes, contactIds) recipientPending = recipientPending .withReadiness(refreshed.read?.readiness, refreshed.read?.restoreFailure) .recorded(pendingRead, contactIds.map { it.toHex() }) // While the payments stay unrestored, refresh again after a pause, for // as long as the screen is shown: a restoration the wallet deferred // behind a busy store completes later and changes no key of this - // effect (Add Contact observes neither a bind nor a DashPay sync for - // it). + // effect (neither a bind nor a DashPay sync ends for it). if (PrivatePaymentsRecovery.waitForRecheck(recipientPending.readiness, readinessRechecks)) { readinessRechecks++ pendingReadVersion++ @@ -238,6 +241,11 @@ fun AddContactScreen(identityIdHex: String, navController: NavHostController) { .distinctUntilChanged() .collect { syncing -> if (!syncing) pendingReadVersion++ } } + // A DashPay sync settles a public request awaiting its outcome: read the + // recipient's holds again when one ends, the background pass included. + LaunchedEffect(manager) { + manager?.dashPaySyncIsSyncing?.collect { syncing -> if (!syncing) pendingReadVersion++ } + } LaunchedEffect(recipientHex) { val recipient = resolvedRecipient val w = wallet @@ -274,16 +282,20 @@ fun AddContactScreen(identityIdHex: String, navController: NavHostController) { kickDashPaySync(scope, m) navController.popBackStack() } catch (e: Exception) { - when (val outcome = ContactRequestSubmissionOutcome.of(e)) { + val outcome = ContactRequestSubmissionOutcome.of(e) + recipientPending = recipientPending.withOutcome(outcome, recipient.toHex()) + when (outcome) { // Nothing was sent: the wallet holds the pair. The screen // shows it as pending, with the private sync, until the // per-contact read says it is free. - ContactRequestSubmissionOutcome.PrivateRequestPending -> - recipientPending = recipientPending.withRefusal(recipient.toHex()) + ContactRequestSubmissionOutcome.PrivateRequestPending -> Unit // Nothing was sent: the screen shows the wallet setting up // its private payments until its readiness says restored. - ContactRequestSubmissionOutcome.PrivatePaymentsNotRestored -> - recipientPending = recipientPending.withRestoringRefusal() + ContactRequestSubmissionOutcome.PrivatePaymentsNotRestored -> Unit + // Nothing was signed: an earlier public request to the + // recipient awaits its outcome. The screen shows it being + // checked, with the DashPay sync, never a retry. + ContactRequestSubmissionOutcome.PublicRequestUnconfirmed -> kickDashPaySync(scope, m) // Nothing was sent and nothing is pending: Send stays on, // and sending again continues the read. ContactRequestSubmissionOutcome.PrivateStoreNotRead -> @@ -320,6 +332,25 @@ fun AddContactScreen(identityIdHex: String, navController: NavHostController) { } } + /** + * Run a DashPay sync, which settles a public request awaiting its + * outcome first (attaching to one already running), then re-read the + * recipient's holds: Send comes back once the wallet frees the pair, and + * a request found is recorded as sent. + */ + fun checkPublicRequest() { + val m = manager ?: return + isCheckingPublic = true + scope.launch { + try { + attachOrStartSync(m) + } finally { + isCheckingPublic = false + pendingReadVersion++ + } + } + } + fun acceptIncoming(recipient: ByteArray) { val w = wallet ?: return val m = manager ?: return @@ -343,17 +374,20 @@ fun AddContactScreen(identityIdHex: String, navController: NavHostController) { "accept it from Requests." } } catch (e: Exception) { - when (val outcome = ContactRequestSubmissionOutcome.of(e)) { + val outcome = ContactRequestSubmissionOutcome.of(e) + recipientPending = recipientPending.withOutcome(outcome, recipient.toHex()) + when (outcome) { ContactRequestSubmissionOutcome.MayHaveGoneThrough -> { // Pending, not failed: the next sync settles it. kickDashPaySync(scope, m) unconfirmedNotice = ContactRequestSubmissionOutcome.MAY_HAVE_GONE_THROUGH_MESSAGE } + // Nothing was signed: the screen shows the pair being + // checked, with the DashPay sync, never a retry. + ContactRequestSubmissionOutcome.PublicRequestUnconfirmed -> kickDashPaySync(scope, m) // Nothing was sent: the screen offers the private sync. - ContactRequestSubmissionOutcome.PrivateRequestPending -> - recipientPending = recipientPending.withRefusal(recipient.toHex()) - ContactRequestSubmissionOutcome.PrivatePaymentsNotRestored -> - recipientPending = recipientPending.withRestoringRefusal() + ContactRequestSubmissionOutcome.PrivateRequestPending -> Unit + ContactRequestSubmissionOutcome.PrivatePaymentsNotRestored -> Unit ContactRequestSubmissionOutcome.PrivateStoreNotRead -> errorMessage = ContactRequestSubmissionOutcome.PRIVATE_STORE_NOT_READ_MESSAGE is ContactRequestSubmissionOutcome.Failed -> @@ -465,6 +499,7 @@ fun AddContactScreen(identityIdHex: String, navController: NavHostController) { Text( ContactRequestSubmissionOutcome.rowMessage( pending = recipientPending.isPending(recipient.toHex()), + awaitingOutcome = recipientPending.isAwaitingOutcome(recipient.toHex()), restoring = recipientPending.isRestoringPrivatePayments, restoreFailure = recipientPending.restoreFailure, shieldedStoreFailure = shieldedStoreFailure, @@ -474,10 +509,18 @@ fun AddContactScreen(identityIdHex: String, navController: NavHostController) { style = MaterialTheme.typography.bodySmall, modifier = Modifier.testTag("dashpay.addContact.pending"), ) - // A private sync settles a pending request; it does not - // restore private payments, so it is not offered for that. - if (!recipientPending.isRestoringPrivatePayments) { - TextButton( + // A DashPay sync settles a public request awaiting its + // outcome, and a private sync a pending private request; + // neither restores private payments, so neither is offered + // for that. + when { + recipientPending.isRestoringPrivatePayments -> Unit + recipientPending.isAwaitingOutcome(recipient.toHex()) -> TextButton( + onClick = { checkPublicRequest() }, + enabled = !isCheckingPublic, + modifier = Modifier.testTag("dashpay.addContact.checkPublic"), + ) { Text(if (isCheckingPublic) "Checking…" else "Check again") } + else -> TextButton( onClick = { syncPrivateRequests() }, enabled = !isSyncingPrivate, modifier = Modifier.testTag("dashpay.addContact.syncPrivate"), diff --git a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/ClaimInvitationSheet.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/ClaimInvitationSheet.kt index bcf176f8985..9857acaa653 100644 --- a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/ClaimInvitationSheet.kt +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/ClaimInvitationSheet.kt @@ -208,7 +208,20 @@ fun ClaimInvitationSheet( ).close() onClose() } catch (t: Throwable) { - errorMessage = t.message ?: "Sending the contact request failed." + // One that may have gone through (18, 20), or that the wallet + // refused while an earlier one awaits its outcome (76), is not + // a failure: the DashPay sync settles it. + // ← Swift `ClaimInvitationSheet.sendContact`. + errorMessage = when (ContactRequestSubmissionOutcome.of(t)) { + ContactRequestSubmissionOutcome.MayHaveGoneThrough, + ContactRequestSubmissionOutcome.PublicRequestUnconfirmed, + -> { + kickDashPaySync(container.applicationScope, mgr) + "Identity claimed. " + + ContactRequestSubmissionOutcome.PUBLIC_REQUEST_UNCONFIRMED_MESSAGE + } + else -> t.message ?: "Sending the contact request failed." + } } } } diff --git a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/ContactRequestOutcomes.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/ContactRequestOutcomes.kt index 4e15286167f..6fb1e9b5261 100644 --- a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/ContactRequestOutcomes.kt +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/ContactRequestOutcomes.kt @@ -1,5 +1,6 @@ package org.dashfoundation.example.ui.dashpay +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.delay import org.dashfoundation.dashsdk.errors.DashSdkError import org.dashfoundation.dashsdk.tokens.Dashpay @@ -11,7 +12,11 @@ import org.dashfoundation.dashsdk.tokens.Dashpay * (Rust keeps its notes reserved), and so may a state transition the SDK could * not confirm after it may have been accepted (code 20), so the screen keeps * either pending and never offers a fresh submission; the next sync settles - * it. A pair Rust reports as + * it. For a public request the wallet holds the pair itself after a 20, the + * signed transition kept, until a public contact request sync settles it, + * and refuses every later request or accept to it with code 76: the screen + * shows "may have gone through; it is being checked" until the wallet's + * per-contact read says settled. A pair Rust reports as * held for a private request (code 71), which a public send or accept can * also meet, sent nothing new: the screen shows it as pending until the * wallet's per-contact read says the pair is free. Unrestored private @@ -31,6 +36,13 @@ sealed interface ContactRequestSubmissionOutcome { /** The wallet holds the pair for a private request; nothing was sent. */ data object PrivateRequestPending : ContactRequestSubmissionOutcome + /** + * The wallet holds the pair for a public request whose outcome is not + * proved yet (code 76); nothing was signed. A public contact request + * sync settles it. + */ + data object PublicRequestUnconfirmed : ContactRequestSubmissionOutcome + /** The wallet's private payments are not restored yet; nothing was sent. */ data object PrivatePaymentsNotRestored : ContactRequestSubmissionOutcome @@ -47,6 +59,13 @@ sealed interface ContactRequestSubmissionOutcome { const val MAY_HAVE_GONE_THROUGH_MESSAGE = "This may have gone through. It shows up " + "after the next sync confirms it; do not submit it again." + /** + * A public request or accept that may have gone through, as the + * wallet holds it until a sync settles its outcome. + */ + const val PUBLIC_REQUEST_UNCONFIRMED_MESSAGE = "This request may have gone through; " + + "it is being checked. The next sync settles it." + const val PRIVATE_REQUEST_PENDING_MESSAGE = "A private request to this contact is " + "pending. It clears once the request is read back from the private store, " + "or once its payment can no longer land, which can take a while. Sync " + @@ -100,6 +119,7 @@ sealed interface ContactRequestSubmissionOutcome { is DashSdkError.StateTransitionResultUnconfirmed, -> MayHaveGoneThrough is DashSdkError.PlatformWallet.PrivateContactRequestPending -> PrivateRequestPending + is DashSdkError.PlatformWallet.PublicContactRequestUnconfirmed -> PublicRequestUnconfirmed is DashSdkError.PlatformWallet.PrivateContactPaymentsNotRestored -> PrivatePaymentsNotRestored is DashSdkError.PlatformWallet.PrivateContactStoreNotRead -> PrivateStoreNotRead is DashSdkError.PlatformWallet.PrivateContactRecoveryUnsupported -> @@ -110,13 +130,15 @@ sealed interface ContactRequestSubmissionOutcome { /** * The message a request row shows: the restoring notice while the - * wallet's private payments are not restored, the pending notice - * while the wallet holds the pair, the unavailable notice while the - * wallet could not be read, and whatever error an earlier attempt - * left otherwise. + * wallet's private payments are not restored, the being-checked + * notice while a request to the contact awaits its outcome, the + * pending notice while the wallet holds the pair for a private + * request, the unavailable notice while the wallet could not be + * read, and whatever error an earlier attempt left otherwise. */ fun rowMessage( pending: Boolean, + awaitingOutcome: Boolean = false, restoring: Boolean = false, restoreFailure: String? = null, shieldedStoreFailure: String? = null, @@ -124,6 +146,7 @@ sealed interface ContactRequestSubmissionOutcome { error: String?, ): String? = when { restoring -> restoringMessage(restoreFailure, shieldedStoreFailure) + awaitingOutcome -> PUBLIC_REQUEST_UNCONFIRMED_MESSAGE pending -> PRIVATE_REQUEST_PENDING_MESSAGE unavailable -> PrivateRepliesPendingSnapshot.UNAVAILABLE_MESSAGE else -> error @@ -133,9 +156,11 @@ sealed interface ContactRequestSubmissionOutcome { /** * Which contacts (lower hex ids) the wallet holds a pair with for a private - * request, as the screens last read it (`privateRepliesPending`, one pair - * read per contact, which answers as a send decides it). While one is held, - * Rust refuses another send or reply to that contact, so the screens turn off + * request, and which for a public request awaiting its outcome, as the + * screens last read it ([contactRequestHolds]: per contact, the private pair + * read, which answers as a send decides it, and the public one). While either + * holds a pair, Rust refuses another send or reply to that contact, so the + * screens turn off * what would submit one (Accept, Ignore, Send). A failed read is not "nothing * pending": the last successful snapshot stays, and those controls stay off * for every contact until a read covering it succeeds. @@ -144,7 +169,12 @@ sealed interface ContactRequestSubmissionOutcome { * pending at once; the next successful read covering it decides, as it reads * what the refusal read. Controls for a pair re-enable only when that read * says it is free: a payment that will never land frees it only once the - * wallet proves so, which the read shows. + * wallet proves so, which the read shows. So does a send or accept that may + * have gone through (18 or 20), or was refused as awaiting a public request's + * outcome (76): the contact shows "being checked" at once, and a read decides + * it the same way, so a reload whose sync did not settle the wallet's hold + * keeps it so. A read always covers both holds: a read of the private hold + * alone would offer a public request awaiting its outcome again. * * Whether the wallet's private payments are restored is the wallet's * readiness (`Dashpay.privateContactReadiness`), not anything the screens @@ -155,7 +185,7 @@ sealed interface ContactRequestSubmissionOutcome { * ← Swift `PrivateRepliesPendingSnapshot` (`ContactRequestOutcomes.swift`). */ data class PrivateRepliesPendingSnapshot( - /** The held contacts the last successful read found. */ + /** The contacts the last successful read found held for a private request. */ val pending: Set = emptySet(), /** The contacts the last successful read covered. */ val covered: Set = emptySet(), @@ -163,6 +193,14 @@ data class PrivateRepliesPendingSnapshot( val available: Boolean = true, /** Contacts a send or accept was refused for as pending (code 71) since the last successful read covering them. */ val refused: Set = emptySet(), + /** The contacts the last successful read found held for a public request awaiting its outcome. */ + val publicUnconfirmed: Set = emptySet(), + /** + * Contacts a send or accept may have gone through to (18, 20), or was + * refused for as awaiting a public request's outcome (76), since the last + * successful read covering them. + */ + val awaitingOutcome: Set = emptySet(), /** The wallet's readiness for the identity, as last read; null until a read succeeds. */ val readiness: Dashpay.PrivateContactReadiness? = null, /** Why the last restoration failed, as last read with the readiness; null when none failed or it could not be read. */ @@ -174,15 +212,43 @@ data class PrivateRepliesPendingSnapshot( val isUnavailable: Boolean get() = !available /** - * This snapshot after a read of [contactIds]: [read] holds the held - * ones, or is null when the read failed. A successful read decides each - * contact it covered, a code 71 refusal included. + * This snapshot after a read of both holds for [contactIds] + * ([contactRequestHolds]), or after a failed one ([read] null). A + * successful read decides each contact it covered, a refusal or a + * submission that may have gone through included. */ - fun recorded(read: Set?, contactIds: Collection): PrivateRepliesPendingSnapshot = + fun recorded(read: ContactRequestHolds?, contactIds: Collection): PrivateRepliesPendingSnapshot = if (read == null) { copy(available = false) } else { - copy(pending = read, covered = contactIds.toSet(), refused = refused - contactIds.toSet(), available = true) + val decided = contactIds.toSet() + copy( + pending = read.privatelyHeld, + publicUnconfirmed = read.publicUnconfirmed, + covered = decided, + refused = refused - decided, + awaitingOutcome = awaitingOutcome - decided, + available = true, + ) + } + + /** + * This snapshot after a send or accept to [contactId] threw with + * [outcome]: a 71 refusal marks it pending and a 72 refusal holds every + * contact, as [withRefusal] and [withRestoringRefusal] do; one that may + * have gone through, or a 76 refusal, marks it awaiting its outcome. + * Nothing else changes a hold. + */ + fun withOutcome(outcome: ContactRequestSubmissionOutcome, contactId: String): PrivateRepliesPendingSnapshot = + when (outcome) { + ContactRequestSubmissionOutcome.PrivateRequestPending -> withRefusal(contactId) + ContactRequestSubmissionOutcome.MayHaveGoneThrough, + ContactRequestSubmissionOutcome.PublicRequestUnconfirmed, + -> copy(awaitingOutcome = awaitingOutcome + contactId) + ContactRequestSubmissionOutcome.PrivatePaymentsNotRestored -> withRestoringRefusal() + ContactRequestSubmissionOutcome.PrivateStoreNotRead, + is ContactRequestSubmissionOutcome.Failed, + -> this } /** This snapshot after a send or accept to [contactId] was refused as pending (code 71). */ @@ -225,12 +291,22 @@ data class PrivateRepliesPendingSnapshot( */ fun isPending(contactId: String): Boolean = contactId in pending || contactId in refused + /** + * Whether a request to [contactId] awaits its outcome: the last + * successful read found the pair held for a public request awaiting it, + * or a send or accept to the contact may have gone through, or was + * refused so (76), since. + */ + fun isAwaitingOutcome(contactId: String): Boolean = + contactId in publicUnconfirmed || contactId in awaitingOutcome + /** * Whether a send or reply to [contactId] may be offered: a successful - * read covered it and found the pair free. + * read covered it and found the pair free of both holds. */ fun allowsSubmission(contactId: String): Boolean = - available && contactId in covered && !isPending(contactId) && !isRestoringPrivatePayments + available && contactId in covered && !isPending(contactId) && + !isAwaitingOutcome(contactId) && !isRestoringPrivatePayments companion object { const val UNAVAILABLE_MESSAGE = "Could not read whether a private request to this " + @@ -238,6 +314,39 @@ data class PrivateRepliesPendingSnapshot( } } +/** + * One read of the wallet's two per-contact holds, as lower hex ids. + * ← Swift `ContactRequestHolds` (`ContactRequestOutcomes.swift`). + */ +data class ContactRequestHolds( + /** The contacts whose pair the wallet holds for a private request. */ + val privatelyHeld: Set = emptySet(), + /** The contacts whose pair the wallet holds for a public request awaiting its outcome. */ + val publicUnconfirmed: Set = emptySet(), +) + +/** + * Read the wallet's two holds: the pairs held for a private request + * ([readPrivatelyHeld]) and those held for a public request awaiting its + * outcome ([readPublicUnconfirmed]). Null when either read failed, which the + * screens keep apart from "nothing held" ([PrivateRepliesPendingSnapshot]); + * an identity the wallet does not manage reads as nothing held in both. + * ← Swift `contactRequestHolds(contactIds:privatePairHeld:publicUnconfirmed:)`. + */ +suspend fun contactRequestHolds( + readPrivatelyHeld: suspend () -> Set, + readPublicUnconfirmed: suspend () -> Set, +): ContactRequestHolds? = try { + ContactRequestHolds( + privatelyHeld = readPrivatelyHeld(), + publicUnconfirmed = readPublicUnconfirmed(), + ) +} catch (e: CancellationException) { + throw e +} catch (e: Exception) { + null +} + /** * The DashPay screens' readiness refresh, one flow for Requests and Add * Contact. The wallet owns the recovery: on a store already open, a repeat diff --git a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/ContactRequestsScreen.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/ContactRequestsScreen.kt index 82adfb72496..0e5ab5af494 100644 --- a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/ContactRequestsScreen.kt +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/ContactRequestsScreen.kt @@ -95,9 +95,10 @@ fun ContactRequestsScreen(identityIdHex: String, navController: NavHostControlle var rowErrors by remember { mutableStateOf(emptyMap()) } var isRefreshing by remember { mutableStateOf(false) } // Hex ids whose pair the wallet holds for a private request, as a send - // decides it (`privateRepliesPending`): the row stays pending, without its - // buttons, until the wallet's read says the pair is free. Read from the - // wallet, not kept by this screen, so leaving and reopening it keeps the + // decides it, or for a public request awaiting its outcome + // (`contactRequestHolds`): the row stays pending or "being checked", + // without its buttons, until the wallet's read says the pair is free. + // Read from the wallet, not kept by this screen, so leaving and reopening it keeps the // state. Re-read on composition and row changes, after an accept, on // refresh and after each sync pass. A failed read keeps the last snapshot // and leaves every row without its buttons until a read succeeds. @@ -177,7 +178,7 @@ fun ContactRequestsScreen(identityIdHex: String, navController: NavHostControlle configureAgain = { container.configureShieldedStoreAgain() }, ) shieldedStoreFailure = refreshed.storeFailure - val read = privateRepliesPending( + val read = contactRequestHolds( wallet = wallet, ourIdentityId = idBytes, contactIds = contactIds, @@ -234,24 +235,28 @@ fun ContactRequestsScreen(identityIdHex: String, navController: NavHostControlle rowErrors = rowErrors + (hex to "Request not in local state — pull to refresh") } } catch (e: Exception) { - when (val outcome = ContactRequestSubmissionOutcome.of(e)) { - ContactRequestSubmissionOutcome.MayHaveGoneThrough -> { + val outcome = ContactRequestSubmissionOutcome.of(e) + walletPending = walletPending.withOutcome(outcome, hex) + when (outcome) { + ContactRequestSubmissionOutcome.MayHaveGoneThrough, + ContactRequestSubmissionOutcome.PublicRequestUnconfirmed, + -> { // As the send screen treats an unconfirmed spend: not - // a failure to retry. The wallet keeps the reply - // pending, and the row reads that. + // a failure to retry. The wallet holds the pair (a + // private reply pending, or a public one awaiting its + // outcome, 76 when an earlier one still does), and the + // row reads that; the sync settles it. kickDashPaySync(scope, m) } ContactRequestSubmissionOutcome.PrivateRequestPending -> { // Nothing was sent: the wallet holds the pair. The // row stays pending until its per-contact read says // free. - walletPending = walletPending.withRefusal(hex) kickDashPaySync(scope, m) } ContactRequestSubmissionOutcome.PrivatePaymentsNotRestored -> { // The wallet is held until its private payments are // restored; every row waits for the readiness to say so. - walletPending = walletPending.withRestoringRefusal() } ContactRequestSubmissionOutcome.PrivateStoreNotRead -> { // Nothing was sent and nothing is pending: the user may @@ -304,6 +309,8 @@ fun ContactRequestsScreen(identityIdHex: String, navController: NavHostControlle scope.launch { isRefreshing = true manager?.let { m -> + // The DashPay sync settles a public request awaiting + // its outcome first, so the reload reads what it decided. attachOrStartSync(m) wallet?.let { privateRefreshNotice = syncPrivateContactRequests(it, m) } pendingReadVersion++ @@ -346,6 +353,7 @@ fun ContactRequestsScreen(identityIdHex: String, navController: NavHostControlle isInFlight = inFlightIds.contains(hex) || !walletPending.allowsSubmission(hex), errorMessage = ContactRequestSubmissionOutcome.rowMessage( pending = walletPending.isPending(hex), + awaitingOutcome = walletPending.isAwaitingOutcome(hex), restoring = walletPending.isRestoringPrivatePayments, restoreFailure = walletPending.restoreFailure, shieldedStoreFailure = shieldedStoreFailure, diff --git a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/DashPaySyncHelpers.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/DashPaySyncHelpers.kt index 9bec0c0f8bf..7ff68d6aec1 100644 --- a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/DashPaySyncHelpers.kt +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/DashPaySyncHelpers.kt @@ -38,31 +38,35 @@ fun kickDashPaySync(scope: CoroutineScope, manager: PlatformWalletManager) { } /** - * The hex ids of the contacts among [contactIds] whose pair with - * [ourIdentityId] the wallet holds for a private request, as a send decides - * it (`Dashpay.privateRepliesPending`, one pair read per contact): a private - * request in flight, paid and not yet read back, or kept by the shielded - * store's journal. While a pair is held, Rust refuses another send or reply - * to it, so the screens show it as pending and offer neither. Empty when the - * identity is not managed, as the wallet then holds nothing for it. Null when - * the wallet could not be read (no wallet, or a read failed), which the - * screens keep apart from "nothing pending" ([PrivateRepliesPendingSnapshot]). - * ← Swift `privateRepliesPending` (`ContactsView.swift`). + * The wallet's two holds on [ourIdentityId]'s pairs with [contactIds], as + * hex ids, one read of each per contact: held for a private request, as a + * send decides it (`Dashpay.privateRepliesPending`: a private request in + * flight, paid and not yet read back, or kept by the shielded store's + * journal), and held for a public request whose broadcast may have been + * delivered (`Dashpay.publicContactRequestsUnconfirmed`), which a DashPay + * sync settles. While either holds a pair, Rust refuses another send or + * reply to it, so the screens offer neither. Empty when the identity is not + * managed, as the wallet then holds nothing for it. Null when the wallet + * could not be read (no wallet, or a read failed), which the screens keep + * apart from "nothing held" ([PrivateRepliesPendingSnapshot]). + * ← Swift `contactRequestHolds(wallet:identityId:contactIds:)` (`ContactsView.swift`). */ -suspend fun privateRepliesPending( +suspend fun contactRequestHolds( wallet: ManagedPlatformWallet?, ourIdentityId: ByteArray, contactIds: List, -): Set? { - if (contactIds.isEmpty()) return emptySet() +): ContactRequestHolds? { + if (contactIds.isEmpty()) return ContactRequestHolds() if (wallet == null) return null - return try { - wallet.dashpay.privateRepliesPending(ourIdentityId, contactIds).map { it.toHex() }.toSet() - } catch (e: CancellationException) { - throw e - } catch (e: Exception) { - null - } + return contactRequestHolds( + readPrivatelyHeld = { + wallet.dashpay.privateRepliesPending(ourIdentityId, contactIds).map { it.toHex() }.toSet() + }, + readPublicUnconfirmed = { + wallet.dashpay.publicContactRequestsUnconfirmed(ourIdentityId, contactIds) + .map { it.toHex() }.toSet() + }, + ) } /** The wallet's readiness for an identity's contact requests and, when it flags a failed restoration, why. */ diff --git a/packages/kotlin-sdk/KotlinExampleApp/app/src/test/java/org/dashfoundation/example/ui/dashpay/ContactRequestOutcomesTest.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/test/java/org/dashfoundation/example/ui/dashpay/ContactRequestOutcomesTest.kt index eab86e6d5b7..93a4d04ab20 100644 --- a/packages/kotlin-sdk/KotlinExampleApp/app/src/test/java/org/dashfoundation/example/ui/dashpay/ContactRequestOutcomesTest.kt +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/test/java/org/dashfoundation/example/ui/dashpay/ContactRequestOutcomesTest.kt @@ -20,7 +20,10 @@ import org.junit.Test * an unsupported store (73) and a pinned SDK (75) are configuration messages, * an unfinished private store read (74) is a retry with no pending row, and a * network that does not serve the private store yet is quiet while real - * failures are shown. + * failures are shown. A public request or accept that may have gone through + * (20), or one the wallet refuses while an earlier one awaits its outcome + * (76), shows "being checked" with no submission offered until the wallet's + * read of the pair says settled. */ class ContactRequestOutcomesTest { @@ -33,11 +36,6 @@ class ContactRequestOutcomesTest { ) } - /** - * A pending private request (code 71), which a public send or accept can - * meet too, sent nothing: it is shown as pending, with a private sync to - * settle it, rather than as a submission that may have gone through. - */ /** * Code 20: a state transition the SDK could not confirm after it may have * been accepted, which a private send or accept can now return through @@ -64,6 +62,11 @@ class ContactRequestOutcomesTest { ) } + /** + * A pending private request (code 71), which a public send or accept can + * meet too, sent nothing: it is shown as pending, with a private sync to + * settle it, rather than as a submission that may have gone through. + */ @Test fun shouldKeepAPendingPrivateRequestPending() { val error = DashSdkError.PlatformWallet.PrivateContactRequestPending("not yet read back from the store") @@ -102,7 +105,7 @@ class ContactRequestOutcomesTest { @Test fun shouldKeepThePendingSnapshotAndOfferNothingAfterAFailedRead() { val contacts = listOf("01", "02") - var snapshot = PrivateRepliesPendingSnapshot().recorded(setOf("01"), contacts) + var snapshot = PrivateRepliesPendingSnapshot().recorded(privatelyHeld("01"), contacts) assertTrue(snapshot.isPending("01")) assertFalse(snapshot.allowsSubmission("01")) assertTrue(snapshot.allowsSubmission("02")) @@ -113,7 +116,7 @@ class ContactRequestOutcomesTest { assertFalse(snapshot.allowsSubmission("01")) assertFalse(snapshot.allowsSubmission("02")) - snapshot = snapshot.recorded(emptySet(), contacts) + snapshot = snapshot.recorded(privatelyHeld(), contacts) assertFalse(snapshot.isUnavailable) assertTrue(snapshot.allowsSubmission("01")) } @@ -127,7 +130,7 @@ class ContactRequestOutcomesTest { @Test fun shouldReEnableARefusedPairOnlyWhenThePerContactReadSaysFree() { var snapshot = PrivateRepliesPendingSnapshot() - .recorded(emptySet(), listOf("01")) + .recorded(privatelyHeld(), listOf("01")) .withRefusal("01") assertTrue(snapshot.isPending("01")) assertFalse(snapshot.allowsSubmission("01")) @@ -142,13 +145,13 @@ class ContactRequestOutcomesTest { assertFalse(snapshot.allowsSubmission("01")) // The pair's read says held: still pending. - snapshot = snapshot.recorded(setOf("01"), listOf("01")) + snapshot = snapshot.recorded(privatelyHeld("01"), listOf("01")) assertTrue(snapshot.isPending("01")) assertFalse(snapshot.allowsSubmission("01")) // A payment that will never land frees the pair once the wallet // proves so; the next read says free and the controls come back. - snapshot = snapshot.recorded(emptySet(), listOf("01")) + snapshot = snapshot.recorded(privatelyHeld(), listOf("01")) assertFalse(snapshot.isPending("01")) assertTrue(snapshot.allowsSubmission("01")) } @@ -158,7 +161,7 @@ class ContactRequestOutcomesTest { fun shouldKeepARefusalAReadDidNotCover() { val snapshot = PrivateRepliesPendingSnapshot() .withRefusal("01") - .recorded(emptySet(), listOf("02")) + .recorded(privatelyHeld(), listOf("02")) assertTrue(snapshot.isPending("01")) assertTrue(snapshot.allowsSubmission("02")) } @@ -176,7 +179,7 @@ class ContactRequestOutcomesTest { DashSdkError.PlatformWallet.PrivateContactStoreNotRead("not read to its end"), ), ) - val snapshot = PrivateRepliesPendingSnapshot().recorded(emptySet(), listOf("01")) + val snapshot = PrivateRepliesPendingSnapshot().recorded(privatelyHeld(), listOf("01")) assertFalse("the screen records no refusal for 74", snapshot.isPending("01")) assertTrue(snapshot.allowsSubmission("01")) assertTrue( @@ -217,7 +220,7 @@ class ContactRequestOutcomesTest { PrivateContactReadinessRead(readiness(restored = true, storeRead = true), null) } } - var snapshot = PrivateRepliesPendingSnapshot().recorded(emptySet(), listOf("01")) + var snapshot = PrivateRepliesPendingSnapshot().recorded(privatelyHeld(), listOf("01")) // The first refresh: the store opened, the restoration failed, and the // retry it asks for fails too. @@ -274,7 +277,7 @@ class ContactRequestOutcomesTest { assertEquals(true, refreshed.read?.readiness?.privatePaymentsRestored) val shown = PrivateRepliesPendingSnapshot() .withReadiness(readiness(restored = false, storeRead = true)) - .recorded(emptySet(), listOf("01")) + .recorded(privatelyHeld(), listOf("01")) .withReadiness(refreshed.read?.readiness, refreshed.read?.restoreFailure) assertTrue(shown.allowsSubmission("01")) } @@ -334,7 +337,7 @@ class ContactRequestOutcomesTest { val read: suspend () -> PrivateContactReadinessRead? = { PrivateContactReadinessRead(readiness(restored = deferredDone, storeRead = true), null) } - var snapshot = PrivateRepliesPendingSnapshot().recorded(emptySet(), listOf("01")) + var snapshot = PrivateRepliesPendingSnapshot().recorded(privatelyHeld(), listOf("01")) val pauses = mutableListOf() var refreshes = 0 // Well past the first minute: 200 automatic refreshes, about 46 minutes. @@ -424,7 +427,7 @@ class ContactRequestOutcomesTest { @Test fun shouldShowWhyARestorationFailedUntilTheReadinessSaysRestored() { var snapshot = PrivateRepliesPendingSnapshot() - .recorded(emptySet(), listOf("01")) + .recorded(privatelyHeld(), listOf("01")) .withReadiness( readiness(restored = false, storeRead = false, restoreFailed = true), "the journal does not read", @@ -467,7 +470,7 @@ class ContactRequestOutcomesTest { */ @Test fun shouldHoldOnCode72UntilTheReadinessSaysRestored() { - var snapshot = PrivateRepliesPendingSnapshot().withRestoringRefusal().recorded(emptySet(), listOf("01")) + var snapshot = PrivateRepliesPendingSnapshot().withRestoringRefusal().recorded(privatelyHeld(), listOf("01")) assertTrue(snapshot.isRestoringPrivatePayments) assertFalse(snapshot.allowsSubmission("01")) @@ -489,7 +492,7 @@ class ContactRequestOutcomesTest { @Test fun shouldShowRestoringFromTheReadinessAlone() { val snapshot = PrivateRepliesPendingSnapshot() - .recorded(emptySet(), listOf("01")) + .recorded(privatelyHeld(), listOf("01")) .withReadiness(readiness(restored = false, storeRead = false)) assertTrue(snapshot.isRestoringPrivatePayments) assertFalse(snapshot.allowsSubmission("01")) @@ -501,7 +504,7 @@ class ContactRequestOutcomesTest { */ @Test fun shouldOfferEveryContactASuccessfulEmptyReadCovered() { - val snapshot = PrivateRepliesPendingSnapshot().recorded(emptySet(), listOf("01")) + val snapshot = PrivateRepliesPendingSnapshot().recorded(privatelyHeld(), listOf("01")) assertFalse(snapshot.isUnavailable) assertFalse(snapshot.isPending("01")) assertTrue(snapshot.allowsSubmission("01")) @@ -545,4 +548,162 @@ class ContactRequestOutcomesTest { PrivateContactRefreshNotice.notice(unpersistedIdentityCount = 2), ) } + + /** + * Code 76: an earlier public request to the pair awaits its outcome, so + * the wallet signed nothing. Not a failure to retry: the contact shows + * "being checked" and no submission is offered. + */ + @Test + fun shouldShowARefusedUnconfirmedPublicRequestAsBeingChecked() { + val outcome = ContactRequestSubmissionOutcome.of( + DashSdkError.PlatformWallet.PublicContactRequestUnconfirmed("an earlier request awaits its outcome"), + ) + assertEquals(ContactRequestSubmissionOutcome.PublicRequestUnconfirmed, outcome) + val snapshot = PrivateRepliesPendingSnapshot().recorded(privatelyHeld(), listOf("01")) + .withOutcome(outcome, "01") + assertTrue(snapshot.isAwaitingOutcome("01")) + assertFalse("not a private request pending", snapshot.isPending("01")) + assertFalse(snapshot.allowsSubmission("01")) + assertEquals( + ContactRequestSubmissionOutcome.PUBLIC_REQUEST_UNCONFIRMED_MESSAGE, + rowMessage(snapshot, "01"), + ) + } + + /** + * Code 20 on a public accept, followed by an empty private-pending read + * and a public sync that did not settle it: an unsuccessful one (the read + * after it fails) or an incomplete one (the wallet's public hold still + * stands). The row stays held through both; a second accept is not + * offered, and one made anyway is refused (76) with no error to retry; + * once the sync settles the hold the read frees the pair and the accept + * comes back. + */ + @Test + fun shouldKeepAPublicAcceptHeldAfterCode20ThroughAnEmptyPrivateReadAndAnUnsettledSync() = runTest { + val wallet = FakeContactRequestHolds() + var snapshot = PrivateRepliesPendingSnapshot().recorded(wallet.read(), listOf("01")) + assertTrue("the accept is offered", snapshot.allowsSubmission("01")) + + // The accept broadcasts and its result cannot be confirmed: the + // wallet holds the pair, the signed transition kept. + wallet.publicUnconfirmed = setOf("01") + val accepted = ContactRequestSubmissionOutcome.of( + DashSdkError.PlatformWallet.TransactionBroadcastUnconfirmed("result could not be confirmed"), + ) + assertEquals(ContactRequestSubmissionOutcome.MayHaveGoneThrough, accepted) + snapshot = snapshot.withOutcome(accepted, "01") + assertFalse(snapshot.allowsSubmission("01")) + assertEquals(ContactRequestSubmissionOutcome.PUBLIC_REQUEST_UNCONFIRMED_MESSAGE, rowMessage(snapshot, "01")) + + // An unsuccessful sync: the read after it fails, and the row stays + // held as the last read left it. + wallet.failure = DashSdkError.PlatformWallet.WalletOperation("no node answered") + snapshot = snapshot.recorded(wallet.read(), listOf("01")) + assertTrue(snapshot.isUnavailable) + assertFalse(snapshot.allowsSubmission("01")) + assertEquals(ContactRequestSubmissionOutcome.PUBLIC_REQUEST_UNCONFIRMED_MESSAGE, rowMessage(snapshot, "01")) + + // An incomplete sync: the private-pending read is empty, the public + // hold stands, and the row stays held. + wallet.failure = null + val reload = wallet.read() + assertEquals("the private-pending read is empty", emptySet(), reload?.privatelyHeld) + snapshot = snapshot.recorded(reload, listOf("01")) + assertTrue(snapshot.isAwaitingOutcome("01")) + assertFalse("a second accept is not offered", snapshot.allowsSubmission("01")) + assertEquals(ContactRequestSubmissionOutcome.PUBLIC_REQUEST_UNCONFIRMED_MESSAGE, rowMessage(snapshot, "01")) + + // A second accept made anyway is refused before anything is signed. + val retried = ContactRequestSubmissionOutcome.of( + DashSdkError.PlatformWallet.PublicContactRequestUnconfirmed("an earlier request awaits its outcome"), + ) + snapshot = snapshot.withOutcome(retried, "01").recorded(wallet.read(), listOf("01")) + assertFalse(snapshot.allowsSubmission("01")) + assertEquals( + "a refusal while held is not an error to retry", + ContactRequestSubmissionOutcome.PUBLIC_REQUEST_UNCONFIRMED_MESSAGE, + rowMessage(snapshot, "01"), + ) + + // The sync settles the hold (a proved-used nonce frees the pair; a + // request found is recorded as sent and the row leaves the list): the + // next read says free and the accept comes back. + wallet.publicUnconfirmed = emptySet() + snapshot = snapshot.recorded(wallet.read(), listOf("01")) + assertFalse(snapshot.isAwaitingOutcome("01")) + assertTrue(snapshot.allowsSubmission("01")) + assertNull(rowMessage(snapshot, "01")) + } + + /** An unconfirmed submission marks only its own contact, and a read that does not cover it leaves the mark. */ + @Test + fun shouldKeepAnUnconfirmedSubmissionAReadDidNotCover() { + val snapshot = PrivateRepliesPendingSnapshot() + .withOutcome(ContactRequestSubmissionOutcome.MayHaveGoneThrough, "01") + .recorded(privatelyHeld(), listOf("02")) + assertTrue(snapshot.isAwaitingOutcome("01")) + assertTrue(snapshot.allowsSubmission("02")) + } + + /** + * The screens' read covers both holds: the private one and the public + * one. A failed read is null, never "nothing held"; a cancelled one is + * not swallowed. + */ + @Test + fun shouldReadBothHoldsAndTellAFailedReadFromNothingHeld() = runTest { + val wallet = FakeContactRequestHolds() + wallet.privatelyHeld = setOf("01") + wallet.publicUnconfirmed = setOf("02") + assertEquals(ContactRequestHolds(privatelyHeld = setOf("01"), publicUnconfirmed = setOf("02")), wallet.read()) + + wallet.failure = DashSdkError.PlatformWallet.WalletOperation("read failed") + assertNull(wallet.read()) + + wallet.failure = CancellationException("left the screen") + var cancelled = false + try { + wallet.read() + } catch (e: CancellationException) { + cancelled = true + } + assertTrue(cancelled) + } +} + +/** A read of both holds that found [contacts] held for a private request and none awaiting a public request's outcome. */ +private fun privatelyHeld(vararg contacts: String): ContactRequestHolds = + ContactRequestHolds(privatelyHeld = contacts.toSet()) + +/** The message the screens show for [contact], from [snapshot]. */ +private fun rowMessage(snapshot: PrivateRepliesPendingSnapshot, contact: String): String? = + ContactRequestSubmissionOutcome.rowMessage( + pending = snapshot.isPending(contact), + awaitingOutcome = snapshot.isAwaitingOutcome(contact), + restoring = snapshot.isRestoringPrivatePayments, + restoreFailure = snapshot.restoreFailure, + unavailable = snapshot.isUnavailable, + error = null, + ) + +/** The wallet's two per-contact holds, read through the screens' helper ([contactRequestHolds]) as the wallet answers them. */ +private class FakeContactRequestHolds { + var privatelyHeld: Set = emptySet() + var publicUnconfirmed: Set = emptySet() + + /** Thrown by every read when set. */ + var failure: Exception? = null + + suspend fun read(): ContactRequestHolds? = contactRequestHolds( + readPrivatelyHeld = { + failure?.let { throw it } + privatelyHeld + }, + readPublicUnconfirmed = { + failure?.let { throw it } + publicUnconfirmed + }, + ) } From f3878cace400fd9a35742fc9fc12dbb6a531f6c7 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Wed, 11 Nov 2026 00:12:33 +0700 Subject: [PATCH 269/269] docs(platform-wallet-ffi): the next allocatable result code is 77 Code 76 (`ErrorPublicContactRequestUnconfirmed`) has its row, but the summary line above the table still named 76 as the next free integer and 68 to 75 as the private contact request block. Co-Authored-By: Claude Fable 5.2 --- packages/rs-platform-wallet-ffi/ERROR_CODE_REGISTRY.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/rs-platform-wallet-ffi/ERROR_CODE_REGISTRY.md b/packages/rs-platform-wallet-ffi/ERROR_CODE_REGISTRY.md index 5aeb5333e2a..4e949c6e9fa 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: 76**: 27–75 are all claimed (27, 29, 31, 34–42 +**Next allocatable integer: 77**: 27–76 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 @@ -128,7 +128,7 @@ renumbered from 59 when #5322 took that number on `v5.1-dev`); 61 proposed by by open branches with no row here yet (62 both `ErrorShieldedClaimBindingMismatch` and `ErrorIdentityRevisionMismatch`, 63–66 the invitation-claim preflight, 67 `ErrorShieldedTreeInconsistent`); 68 -to 75 proposed for DashPay private contact requests; 28, 30, +to 76 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