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-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-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..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 @@ -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,12 @@ 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() - }) + // 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()) } 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-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"), }; 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-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/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-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 00000000000..b84f9079d7f Binary files /dev/null and b/packages/rs-drive-proof-verifier/tests/vectors/protocol-gate/epoch-probe-pv15.bin differ 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/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..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 @@ -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 @@ -454,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:?}" ); } @@ -688,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); @@ -719,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, @@ -730,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() } @@ -774,32 +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: 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); } } @@ -809,41 +825,48 @@ 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::default(), "from {start}"); + assert_eq!( + page, + PrivateDocumentEntries { + chunk_power: Some(7), + ..Default::default() + }, + "from {start}" + ); } } /// 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), @@ -992,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-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-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!( 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 7ecfb08d198..2d4ed1cc155 100644 --- a/packages/rs-platform-encryption/src/private_document_envelope.rs +++ b/packages/rs-platform-encryption/src/private_document_envelope.rs @@ -39,17 +39,29 @@ //! //! ```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 //! 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}; @@ -60,7 +72,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 }, @@ -91,6 +103,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 +167,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,10 +209,13 @@ 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 { + contract_id: [u8; 32], + document_type_name: String, commitment: [u8; 32], recipient_key_index: Option, recipient_public_key: PublicKey, @@ -206,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 @@ -277,15 +311,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 +319,80 @@ 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, `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: &PrivateDocumentRecipient, + payload: &[u8], +) -> [u8; 32] { + auth_message( + target, + PrivateDocumentAuthMode::Signature, + sender_identity_id, + sender_key_id, + &recipient.identity_id, + &recipient.public_key.serialize(), + 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 @@ -358,6 +458,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]; @@ -370,14 +471,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); } } @@ -476,6 +574,31 @@ 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)) + } +} + +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 @@ -607,9 +730,12 @@ fn parse_body( &sender_identity_id, sender_key_id, &recipient_identity_id, + &recipient_public_key.serialize(), payload, ); Ok(OpenedPrivateDocumentEntry { + contract_id: target.contract_id, + document_type_name: target.document_type_name.to_string(), commitment, recipient_key_index, recipient_public_key, @@ -687,6 +813,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(&[ @@ -698,6 +825,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, ]) @@ -768,6 +896,23 @@ 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, + &PrivateDocumentRecipient { + identity_id: RECIPIENT_IDENTITY, + public_key: &public_key(2), + }, + &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( @@ -836,12 +981,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, ); @@ -866,6 +1011,109 @@ 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))); + // 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!( + 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() { @@ -904,6 +1152,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 @@ -913,6 +1168,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() { @@ -1093,13 +1384,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 @@ -1117,8 +1409,8 @@ mod tests { pinned, [ "abb644128f3e73aade8b6210573fba942709e7e44eaeffef92dc02d5187f37d3", - "bedf5084aca776ae520e1395e4a4f92628f4589f04b244630acc890649f74a82", - "a1054055987e953aeeb76deb9998db79eebe3e798a7296eadeb90575bc6e0e56", + "7250a6613ff5aa57f28cc57bd3cdd37824e4f2d3ae634e623e53d032387ce261", + "05f544901039b5b112a0c55863db0bfd37951156f0639e94d65eb209da106502", ] ); } 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 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 00a71e108a4..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)); } } @@ -1028,10 +1036,11 @@ 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(); - known.insert(id, contract); + hold_unless_newer_held(&mut known, contract); } fn get_token_configuration( @@ -1060,7 +1069,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 +2261,95 @@ 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"); + } + + /// 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 278eb5a11ad..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 { @@ -669,8 +681,10 @@ 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); + hold_unless_newer_held(&mut lock(&self.inner.known_contracts), contract); } fn get_token_configuration( @@ -1070,6 +1084,62 @@ 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"); + } + + /// 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/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/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/mock/provider.rs b/packages/rs-sdk/src/mock/provider.rs index 68cbf1a26a9..567869deb45 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,36 @@ 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. 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 + /// 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 +291,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/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.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..4c663a40376 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,18 @@ 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_identities, private_contact_request_outgoing_viewing_key, + private_contact_request_signature, private_contact_request_signing_data, + private_contact_request_signing_keys, private_contact_request_signing_security_levels, + private_contact_requests_from_scan, seal_private_contact_request, PrivateContactRequest, + PrivateContactRequestRejection, PrivateContactRequestScan, PrivateContactRequestSender, + 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, +}; 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..09e5feb9ca9 --- /dev/null +++ b/packages/rs-sdk/src/platform/dashpay/private_contact_request.rs @@ -0,0 +1,3958 @@ +//! 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, 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: [`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 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 +//! 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_entries::private_document_entries_requirement; +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::{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; +use dpp::identity::identity_public_key::accessors::v1::IdentityPublicKeyGettersV1; +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}; +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 futures::{StreamExt, TryStreamExt}; +use platform_encryption::{ + 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 std::sync::Arc; +use zeroize::{Zeroize, Zeroizing}; + +/// 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. 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(), + 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())); + } + properties + } + + /// The request a private contact request's properties hold, refused when one is missing, + /// 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}")); + // 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) + .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()?, + }; + Ok(request) + } +} + +/// 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()), + } +} + +/// 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 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 +/// ([`SecurityLevel::document_signing_levels`] of the type's requirement), from `CRITICAL` to +/// 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> { + 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 +/// 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, + platform_version: &PlatformVersion, +) -> Result<[u8; 32], Error> { + Ok(SealInputs::new( + dashpay_contract, + sender, + encryption_key_id, + recipient, + recipient_key_id, + request, + platform_version, + )? + .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, which the pair's shared key is + /// derived from. + pub encryption_key_id: KeyID, + /// 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 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. +/// +/// - 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( + dashpay_contract: &DataContract, + sender: &PrivateContactRequestSender, + recipient: &Identity, + recipient_key_id: KeyID, + request: &PrivateContactRequest, + esk_seed: &[u8; 32], + platform_version: &PlatformVersion, +) -> Result { + let inputs = SealInputs::new( + dashpay_contract, + sender.identity, + sender.encryption_key_id, + 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!( + "the secret key given is not the one of the sender key {}", + sender.encryption_key_id + ))); + } + let signing_key = sender + .identity + .get_public_key_by_id(sender.signing_key_id) + .ok_or_else(|| { + Error::Generic(format!( + "the sender identity {} has no key {}", + sender.identity.id(), + sender.signing_key_id + )) + })?; + 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)); + } + 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 signature is not the sender key {}'s signature of this request", + sender.signing_key_id + ))); + } + + // 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, + &PrivateDocumentRecipient { + identity_id: recipient.id().to_buffer(), + public_key: &inputs.recipient_public_key, + }, + &PrivateDocumentSender::Signature { + identity_id: sender.identity.id().to_buffer(), + key_id: sender.encryption_key_id, + signature: sender.signature, + }, + &inputs.payload, + &outgoing_viewing_key, + esk_seed, + ) + .map_err(|error| Error::Generic(format!("sealing a private contact request: {error}")))?; + Ok(BinaryData::new(entry)) +} + +/// 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, + platform_version: &PlatformVersion, + ) -> 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, dashpay_contract.id(), None) + .map_err(Error::Generic)?; + + let recipient_public_key = + recipient_key(recipient, recipient_key_id, dashpay_contract.id(), None) + .map_err(Error::Generic)?; + + 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 { + contract_id, + document_type_name: PRIVATE_CONTACT_REQUEST_DOCUMENT_TYPE_NAME, + }, + &sender.id().to_buffer(), + encryption_key_id, + &PrivateDocumentRecipient { + identity_id: recipient.id().to_buffer(), + public_key: &recipient_public_key, + }, + &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, + dashpay_id: Identifier, + 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", 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, +/// never removed. +fn ecdh_public_key( + key: &IdentityPublicKey, + role: &str, + dashpay_id: Identifier, + at_ms: Option, +) -> Result { + if key.key_type() != KeyType::ECDSA_SECP256K1 { + return Err(format!( + "the {role} key {} is a {} key, not ECDSA_SECP256K1", + key.id(), + key.key_type() + )); + } + 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| { + 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, + } +} + +/// 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 +/// 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 +/// 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], + dashpay_id: Identifier, + 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() + )); + } + // 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. + 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) { + 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, 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, +) -> 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() { + 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, + } +} + +/// 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()) +} + +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 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], + /// 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 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. + 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, + /// 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 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), + /// 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, + /// 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. Received and sent requests are + /// counted apart, so a request the wallet both sent and received is kept on both sides. + 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 + } +} + +/// 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, +} + +/// 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 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, +) -> 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 { + 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)) + }) + .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 +/// 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) -> 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() + .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, each proved +/// 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 +/// 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. +/// +/// 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. +pub fn private_contact_requests_from_scan( + dashpay_contract: &DataContract, + scan: PrivateDocumentScan, + 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( + &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 + .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 => { + return Err(Error::Generic(format!( + "the identity {identity_id}, which an entry of the scan is checked against, \ + 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 => { + 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(_) => {} + } + } + let document_type = private_contact_request_type(dashpay_contract)?; + let signing_levels = private_contact_request_signing_security_levels(dashpay_contract)?; + 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( + |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())) + }, + }; + + 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 reader.read_received(&received) { + Ok((request, recipient_key_id)) => { + 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, + sender_id, + sender_key_id: received.entry.sender_key_id(), + commitment: *received.entry.commitment(), + request, + }); + } + Err(reason) => rejected.push(reject(reason)), + } + } + + 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) => { + 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)), + } + } + + rejected.sort_by_key(|rejected| rejected.position); + result.rejected = rejected; + Ok(result) +} + +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], + dashpay_id: Identifier, + decode: &'a DecodeRequest<'a>, +} + +impl EntryReader<'_> { + fn identity(&self, identity_id: &Identifier) -> Option<&Identity> { + 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, + /// 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<(PrivateContactRequest, KeyID), PrivateContactRequestRejection> { + self.check_sender(&received.entry, received.block_time_ms)?; + 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, + key_id, + self.dashpay_id, + Some(received.block_time_ms), + ) { + Err(problem) => problem, + Ok(public_key) if public_key != *received.entry.recipient_public_key() => { + format!("the recipient key {key_id} is not the key the entry is encrypted to") + } + Ok(_) => { + let request = (self.decode)(received.entry.payload())?; + return Ok((request, key_id)); + } + }; + first_problem.get_or_insert(problem); + } + 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 + /// 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()) + } + + /// 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, + 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(), + self.dashpay_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, + self.dashpay_id, + Some(block_time_ms), + ) { + None => return Ok(()), + Some(key_problem) => { + problem.get_or_insert(key_problem); + } + } + } + Err(problem.map_or( + PrivateContactRequestRejection::SenderNotAuthenticated, + PrivateContactRequestRejection::SenderKeyNotUsable, + )) + } +} + +impl Sdk { + /// 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: &Identity, + encryption_key_id: KeyID, + encryption_secret_key: &SecretKey, + signer: &S, + recipient: &Identity, + recipient_key_id: KeyID, + request: &PrivateContactRequest, + ) -> Result + where + S: Signer + ?Sized, + { + 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)) + .ok_or_else(|| { + Error::Generic(format!( + "the signer holds no key of {} that signs a private contact request: an \ + enabled AUTHENTICATION key at HIGH or CRITICAL, bound to no other contract", + sender.id() + )) + })?; + let signing_data = private_contact_request_signing_data( + &dashpay_contract, + sender, + encryption_key_id, + recipient, + recipient_key_id, + request, + self.version(), + )?; + 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::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 { + identity: sender, + encryption_key_id, + encryption_secret_key, + signing_key_id: signing_key.id(), + signature: &signature, + }, + recipient, + recipient_key_id, + request, + &esk_seed, + self.version(), + ); + esk_seed.zeroize(); + sealed + } + + /// 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 (`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.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 + } + + /// 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> { + 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, + }) + }) + .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, 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 + /// [`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, + keys: &PrivateDocumentScanKeys, + ) -> Result { + let dashpay_contract = self.fetch_private_contact_request_contract().await?; + let scan = self + .scan_private_document_page( + dashpay_contract.id(), + PRIVATE_CONTACT_REQUEST_DOCUMENT_TYPE_NAME, + start_position, + keys, + ) + .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()) + } +} + +#[cfg(test)] +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; + use dpp::identity::identity_public_key::v0::IdentityPublicKeyV0; + 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; + use rs_dapi_client::CanRetry; + + 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; + /// 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()) + .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, + // 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, + 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, + }) + } + + /// `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 + } + + /// `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 { + 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, + } + } + + /// 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, + PlatformVersion::latest(), + ) + .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, + request: &PrivateContactRequest, + seed: u8, + ) -> Vec { + 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, + &[seed; 32], + PlatformVersion::latest(), + ) + .expect("the request seals") + .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("the payload encodes") + } + + fn page(entries: Vec>) -> PrivateDocumentEntries { + PrivateDocumentEntries { + chunk_power: None, + 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 alone. + fn recipient_keys() -> PrivateDocumentScanKeys { + PrivateDocumentScanKeys::new() + .with_recipient(Identifier::from(RECIPIENT_ID), &[(2, secret_key(2))]) + } + + /// 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>, + keys: &PrivateDocumentScanKeys, + identities: &[Identity], + ) -> PrivateContactRequestScan { + 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 fetched = private_contact_request_identities(&scan) + .into_keys() + .map(|identity_id| { + let identity = identities + .iter() + .find(|identity| identity.id() == identity_id) + .cloned(); + ( + identity_id, + ProvedIdentity { + identity, + proved_at_height: PROVED_AT_HEIGHT, + }, + ) + }) + .collect(); + 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. + #[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("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) + }, + // 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; + 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) + }, + ]; + // 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!( + 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(); + 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 of the wrong type", + with(ACCOUNT_LABEL, Value::U32(1)), + ), + ( + "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)), + ] { + 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], + &scan_keys(), + &[sender, recipient_identity()], + ); + 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.sender_key_id, 1); + 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 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_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_with(None), + vec![PrivateContactRequestRejection::SenderNotFound] + ); + + // 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_with(Some(sender_identity(Some(BLOCK_TIME_MS + 1)))), + vec![] + ); + // Key 1 is not an encryption key. + assert!(not_usable(reasons_with(Some(with_key( + sender_identity(None), + key( + 1, + Purpose::AUTHENTICATION, + KeyType::ECDSA_SECP256K1, + &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_with(Some(with_key( + sender_identity(None), + signing_key(SecurityLevel::HIGH, Some(BLOCK_TIME_MS + 1)), + ))), + vec![] + ); + 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(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(_)] + )); + } + + /// 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(_)] + )); + } + + /// 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. + #[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, + &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); + 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(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 anonymous = seal_raw( + &contract, + RECIPIENT_ID, + &secret_key(2), + &PrivateDocumentSender::Anonymous, + &payload(&contract, &smallest_request(1)), + ); + + 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), + ], + &recipient_keys(), + &[sender.clone(), recipient_identity()], + ); + 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, 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 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, + PlatformVersion::latest(), + )?; + seal_private_contact_request( + &contract, + &PrivateContactRequestSender { + 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, + &request, + &[1; 32], + PlatformVersion::latest(), + ) + }; + 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( + &sender, + encryption_key_id, + secret, + signing_key_id, + signing_secret, + recipient_key_id + ) + .is_err(), + "{case}" + ); + } + 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" + ); + } + + /// 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], + PlatformVersion::latest(), + ); + 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"); + } + } + } + + /// 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:?}" + ); + } + } + } + + /// 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(); + 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}"); + } + + 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()); + } + + /// 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. + #[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, + ] + ); + } + + /// 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()); + 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!( + 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)); + 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 + /// 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() { + 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!( + matches!( + error, + Error::ProtocolVersionNotActive { + required: 15, + pinned: true, + .. + } + ), + "{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!( + matches!( + error, + Error::ProtocolVersionNotActive { + required: 15, + pinned: true, + .. + } + ), + "{error:?}" + ); + } + + /// 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); + 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(), + &[sender_signing_until(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, &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. + #[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(_)] + )); + + // 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, + }; + 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, + }) + ); + let twice_stale = fetch_proved_identities( + &needed, + scripted(vec![ + proved(Some(sender_identity(None)), 9), + proved(Some(sender_identity(None)), 9), + ]), + ) + .await + .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()))]), + ) + .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() { + 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, + PlatformVersion::latest(), + ) + .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: &sender, + encryption_key_id: 1, + encryption_secret_key: &secret_key(1), + signing_key_id: 0, + signature: &signature, + }, + &recipient_identity(), + 2, + &request, + &[3; 32], + PlatformVersion::latest(), + ) + .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); + } + + #[cfg(feature = "mocks")] + use crate::platform::private_document_entries::held_contract_provider::HeldContractProvider; + #[cfg(feature = "mocks")] + use crate::platform::private_document_entries::PrivateDocumentEntriesQuery; + #[cfg(feature = "mocks")] + use dash_context_provider::ContextProvider; + + /// 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(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()) + .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!(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 { + chunk_power: None, + 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 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 { + chunk_power: None, + 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); + } + + /// 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 + /// 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")] + #[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, + /// 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(Some(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().expect("the provider holds DashPay") + )); + } + + /// 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( + Some(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(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( + Some(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/private_document_entries.rs b/packages/rs-sdk/src/platform/private_document_entries.rs index 0d68e5d80bf..ac01135b1ce 100644 --- a/packages/rs-sdk/src/platform/private_document_entries.rs +++ b/packages/rs-sdk/src/platform/private_document_entries.rs @@ -10,14 +10,25 @@ //! //! [`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 crate::platform::{Fetch, FetchUnproved, Identifier, Query, QuerySettings}; -use crate::Error; +use std::sync::Arc; + +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::{ 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; +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, }; @@ -76,6 +87,207 @@ 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"; + +/// 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 + .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. 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 + /// 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(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())? { + 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) { + // 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 + { + return Err(self.protocol_version_not_active( + PRIVATE_DOCUMENT_CREATE_FROM_SHIELDED_POOL_INITIAL_PROTOCOL_VERSION, + PRIVATE_DOCUMENT_ENTRIES_METHOD, + None, + None, + )); + } + 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); + }; + // 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), + _ => 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, 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>>>, + 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 { + pub(crate) fn new(held: Option, writable: bool) -> Self { + 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() + } + } + + impl ContextProvider for HeldContractProvider { + fn get_data_contract( + &self, + _id: &Identifier, + _platform_version: &PlatformVersion, + ) -> Result>, ContextProviderError> { + 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( + &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::*; @@ -90,6 +302,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 new file mode 100644 index 00000000000..d34ecebd882 --- /dev/null +++ b/packages/rs-sdk/src/platform/private_document_scan.rs @@ -0,0 +1,1747 @@ +//! 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. +//! [`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 +//! 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 std::future::Future; +use std::pin::Pin; + +use crate::error::StaleNodeError; +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 futures::stream::{self, Stream, StreamExt}; +use platform_encryption::{ + private_document_outgoing_viewing_key, recover_sent_private_document_entry, + OpenedPrivateDocumentEntry, PrivateDocumentEnvelopeError, PrivateDocumentRecipientKeys, + PrivateDocumentTarget, +}; +use zeroize::{Zeroize, Zeroizing}; + +/// One identity of the wallet and the keys entries to it may be encrypted to. +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, +} + +/// 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: 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, + 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: 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, +} + +/// 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 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, +} + +/// 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 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. + 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 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 { + 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 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(), + public_keys: secret_keys.iter().map(PublicKey::from_secret_key).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`), derived from its key + /// `key_id`. + pub fn with_sender( + mut self, + identity_id: Identifier, + key_id: KeyID, + mut outgoing_viewing_key: [u8; 32], + ) -> Self { + self.senders.push(ScanSender { + identity_id, + key_id, + outgoing_viewing_key: Box::new(Zeroizing::new(outgoing_viewing_key)), + }); + outgoing_viewing_key.zeroize(); + 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, + target: &PrivateDocumentTarget, + start_position: u64, + 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() + .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> { + // 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() + .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( + &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 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_ids[0], + recipient_key_ids, + 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)) => { + // 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: named.identity_id, + outgoing_key_id: named.key_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; + } + } + } + } +} + +/// 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, + } => { + // 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)), + }; + 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; + // 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, + }; + 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 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() + && 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: !ended_its_snapshot, + } + }; + Some((Ok(page), next)) + } + } + } + }, + ) +} + +/// 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 +/// 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, + 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 fetch = fetch.clone(); + async move { + 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 + // 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" + )) + }; + // 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 { + 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_ahead_of(&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, + 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. 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 + /// 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 + /// ([`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`]; + /// 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 { + // 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) + .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 { + chunk_power: None, + 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, 6, 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, sent.outgoing_key_id)) + .collect::>(), + vec![(12, alice, 6), (14, alice, 6)] + ); + 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) + ); + + // 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"); + } + + /// 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] + 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)] + ); + } + + /// 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() { + 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 { + contract_id: Identifier::from(target().contract_id), + document_type_name: target().document_type_name.to_string(), + next_position: 7, + total_count: 7, + ..Default::default() + } + ); + 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 { + chunk_power: None, + 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 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 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 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 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}"); + } + } + + /// `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() { + 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")] + 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]); + 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/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 new file mode 100644 index 00000000000..ed1b48a0cca --- /dev/null +++ b/packages/rs-sdk/src/platform/transition/private_document_create_from_shielded_pool.rs @@ -0,0 +1,321 @@ +//! Append entries to a private document type's store, paid from the shielded pool. + +use super::broadcast::{private_document_create_requirement, 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; +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 +/// 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, 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, + data_contract_id: Identifier, + document_type_name: String, + entries: Vec, + fee: u64, + bundle: OrchardBundleParams, + ) -> Result; + + /// 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 + /// 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, + 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 { + // 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, + 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 { + // The network's version is established before the transition is built against it, as + // 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, + 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::platform::transition::broadcast::BROADCAST_STATE_TRANSITION_METHOD; + use crate::sdk::test::expect_epoch_refresh; + 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}"); + } + } + + /// 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, 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"); + 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, + // 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:?}" + ); + } + } + + /// 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] + 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:?}" + ); + } +} 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 eadbab8b165..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,12 +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(None).await.0) + } + + /// [`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 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 { + if let Err(error) = resolve_current_epoch(self, settings).await { tracing::warn!( target: "dash_sdk::protocol_version", %error, @@ -776,9 +787,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 7be2d4f8e46..59d39c18c26 100644 --- a/packages/rs-sdk/src/sdk/protocol_gate.rs +++ b/packages/rs-sdk/src/sdk/protocol_gate.rs @@ -254,6 +254,87 @@ 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. `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, + required, + feature, + network: self.query_settings().network_protocol_basis(), + pinned: pinned_below(required, &self.gate_state()), + cause: cause.map(Box::new), + } + } + + /// 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 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`]). 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; + 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(false); + } + 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. 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( + "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 @@ -293,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 09246968eec..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,10 @@ 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; use crate::platform::types::epoch::current_epoch_probe_query; use crate::platform::types::identity::{ IdentityRequest, IdentityResponse, NonUniquePublicKeyHashQuery, @@ -77,8 +81,12 @@ 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, 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; @@ -2384,3 +2392,322 @@ 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 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. +#[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}"); + } +} + +/// 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" + ); +} + +/// 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 +/// 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}"); + } +} diff --git a/packages/wasm-dpp2/src/data_contract/model.rs b/packages/wasm-dpp2/src/data_contract/model.rs index ea810b49a63..f1a60433b38 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()) @@ -378,6 +392,21 @@ 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. The contract format + // reads token positions as text keys. + if !tokens.is_empty() { + 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) + .map_err(|err| WasmDppError::serialization(err.to_string()))?; + } + let data_contract = DataContract::from_value(contract_value, opts.full_validation, &platform_version)?; @@ -664,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 2006c7cb2a5..b04d29fa6dd 100644 --- a/packages/wasm-dpp2/tests/unit/TokenConfiguration.spec.ts +++ b/packages/wasm-dpp2/tests/unit/TokenConfiguration.spec.ts @@ -289,6 +289,100 @@ 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 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 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, + 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({ diff --git a/packages/wasm-sdk/src/context_provider.rs b/packages/wasm-sdk/src/context_provider.rs index 39d4371b2be..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) } @@ -369,11 +370,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 +472,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 } 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',