From 5fa471483ac9605bb04524a73d6f2a11c586ca88 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Sun, 8 Nov 2026 10:35:59 +0700 Subject: [PATCH 01/51] 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 02/51] 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 03/51] 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 c3f1b0d71b9339d2ab5c31fb65edea0e464714b1 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Sun, 8 Nov 2026 17:10:24 +0700 Subject: [PATCH 04/51] 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 6db333ec6a479fa4352146555aca5c08df3a758d Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Sun, 8 Nov 2026 19:08:17 +0700 Subject: [PATCH 05/51] 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 06/51] 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 d4efa2e5db79f84e3c369d973988d0c17c6eaf71 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Sun, 8 Nov 2026 21:52:13 +0700 Subject: [PATCH 07/51] 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 473a4405f6968fe2c13da7975f27e6893a1a6a64 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 9 Nov 2026 00:27:34 +0700 Subject: [PATCH 08/51] 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 4a4826a85aca9b7dc1647a2262be82b3ce61d8a4 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 9 Nov 2026 01:08:49 +0700 Subject: [PATCH 09/51] 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 785e4939b1d8cfa5c67b755552a8246646246f1a Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 9 Nov 2026 02:07:13 +0700 Subject: [PATCH 10/51] 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 b3a0ad36b23ee2268f80b744ff4066e2a4607420 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 9 Nov 2026 02:45:25 +0700 Subject: [PATCH 11/51] fix(wasm-dpp2): key the constructor's tokens by position as text, and test a multibyte label The contract format reads token positions as text keys, but the constructor handed its tokens to the parser keyed by integer, so every DataContract built with tokens failed to deserialize. Positions now go in as strings. The bounds test also checks that the account label's bound is in bytes: 32 two-byte characters (64 bytes) are refused when sealing and when reading. Co-Authored-By: Claude Opus 5.5 --- .../src/platform/dashpay/private_contact_request.rs | 7 +++++++ packages/wasm-dpp2/src/data_contract/model.rs | 9 +++++++-- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/packages/rs-sdk/src/platform/dashpay/private_contact_request.rs b/packages/rs-sdk/src/platform/dashpay/private_contact_request.rs index f80d6bf7a27..6dcb1cc2cba 100644 --- a/packages/rs-sdk/src/platform/dashpay/private_contact_request.rs +++ b/packages/rs-sdk/src/platform/dashpay/private_contact_request.rs @@ -1796,6 +1796,13 @@ mod tests { ), ..smallest_request(1) }, + // The bound is in bytes: 32 two-byte characters are 64 bytes. + PrivateContactRequest { + account_label: Some( + "é".repeat(PRIVATE_CONTACT_REQUEST_ACCOUNT_LABEL_MAX_BYTES.div_ceil(2)), + ), + ..smallest_request(1) + }, PrivateContactRequest { auto_accept_proof: Some(vec![ 0; diff --git a/packages/wasm-dpp2/src/data_contract/model.rs b/packages/wasm-dpp2/src/data_contract/model.rs index 57892aba6ed..dfadad8c7fa 100644 --- a/packages/wasm-dpp2/src/data_contract/model.rs +++ b/packages/wasm-dpp2/src/data_contract/model.rs @@ -379,9 +379,14 @@ impl DataContractWasm { .map_err(|err| WasmDppError::serialization(err.to_string()))?; // A document type's token costs are checked against the contract's tokens when the - // contract is validated, so the tokens are part of what is parsed. + // contract is validated, so the tokens are part of what is parsed. The contract format + // reads token positions as text keys. if !tokens.is_empty() { - let tokens_value = dpp::platform_value::to_value(&tokens) + let tokens_by_position: BTreeMap = tokens + .iter() + .map(|(position, token)| (position.to_string(), token)) + .collect(); + let tokens_value = dpp::platform_value::to_value(&tokens_by_position) .map_err(|e| WasmDppError::serialization(e.to_string()))?; contract_value .set_value("tokens", tokens_value) From f5dde309c4bfcf51c501a08a656bbb6b75552d0b Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 9 Nov 2026 03:09:45 +0700 Subject: [PATCH 12/51] 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 13/51] 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 ab68754a1677436ea4b2cb2f72edec1a1a5382f9 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 9 Nov 2026 04:37:34 +0700 Subject: [PATCH 14/51] 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 15/51] 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 16/51] 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 17/51] 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 18/51] 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 b8e41ab0a84e990997f4cc339e59422c64895baa Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 9 Nov 2026 05:58:12 +0700 Subject: [PATCH 19/51] 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 d91c1c6df3922b48ce740cb2f21485512946cf25 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 9 Nov 2026 06:56:43 +0700 Subject: [PATCH 20/51] 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 21/51] 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 22/51] 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 23/51] 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 24/51] 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 25/51] 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 26/51] 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 27/51] 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 28/51] 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 29/51] 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 30/51] 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 e11ef7cbc2aca2abb47c38b2151b0b73cfa18c41 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 9 Nov 2026 08:32:43 +0700 Subject: [PATCH 31/51] 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 efa0fc8553e7bc177fefb1d4138ceecbc4653b76 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 9 Nov 2026 08:53:15 +0700 Subject: [PATCH 32/51] 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 33/51] 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 34/51] 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 35/51] fix(sdk): report an identity proved before its entry as a retryable stale node When both fetches proved an identity below the height of an entry it checks, and when `private_contact_requests_from_scan` was given one proved too early, the SDK returned `Error::Generic`, which `can_retry` takes as terminal, though fetching again later or from another node may succeed. Both now return `Error::StaleNode(StaleNodeError::Height)` (the entry's height, the proof's, tolerance 0). A missing identity, or one given under another id, stays a non-retryable caller error. Co-Authored-By: Claude Opus 5.5 --- .../dashpay/private_contact_request.rs | 108 ++++++++++++------ 1 file changed, 75 insertions(+), 33 deletions(-) diff --git a/packages/rs-sdk/src/platform/dashpay/private_contact_request.rs b/packages/rs-sdk/src/platform/dashpay/private_contact_request.rs index 57bf53125c2..3a8c5ea7f91 100644 --- a/packages/rs-sdk/src/platform/dashpay/private_contact_request.rs +++ b/packages/rs-sdk/src/platform/dashpay/private_contact_request.rs @@ -23,6 +23,7 @@ //! the requests it sent from the store alone. use super::contact_request::{recipient_key_purpose_is_valid, sender_key_purpose_is_valid}; +use crate::error::StaleNodeError; use crate::platform::private_document_scan::{ PrivateDocumentScan, PrivateDocumentScanKeys, ReceivedPrivateDocumentEntry, SentPrivateDocumentEntry, @@ -865,12 +866,25 @@ pub struct ProvedIdentity { pub proved_at_height: u64, } +/// The error for an identity proved at `received_height`, before the block `expected_height` of +/// an entry it checks: the node's snapshot is older than the entry, so fetching it again, from +/// another node or later, may succeed ([`StaleNodeError::Height`], which `can_retry` takes as +/// retryable). +fn stale_identity(expected_height: u64, received_height: u64) -> Error { + Error::StaleNode(StaleNodeError::Height { + expected_height, + received_height, + tolerance_blocks: 0, + }) +} + /// How many identities [`Sdk::fetch_private_contact_request_identities`] fetches at once. const PRIVATE_CONTACT_REQUEST_IDENTITY_FETCHES: usize = 4; /// [`Sdk::fetch_private_contact_request_identities`] with `fetch` proving one identity: each /// identity `needed` lists is fetched, a few at a time, until proved at or after its height, -/// twice at most. A stale second proof or a failed fetch fails the whole fetch. +/// twice at most. A stale second proof fails the whole fetch with the retryable +/// `Error::StaleNode(StaleNodeError::Height)`, and a failed fetch with its own error. async fn fetch_proved_identities( needed: &BTreeMap, fetch: F, @@ -893,11 +907,13 @@ where proved = fetch(identity_id).await?; } if proved.proved_at_height < height { - return Err(Error::Generic(format!( - "the identity {identity_id} was proved at block {}, before the block \ - {height} of an entry it checks", - proved.proved_at_height - ))); + tracing::debug!( + identity = %identity_id, + proved_at = proved.proved_at_height, + needed = height, + "an identity was proved twice before the block of an entry it checks" + ); + return Err(stale_identity(height, proved.proved_at_height)); } Ok((identity_id, proved)) }) @@ -949,8 +965,9 @@ pub fn private_contact_request_identities(scan: &PrivateDocumentScan) -> BTreeMa /// Reads the private contact requests of a scan of DashPay's `privateContactRequest` store. /// /// `identities` holds the identities [`private_contact_request_identities`] lists, each proved -/// at or after the height it lists: a missing one, or one proved earlier, fails the read, to be -/// fetched again. Each entry, received or sent, is checked before its payload is read: +/// at or after the height it lists. One proved earlier fails the read with the retryable +/// `Error::StaleNode(StaleNodeError::Height)`, to be fetched again; a missing one, or one given +/// under another id, is the caller's error, which fetching again does not lift. Each entry, received or sent, is checked before its payload is read: /// - it must be signed, and its sender identity must exist; /// - the encryption key it names must be the sender's `ECDSA_SECP256K1` `ENCRYPTION` key, not /// disabled when the entry was appended, and bound, if at all, to DashPay or its contact @@ -1036,11 +1053,13 @@ pub fn private_contact_requests_from_scan( ))) } Some(proved) if proved.proved_at_height < height => { - return Err(Error::Generic(format!( - "the identity {identity_id} was proved at block {}, before the block {height} \ - of an entry it checks: fetch it again", - proved.proved_at_height - ))) + tracing::debug!( + identity = %identity_id, + proved_at = proved.proved_at_height, + needed = height, + "an identity was proved before the block of an entry it checks" + ); + return Err(stale_identity(height, proved.proved_at_height)); } Some(_) => {} } @@ -1433,6 +1452,7 @@ mod tests { use dpp::system_data_contracts::{load_system_data_contract, SystemDataContract}; use dpp::ProtocolError; use platform_encryption::sign_private_document_signing_data; + use rs_dapi_client::CanRetry; const SENDER_ID: [u8; 32] = [0xA0; 32]; const RECIPIENT_ID: [u8; 32] = [0xB0; 32]; @@ -3005,28 +3025,35 @@ mod tests { PlatformVersion::latest() ) .is_ok()); + let stale = private_contact_requests_from_scan( + &contract, + scan.clone(), + &proved(9), + PlatformVersion::latest(), + ) + .expect_err("a sender proved a block before its entry"); assert!( - private_contact_requests_from_scan( - &contract, - scan.clone(), - &proved(9), - PlatformVersion::latest() - ) - .is_err(), - "a sender proved a block before its entry" + matches!( + stale, + Error::StaleNode(StaleNodeError::Height { + expected_height: 10, + received_height: 9, + .. + }) + ), + "{stale:?}" ); + assert!(stale.can_retry(), "fetching it again may help"); let mut without_recipient = proved(10); without_recipient.remove(&Identifier::from(RECIPIENT_ID)); - assert!( - private_contact_requests_from_scan( - &contract, - scan, - &without_recipient, - PlatformVersion::latest() - ) - .is_err(), - "an identity not given" - ); + let missing = private_contact_requests_from_scan( + &contract, + scan, + &without_recipient, + PlatformVersion::latest(), + ) + .expect_err("an identity not given"); + assert!(!missing.can_retry(), "the caller's mistake: {missing:?}"); } /// Below protocol version 15 the SDK refuses private contact requests before resolving @@ -3251,7 +3278,7 @@ mod tests { proved_at_height: 10, }) ); - assert!(fetch_proved_identities( + let twice_stale = fetch_proved_identities( &needed, scripted(vec![ proved(Some(sender_identity(None)), 9), @@ -3259,7 +3286,22 @@ mod tests { ]), ) .await - .is_err()); + .expect_err("two stale proofs"); + assert!( + matches!( + twice_stale, + Error::StaleNode(StaleNodeError::Height { + expected_height: 10, + received_height: 9, + tolerance_blocks: 0, + }) + ), + "{twice_stale:?}" + ); + assert!( + twice_stale.can_retry(), + "a later fetch may be recent enough" + ); assert!(fetch_proved_identities( &needed, scripted(vec![Err(Error::Generic("unreachable".to_string()))]), From b065ca3853256cd94bf039a342f90948ff2df71a Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 9 Nov 2026 10:08:54 +0700 Subject: [PATCH 36/51] 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 37/51] 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 38/51] 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 4386ec09af79fcff89c0cc94fffbb420058587f1 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 9 Nov 2026 11:39:27 +0700 Subject: [PATCH 39/51] docs(sdk): say that a private request's key expiry is the identity's current limit The reader checks a signing key's expiry against the entry's block time using the expiry the identity given carries now, not the one the key had when the entry was appended. An identity may extend a key's expiry, reviving an expired key, so an entry signed after the key's first expiry is refused against an identity read before the extension and read against one read after it. The read's documentation now says so, beside its note on historical key membership, and a test pins the expired, extended, read-again sequence. Co-Authored-By: Claude Opus 5.5 --- .../dashpay/private_contact_request.rs | 100 +++++++++++++----- 1 file changed, 74 insertions(+), 26 deletions(-) diff --git a/packages/rs-sdk/src/platform/dashpay/private_contact_request.rs b/packages/rs-sdk/src/platform/dashpay/private_contact_request.rs index d805765faab..482136722d4 100644 --- a/packages/rs-sdk/src/platform/dashpay/private_contact_request.rs +++ b/packages/rs-sdk/src/platform/dashpay/private_contact_request.rs @@ -627,7 +627,9 @@ fn bounds_problem( /// `AUTHENTICATION` key at one of `levels`, of a signing key type, bound to no other contract or /// document type, enabled then and not expired then (the expiry instant itself is expired, as /// dpp's `is_expired_at` says). The reader checks it at the entry's block time, which decides; -/// the seal and the key list check it now, so a key about to be refused is not offered. +/// the seal and the key list check it now, so a key about to be refused is not offered. The +/// expiry is `key`'s as given: its identity's current limit, which an extension since the entry +/// may have moved past it ([`private_contact_requests_from_scan`]). fn signing_key_problem( key: &IdentityPublicKey, levels: &[SecurityLevel], @@ -984,6 +986,12 @@ pub fn private_contact_request_identities(scan: &PrivateDocumentScan) -> BTreeMa /// when a key was disabled, not when it was added. Adding a key takes the identity's master key, /// so a key added later still speaks for the identity's owner. /// +/// Likewise, a key's expiry is the limit the identity given carries now, compared with the +/// entry's block time, not the limit the key had when the entry was appended. An identity may +/// extend a key's expiry, which revives an expired key: an entry its key signed after the key's +/// first expiry is refused against an identity read before the extension, and read against one +/// read after it, once the new expiry is past the entry's block time. +/// /// The first request from a sender to a recipient for an account reference is kept. A wallet /// keeps the requests it already holds from earlier scans, and drops a later one from the same /// sender for the same account reference as this does. @@ -3201,35 +3209,37 @@ mod tests { ); } + /// The sender, its signing key expiring at `expires_at`. + fn sender_signing_until(expires_at: u64) -> Identity { + with_key( + sender_identity(None), + IdentityPublicKey::V1( + dpp::identity::identity_public_key::v1::IdentityPublicKeyV1 { + id: 0, + purpose: Purpose::AUTHENTICATION, + security_level: SecurityLevel::HIGH, + contract_bounds: None, + key_type: KeyType::ECDSA_SECP256K1, + read_only: false, + data: BinaryData::new( + PublicKey::from_secret_key(&secret_key(9)) + .serialize() + .to_vec(), + ), + disabled_at: None, + total_budget: None, + expires_at: Some(expires_at), + }, + ), + ) + } + /// A signing key with an expiry signs entries appended before it, not at or after it, and /// is not offered once expired. #[test] fn should_refuse_a_signing_key_expired_when_the_entry_was_appended() { let contract = dashpay(); let entry = seal(&contract, &sender_identity(None), &smallest_request(1), 1); - let expiring = |expires_at| { - with_key( - sender_identity(None), - IdentityPublicKey::V1( - dpp::identity::identity_public_key::v1::IdentityPublicKeyV1 { - id: 0, - purpose: Purpose::AUTHENTICATION, - security_level: SecurityLevel::HIGH, - contract_bounds: None, - key_type: KeyType::ECDSA_SECP256K1, - read_only: false, - data: BinaryData::new( - PublicKey::from_secret_key(&secret_key(9)) - .serialize() - .to_vec(), - ), - disabled_at: None, - total_budget: None, - expires_at: Some(expires_at), - }, - ), - ) - }; for (case, expires_at, accepted) in [ ("after the entry", BLOCK_TIME_MS + 1, true), ("at the entry", BLOCK_TIME_MS, false), @@ -3239,7 +3249,7 @@ mod tests { &contract, vec![entry.clone()], &recipient_keys(), - &[expiring(expires_at), recipient_identity()], + &[sender_signing_until(expires_at), recipient_identity()], )); if accepted { assert_eq!(reasons, vec![], "{case}"); @@ -3254,13 +3264,51 @@ mod tests { } } assert!( - private_contact_request_signing_keys(&contract, &expiring(1)) + private_contact_request_signing_keys(&contract, &sender_signing_until(1)) .expect("DashPay v3") .is_empty(), "an expired key is not offered" ); } + /// The expiry checked is the sender's key's limit as the identity given carries it, not the + /// one it had when the entry was appended: an entry the key signed after it expired is + /// refused against the identity as it was then, and read once the identity has extended + /// the key's expiry past the entry and is read again. + #[test] + fn should_read_an_entry_signed_after_an_expiry_once_the_expiry_is_extended_past_it() { + let contract = dashpay(); + let entry = seal(&contract, &sender_identity(None), &smallest_request(1), 1); + + let expired = reasons(read( + &contract, + vec![entry.clone()], + &recipient_keys(), + &[ + sender_signing_until(BLOCK_TIME_MS - 1), + recipient_identity(), + ], + )); + assert!( + matches!( + expired.as_slice(), + [PrivateContactRequestRejection::SenderKeyNotUsable(_)] + ), + "{expired:?}" + ); + + let extended = reasons(read( + &contract, + vec![entry], + &recipient_keys(), + &[ + sender_signing_until(BLOCK_TIME_MS + 1), + recipient_identity(), + ], + )); + assert_eq!(extended, vec![], "read with the extended expiry"); + } + /// The recipient key an entry names must be the one it is encrypted to, whether it is a /// key sealing could not encrypt to or an eligible one holding another point, and an /// identity must be given under its own id. From f9d669fa23c6669932d6e78542d29f630f897d60 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 9 Nov 2026 12:35:15 +0700 Subject: [PATCH 40/51] 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 41/51] 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 42/51] 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 43/51] 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 ecf621d5c4f2f0cfe21ae01d3f7ec1f9274faa75 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 9 Nov 2026 13:29:13 +0700 Subject: [PATCH 44/51] 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 45/51] 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 46/51] 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 47/51] 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 84c2b96abc5728ae42deeb7e8a76c252fefb30bd Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 9 Nov 2026 16:12:53 +0700 Subject: [PATCH 48/51] 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 8e80360366ef794aab8bf22c3b43fe57dc2d8e4c Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 9 Nov 2026 16:44:40 +0700 Subject: [PATCH 49/51] test(drive): pin the protocol 15 root after DashPay's insert at its store's new chunk power DashPay v3's privateContactRequest store now takes chunk power 10 under the 1 MiB chunk target (84c2b96abc), and the store element carries its chunk power, so the root after inserting the contract at protocol version 15 moves. Only the latest-version pin changes. Co-Authored-By: Claude Opus 5.5 --- packages/rs-drive/tests/deterministic_root_hash.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/rs-drive/tests/deterministic_root_hash.rs b/packages/rs-drive/tests/deterministic_root_hash.rs index 41b041b42f6..08786ef3e2f 100644 --- a/packages/rs-drive/tests/deterministic_root_hash.rs +++ b/packages/rs-drive/tests/deterministic_root_hash.rs @@ -314,8 +314,9 @@ mod tests { // From protocol version 15 the contract is stored compiled (format version 2), its // document types beside its schemas, and is DashPay v3, which declares the checks of // its data triggers and adds the private `privateContactRequest` type, whose store - // the insert creates. - _ => "66306426bce1beaa7319e7e6d822bcf8a7c93f14425c2f82841a3222a7502912", + // the insert creates, at chunk power 10 (605-byte stored entries under the 1 MiB + // chunk target of `SYSTEM_LIMITS_V5`). + _ => "adb6a2e6be5a67bb2730c27ca9a63cc4cacf485b634ec4a0ad998b0da508a7e2", }; assert_eq!( From cfc4dd99d6f27add6e2d14a9aec5ca626d3e300a Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 9 Nov 2026 17:05:47 +0700 Subject: [PATCH 50/51] test(drive-abci): re-pin the nullifier batch guard's latest-version roots after the chunk target change The settled characterization hashes and the single-entry root cover GroveDB roots at PlatformVersion::latest(), whose genesis inserts DashPay v3 and its privateContactRequest store. That store element carries its chunk power, now 10 under the 1 MiB chunk target (84c2b96abc), so the roots move. The shielded test phase passed with the old pins at 50658b51f3, and the only change since that reaches drive-abci is the limit. All nine validation hashes, which do not depend on roots, are unchanged; the nine settled hashes and the single-entry root are re-pinned from one run with the settled check skipped, then checked with it restored. Co-Authored-By: Claude Opus 5.5 --- .../token/shielded/nullifier_batch_guard.rs | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/token/shielded/nullifier_batch_guard.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/token/shielded/nullifier_batch_guard.rs index 55f724b2fd7..87c5b947c73 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/token/shielded/nullifier_batch_guard.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/tests/token/shielded/nullifier_batch_guard.rs @@ -734,7 +734,7 @@ async fn should_preserve_single_entry_full_processor_fees_and_result() { .value .unwrap() ), - "c08eb5f5971bb07143a63bc92d6fad5c593e77623ea7a7f178c857eb1ae48cea" + "b9f5883991476eb6e01109ff5bf1057eab24b756170397d4d850e3e6e2c09a5b" ); assert_eq!( pool.platform @@ -2185,39 +2185,39 @@ async fn should_preserve_single_entry_actions_errors_operations_and_settled_stat let (expected_validation, expected_settled) = match case { "shield" => ( "6a823c2db94612652926e6b9c1ae7feec9c2547dfd4cfc4e474318da1c16d59c", - "c9577d238a283729bfce503563e22542356460aef2d4680eddf8c91a6981189b", + "7eea5d32409d3df173a4ec4f5e9b56d40d2cacfcea8cac49d05e170976a893df", ), "valid-payment" => ( "9388d5cf63c4a571b9fb009679e2e34d0b681acc88f0f5195e7287c7efbd92f7", - "22fc25da7ded9ec8ef0c63b691f3c5411c063072458379ff0bc449f4c61f4e33", + "2a7e8a57360fad775ab0411ed5f4e31db9bd3ac5948e3a33a21f11ee5fa2d93c", ), "failed-payment" => ( "d9b5fe191841bea952494f2a45e3833f925f4d367a1937b2ef1235a1aa01f51e", - "e106a9e7ec95fa9ae56956cb8d2eb55f92b2003c63e52b30b0e2a4015aa4ffea", + "00315213aa7c931f2304191465a0319c9dbcc280269216e63a3e0e60ca16b178", ), "within-bundle-repeat" => ( "8023e03a697a20f6a78396a05503a4bc583ca8f69e19bd7ee07a6ae0ce7be478", - "e5168d60f643ae3a98d325fbe4f61b179d85fc281e27784456d05fd2de17bc0b", + "9402a3dfd81a098f9575011fc0f6e5cd2415f0527ea9c4f45a04daaae2ab6ce9", ), "stored-repeat" => ( "05652ac85c972902e8c09d20a7d8586e17e9434a478b335da136ad85f73def01", - "52f715a3ee906f0e79f474734f1441e61d8a68d4a293b7ea80196bc27c16416a", + "68ecfa75165f58edcf7262697fddad9378492e71eb1b340c9683b6a08131bfb6", ), "open-mint-group" => ( "5c68381297c9bf68c5e85ebe6bb9e0f1289e2c07718eecfb5e6773d6009455bb", - "9b8a20a60c362b7cc16224aea3f79ff699c53643b2ecedbd10bec753edac0e5b", + "5c5eb01b9f9fa0ceaa458746fdbb705e0a4aa573c8efeb0bc9f2f4d7c499e95f", ), "closing-mint-group" => ( "2cedc16732c76e0605b5f46085db3d69c12936a5af55a2a82f52ec9e9ca67e38", - "d104393779df056d6444dfa4a69aaf4582913b6f00b61f2f53e062fdef250d47", + "a09c353badac216e6897ae44bfa6d20038ba2b9ac8b607dcfb5824da4e9e1a3d", ), "open-burn-group" => ( "e664a045f020ff84de1ca98f364fbabccfc141ad46aa82c79491a340bb0db5f8", - "224d1a01832e9faf0f4c9d8af8f8c318855f74f5549976d1b1d1e48896d0ba93", + "de8b6eab60c7a94da3470facf22c1225e09e58f3779f0982a87a36b6dffec48c", ), "closing-burn-group" => ( "93e1732286f168c4a91d2765931e181c5721bfca1bfd5f93b8baacca03e00b2b", - "5d8c544ca25dc3d9850bae5b5a2eaa17fd8ac36a486bdd48010c6f2e3c929d59", + "bcd073ee886c1e0f07f316680756ee07ffd2961a0960090f685f25a8278c65ef", ), _ => unreachable!("enumerated characterization case"), }; From f597cd84447640404405a930bf0adcf0450eaa91 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 9 Nov 2026 17:05:47 +0700 Subject: [PATCH 51/51] fix(sdk): answer from the cache when a mock provider's contract fetch finds none GrpcContextProvider::get_data_contract fetches a contract it does not hold. When the fetch found none it returned None, even if another operation had registered the contract while the fetch ran, so a page verification could fail on a contract the cache held. It now reads the cache again on that path, as the found path already keeps a newer registered contract. Co-Authored-By: Claude Opus 5.5 --- packages/rs-sdk/src/mock/provider.rs | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/packages/rs-sdk/src/mock/provider.rs b/packages/rs-sdk/src/mock/provider.rs index 8c790972673..567869deb45 100644 --- a/packages/rs-sdk/src/mock/provider.rs +++ b/packages/rs-sdk/src/mock/provider.rs @@ -218,14 +218,20 @@ impl ContextProvider for GrpcContextProvider { self.dump_data_contract(dc); } - // 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 - }) - })) + // A newer contract registered while this one was fetched stays, and is the answer. So + // is one registered while a fetch that found none ran. + Ok(match data_contract { + Some(dc) => { + let fetched_version = dc.version(); + Some( + self.data_contracts_cache + .put_unless(*data_contract_id, dc, |held| { + held.version() > fetched_version + }), + ) + } + None => self.data_contracts_cache.get(data_contract_id), + }) } /// Keeps a held contract of a higher version, deciding under the cache's lock: a