diff --git a/Cargo.lock b/Cargo.lock index 908681a6868..99bf79e01d0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2285,6 +2285,7 @@ name = "drive-proof-verifier" version = "4.2.0-dev.8" dependencies = [ "bincode", + "ciborium", "dapi-grpc", "dash-context-provider", "derive_more 1.0.0", @@ -2292,6 +2293,7 @@ dependencies = [ "drive", "hex", "indexmap 2.14.0", + "platform-query-wire", "platform-serialization", "platform-serialization-derive", "serde", diff --git a/packages/dash-platform-queries/Cargo.toml b/packages/dash-platform-queries/Cargo.toml index f3d76e7c747..7c1bf7ef225 100644 --- a/packages/dash-platform-queries/Cargo.toml +++ b/packages/dash-platform-queries/Cargo.toml @@ -24,6 +24,8 @@ dapi-grpc = { path = "../dapi-grpc", default-features = false, features = [ dash-context-provider = { path = "../rs-context-provider", default-features = false } dash-platform-macros = { path = "../rs-dash-platform-macros" } dpp = { path = "../rs-dpp", default-features = false, features = [ + "dashpay-contract", + "dpns-contract", "platform-value-cbor", "state-transitions", "state-transition-validation", diff --git a/packages/dash-platform-queries/src/dashpay.rs b/packages/dash-platform-queries/src/dashpay.rs new file mode 100644 index 00000000000..eb6e10de168 --- /dev/null +++ b/packages/dash-platform-queries/src/dashpay.rs @@ -0,0 +1,190 @@ +//! Transport-free DashPay document assembly. +//! +//! The Sdk-bound DashPay surface (ECDH, encryption, fetching the recipient, +//! broadcasting) lives in `dash-sdk`; this is the pure DIP-15 document +//! assembly it shares with embedders that hold the encrypted material +//! themselves. Field size bounds come from the DashPay contract schema, so +//! the builder cannot drift from what the contract accepts. + +use crate::dpns_usernames::new_document; +use crate::Error; +use dpp::data_contract::accessors::v0::DataContractV0Getters; +use dpp::data_contract::document_type::accessors::DocumentTypeV0Getters; +use dpp::data_contract::document_type::DocumentTypeRef; +use dpp::data_contract::errors::DataContractError; +use dpp::document::Document; +use dpp::platform_value::Value; +use dpp::prelude::{DataContract, Identifier}; +use dpp::system_data_contracts::dashpay_contract::v1::document_types::contact_request; +use std::collections::BTreeMap; + +/// Inputs of a DIP-15 `contactRequest` document. The encrypted fields are +/// supplied already encrypted (ECDH, AES-CBC with a fresh IV prepended); +/// see `dash-sdk`'s `create_contact_request` for the encryption itself. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ContactRequestDocumentParams { + /// Identity sending the request; becomes the document owner. + pub sender_id: Identifier, + /// Identity receiving the request (`toUserId`). + pub recipient_id: Identifier, + /// Index of the sender's encryption key used for ECDH. + pub sender_key_index: u32, + /// Index of the recipient's key used for ECDH. + pub recipient_key_index: u32, + /// DashPay receiving-account reference. + pub account_reference: u32, + /// Encrypted DIP-15 compact extended public key (IV ‖ ciphertext). + pub encrypted_public_key: Vec, + /// Encrypted account label (IV ‖ ciphertext), if any. + pub encrypted_account_label: Option>, + /// Unencrypted auto-accept proof, if any. + pub auto_accept_proof: Option>, + /// Entropy the document id derives from; reuse it on the create + /// transition. + pub entropy: [u8; 32], +} + +/// Assemble a `contactRequest` document, checking every byte-array field +/// against the size bounds the contract schema declares for it. +pub fn build_contact_request_document( + contract: &DataContract, + params: ContactRequestDocumentParams, +) -> Result { + let document_type = contract.document_type_for_name(contact_request::NAME)?; + + check_byte_field( + document_type, + "encryptedPublicKey", + ¶ms.encrypted_public_key, + )?; + if let Some(label) = ¶ms.encrypted_account_label { + check_byte_field(document_type, "encryptedAccountLabel", label)?; + } + if let Some(proof) = ¶ms.auto_accept_proof { + check_byte_field(document_type, "autoAcceptProof", proof)?; + } + + let mut properties = BTreeMap::from([ + ( + contact_request::properties::TO_USER_ID.to_string(), + Value::Identifier(params.recipient_id.to_buffer()), + ), + ( + "encryptedPublicKey".to_string(), + Value::Bytes(params.encrypted_public_key), + ), + ( + "senderKeyIndex".to_string(), + Value::U32(params.sender_key_index), + ), + ( + "recipientKeyIndex".to_string(), + Value::U32(params.recipient_key_index), + ), + ( + "accountReference".to_string(), + Value::U32(params.account_reference), + ), + ]); + if let Some(label) = params.encrypted_account_label { + properties.insert("encryptedAccountLabel".to_string(), Value::Bytes(label)); + } + if let Some(proof) = params.auto_accept_proof { + properties.insert("autoAcceptProof".to_string(), Value::Bytes(proof)); + } + + Ok(new_document( + contract, + document_type.name(), + params.sender_id, + params.entropy, + properties, + )) +} + +/// Check `bytes` against the `minItems`/`maxItems` the contract declares for +/// the byte-array property `field`. +fn check_byte_field( + document_type: DocumentTypeRef, + field: &str, + bytes: &[u8], +) -> Result<(), Error> { + let property = document_type.properties().get(field).ok_or_else(|| { + DataContractError::DocumentTypeFieldNotFound(format!( + "{} has no property {field}", + document_type.name() + )) + })?; + let min = property.property_type.min_size().unwrap_or(0) as usize; + let max = property.property_type.max_size().unwrap_or(u16::MAX) as usize; + if bytes.len() < min || bytes.len() > max { + return Err(Error::Config(format!( + "{field} must be {min}-{max} bytes, got {}", + bytes.len() + ))); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use dpp::document::DocumentV0Getters; + use dpp::system_data_contracts::{load_system_data_contract, SystemDataContract}; + use dpp::version::PlatformVersion; + + fn params(encrypted_public_key: Vec) -> ContactRequestDocumentParams { + ContactRequestDocumentParams { + sender_id: Identifier::from([1u8; 32]), + recipient_id: Identifier::from([2u8; 32]), + sender_key_index: 1, + recipient_key_index: 2, + account_reference: 3, + encrypted_public_key, + encrypted_account_label: None, + auto_accept_proof: None, + entropy: [7u8; 32], + } + } + + fn contract() -> DataContract { + load_system_data_contract(SystemDataContract::Dashpay, PlatformVersion::latest()) + .expect("dashpay contract") + } + + #[test] + fn should_derive_the_document_id_from_the_entropy() { + let contract = contract(); + let document = build_contact_request_document(&contract, params(vec![0u8; 96])) + .expect("valid contact request"); + assert_eq!( + document.id(), + Document::generate_document_id_v0( + &contract.id(), + &Identifier::from([1u8; 32]), + contact_request::NAME, + &[7u8; 32] + ) + ); + assert_eq!(document.owner_id(), Identifier::from([1u8; 32])); + assert_eq!( + document.get("toUserId"), + Some(&Value::Identifier([2u8; 32])) + ); + } + + #[test] + fn should_enforce_the_contract_byte_bounds() { + let contract = contract(); + build_contact_request_document(&contract, params(vec![0u8; 95])) + .expect_err("encryptedPublicKey below the schema's 96 bytes"); + let mut with_label = params(vec![0u8; 96]); + with_label.encrypted_account_label = Some(vec![0u8; 81]); + build_contact_request_document(&contract, with_label) + .expect_err("encryptedAccountLabel above the schema's 80 bytes"); + let mut with_proof = params(vec![0u8; 96]); + with_proof.auto_accept_proof = Some(vec![0u8; 37]); + build_contact_request_document(&contract, with_proof) + .expect_err("autoAcceptProof below the schema's 38 bytes"); + } +} diff --git a/packages/dash-platform-queries/src/dpns_usernames.rs b/packages/dash-platform-queries/src/dpns_usernames.rs index 3f452519b29..87840f3e742 100644 --- a/packages/dash-platform-queries/src/dpns_usernames.rs +++ b/packages/dash-platform-queries/src/dpns_usernames.rs @@ -2,21 +2,27 @@ //! //! The Sdk-bound DPNS surface (registration, availability checks, name //! resolution) lives in `dash-sdk`; these free functions are pure string -//! validation/normalization shared with embedders. +//! validation/normalization and document assembly shared with embedders. +//! Normalization is dpp's consensus implementation +//! ([`convert_to_homograph_safe_chars`]), the same one the DPNS data trigger +//! checks `normalizedLabel` against. -/// Convert a string to homograph-safe characters by replacing 'o', 'i', and 'l' -/// with '0', '1', and '1' respectively to prevent homograph attacks -pub fn convert_to_homograph_safe_chars(input: &str) -> String { - input - .chars() - .map(|c| match c { - 'o' | 'O' => '0', - 'i' | 'I' => '1', - 'l' | 'L' => '1', - _ => c.to_ascii_lowercase(), - }) - .collect() -} +use crate::Error; +use dpp::data_contract::accessors::v0::DataContractV0Getters; +use dpp::data_contract::document_type::accessors::DocumentTypeV0Getters; +use dpp::document::{Document, DocumentV0}; +use dpp::platform_value::Value; +use dpp::prelude::{DataContract, Identifier}; +use dpp::system_data_contracts::dpns_contract::v1::document_types::domain; +use dpp::util::hash::hash_double; +use std::collections::BTreeMap; + +/// Document type name of the DPNS preorder document. +pub const PREORDER_DOCUMENT_TYPE: &str = "preorder"; +/// The only parent domain names can currently be registered under. +pub const DASH_PARENT_DOMAIN: &str = "dash"; + +pub use dpp::util::strings::convert_to_homograph_safe_chars; /// Check if a username is valid according to DPNS rules /// @@ -97,10 +103,203 @@ pub fn is_contested_username(label: &str) -> bool { .all(|c| matches!(c, 'a'..='z' | '0' | '1' | '-')) } +/// The DPNS `preorder` document that blinds `label`.dash behind `salt`. +/// +/// `saltedDomainHash` is `sha256d(salt ‖ ".dash")` — the +/// same preimage the DPNS data trigger recomputes when the paired `domain` +/// document is created. The document id derives from `entropy`, which must +/// be reused on the create transition. +/// +/// Callers driving their own registration must draw `salt` from a CSPRNG and +/// keep it, the label, and the domain document private until the preorder is +/// confirmed, or the preorder's front-running protection is lost. +pub fn build_dpns_preorder_document( + contract: &DataContract, + owner_id: Identifier, + label: &str, + salt: [u8; 32], + entropy: [u8; 32], +) -> Result { + let document_type = contract.document_type_for_name(PREORDER_DOCUMENT_TYPE)?; + let properties = BTreeMap::from([( + "saltedDomainHash".to_string(), + Value::Bytes32(salted_domain_hash(label, salt)), + )]); + Ok(new_document( + contract, + document_type.name(), + owner_id, + entropy, + properties, + )) +} + +/// The DPNS `domain` document registering `label`.dash for `owner_id`, with +/// the identity record pointing at the owner and subdomains disallowed. +/// +/// Rejects a label the DPNS contract's consensus pattern would refuse, so an +/// invalid name fails before a preorder is paid for. `normalizedLabel` is the +/// consensus normalization of `label`. +pub fn build_dpns_domain_document( + contract: &DataContract, + owner_id: Identifier, + label: &str, + salt: [u8; 32], + entropy: [u8; 32], +) -> Result { + if !is_valid_username(label) { + return Err(Error::Config(format!( + "DPNS label {label:?} does not match the contract's label pattern" + ))); + } + let document_type = contract.document_type_for_name(domain::NAME)?; + let properties = BTreeMap::from([ + ( + domain::properties::PARENT_DOMAIN_NAME.to_string(), + Value::Text(DASH_PARENT_DOMAIN.to_string()), + ), + ( + domain::properties::NORMALIZED_PARENT_DOMAIN_NAME.to_string(), + Value::Text(DASH_PARENT_DOMAIN.to_string()), + ), + ( + domain::properties::LABEL.to_string(), + Value::Text(label.to_string()), + ), + ( + domain::properties::NORMALIZED_LABEL.to_string(), + Value::Text(convert_to_homograph_safe_chars(label)), + ), + ( + domain::properties::PREORDER_SALT.to_string(), + Value::Bytes32(salt), + ), + ( + domain::properties::RECORDS.to_string(), + Value::Map(vec![( + Value::Text(domain::properties::IDENTITY.to_string()), + Value::Identifier(owner_id.to_buffer()), + )]), + ), + ( + "subdomainRules".to_string(), + Value::Map(vec![( + Value::Text("allowSubdomains".to_string()), + Value::Bool(false), + )]), + ), + ]); + Ok(new_document( + contract, + document_type.name(), + owner_id, + entropy, + properties, + )) +} + +/// `sha256d(salt ‖ ".dash")`: the preorder commitment the +/// DPNS data trigger recomputes from the domain document. +pub fn salted_domain_hash(label: &str, salt: [u8; 32]) -> [u8; 32] { + let mut preimage = salt.to_vec(); + preimage.extend_from_slice(convert_to_homograph_safe_chars(label).as_bytes()); + preimage.extend_from_slice(b"."); + preimage.extend_from_slice(DASH_PARENT_DOMAIN.as_bytes()); + hash_double(preimage) +} + +/// A fresh document whose id derives from `entropy`, with every +/// chain-assigned field left unset (they never enter a create transition). +pub(crate) fn new_document( + contract: &DataContract, + document_type_name: &str, + owner_id: Identifier, + entropy: [u8; 32], + properties: BTreeMap, +) -> Document { + Document::V0(DocumentV0 { + id: Document::generate_document_id_v0( + &contract.id(), + &owner_id, + document_type_name, + &entropy, + ), + owner_id, + properties, + ..Default::default() + }) +} + #[cfg(test)] mod tests { use super::*; + #[test] + fn should_build_preorder_and_domain_documents_that_agree() { + use dpp::document::DocumentV0Getters; + use dpp::system_data_contracts::{load_system_data_contract, SystemDataContract}; + use dpp::version::PlatformVersion; + + let contract = + load_system_data_contract(SystemDataContract::DPNS, PlatformVersion::latest()) + .expect("dpns contract"); + let owner = Identifier::from([1u8; 32]); + let salt = [5u8; 32]; + let entropy = [9u8; 32]; + + let preorder = build_dpns_preorder_document(&contract, owner, "Alice", salt, entropy) + .expect("preorder"); + let domain = + build_dpns_domain_document(&contract, owner, "Alice", salt, entropy).expect("domain"); + + // The commitment in the preorder is the one the DPNS data trigger + // recomputes from the domain document's salt and normalized label. + assert_eq!( + preorder.get("saltedDomainHash"), + Some(&Value::Bytes32(salted_domain_hash("Alice", salt))) + ); + assert_eq!( + domain.get(domain::properties::NORMALIZED_LABEL), + Some(&Value::Text("a11ce".to_string())) + ); + assert_eq!( + domain.get(domain::properties::LABEL), + Some(&Value::Text("Alice".to_string())) + ); + assert_eq!( + domain.get(domain::properties::PREORDER_SALT), + Some(&Value::Bytes32(salt)) + ); + assert_eq!( + preorder.id(), + Document::generate_document_id_v0( + &contract.id(), + &owner, + PREORDER_DOCUMENT_TYPE, + &entropy + ) + ); + assert_ne!(preorder.id(), domain.id()); + } + + #[test] + fn should_refuse_a_label_the_contract_pattern_rejects() { + use dpp::system_data_contracts::{load_system_data_contract, SystemDataContract}; + use dpp::version::PlatformVersion; + + let contract = + load_system_data_contract(SystemDataContract::DPNS, PlatformVersion::latest()) + .expect("dpns contract"); + build_dpns_domain_document( + &contract, + Identifier::from([1u8; 32]), + "-bad", + [0; 32], + [0; 32], + ) + .expect_err("leading hyphen violates the label pattern"); + } + #[test] fn test_convert_to_homograph_safe_chars() { assert_eq!(convert_to_homograph_safe_chars("alice"), "a11ce"); diff --git a/packages/dash-platform-queries/src/error.rs b/packages/dash-platform-queries/src/error.rs index 0d8727763ce..ecfad70370b 100644 --- a/packages/dash-platform-queries/src/error.rs +++ b/packages/dash-platform-queries/src/error.rs @@ -1,6 +1,7 @@ //! Errors produced by the transport-free query core. use dpp::consensus::ConsensusError; +use dpp::data_contract::errors::DataContractError; use dpp::validation::SimpleConsensusValidationResult; use dpp::ProtocolError; @@ -23,6 +24,12 @@ pub enum Error { Protocol(#[from] ProtocolError), } +impl From for Error { + fn from(value: DataContractError) -> Self { + Self::Protocol(ProtocolError::DataContractError(value)) + } +} + impl From for Error { fn from(value: ConsensusError) -> Self { Self::Protocol(ProtocolError::ConsensusError(Box::new(value))) diff --git a/packages/dash-platform-queries/src/lib.rs b/packages/dash-platform-queries/src/lib.rs index 9da47cf6435..29b71b8f434 100644 --- a/packages/dash-platform-queries/src/lib.rs +++ b/packages/dash-platform-queries/src/lib.rs @@ -13,6 +13,7 @@ #![allow(clippy::result_large_err)] pub mod block_info_from_metadata; +pub mod dashpay; pub mod documents; pub mod dpns_usernames; pub mod error; diff --git a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/document_create_transition/v0/from_document.rs b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/document_create_transition/v0/from_document.rs index 126062a1659..cf705c19272 100644 --- a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/document_create_transition/v0/from_document.rs +++ b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/document_create_transition/v0/from_document.rs @@ -1,3 +1,5 @@ +use crate::consensus::basic::document::InvalidDocumentTransitionIdError; +use crate::data_contract::document_type::accessors::DocumentTypeV0Getters; use crate::data_contract::document_type::methods::DocumentTypeV0Methods; use crate::data_contract::document_type::DocumentTypeRef; use crate::document::{Document, DocumentV0Getters}; @@ -18,6 +20,22 @@ impl DocumentCreateTransitionV0 { platform_version: &PlatformVersion, base_feature_version: Option, ) -> Result { + // Drive recomputes the document id from the entropy during + // advanced-structure validation and rejects a mismatch with + // InvalidDocumentTransitionIdError, after the identity contract nonce + // has already been bumped. Refuse locally so no caller can assemble a + // create transition that will only fail once it has been paid for. + let expected_id = Document::generate_document_id_v0( + &document_type.data_contract_id(), + &document.owner_id(), + document_type.name(), + &entropy, + ); + if document.id() != expected_id { + return Err(ProtocolError::ConsensusError(Box::new( + InvalidDocumentTransitionIdError::new(expected_id, document.id()).into(), + ))); + } let prefunded_voting_balance = document_type.prefunded_voting_balance_for_document(&document, platform_version)?; Ok(DocumentCreateTransitionV0 { diff --git a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/document_create_transition/v0/mod.rs b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/document_create_transition/v0/mod.rs index 876fc29bc10..3037766d817 100644 --- a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/document_create_transition/v0/mod.rs +++ b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/document_create_transition/v0/mod.rs @@ -600,6 +600,63 @@ mod test { ) } + /// Drive recomputes the document id from the create transition's entropy + /// and rejects a mismatch only after the identity contract nonce was + /// bumped; `from_document` must refuse the same mismatch locally. + #[test] + fn from_document_refuses_an_id_the_entropy_does_not_derive() { + use crate::consensus::basic::BasicError; + use crate::consensus::ConsensusError; + use crate::data_contract::accessors::v0::DataContractV0Getters; + use crate::document::{Document, DocumentV0}; + + let data_contract = data_contract_with_dynamic_properties(); + let document_type = data_contract + .document_type_for_name("test") + .expect("test document type"); + let owner_id = Identifier::from([2_u8; 32]); + let entropy = [7_u8; 32]; + let derived_id = + Document::generate_document_id_v0(&data_contract.id(), &owner_id, "test", &entropy); + let document = |id: Identifier| { + Document::V0(DocumentV0 { + id, + owner_id, + ..Default::default() + }) + }; + + DocumentCreateTransitionV0::from_document( + document(derived_id), + document_type, + entropy, + None, + 1, + LATEST_PLATFORM_VERSION, + None, + ) + .expect("an id derived from the entropy is accepted"); + + let error = DocumentCreateTransitionV0::from_document( + document(Identifier::from([9_u8; 32])), + document_type, + entropy, + None, + 1, + LATEST_PLATFORM_VERSION, + None, + ) + .expect_err("an id the entropy does not derive is refused"); + assert!( + matches!( + error, + ProtocolError::ConsensusError(ref boxed) + if matches!(**boxed, ConsensusError::BasicError(BasicError::InvalidDocumentTransitionIdError(_))) + ), + "unexpected error: {error:?}" + ); + } + #[test] #[cfg(feature = "json-conversion")] fn convert_to_json_with_dynamic_binary_paths() { diff --git a/packages/rs-drive-proof-verifier/Cargo.toml b/packages/rs-drive-proof-verifier/Cargo.toml index d0246c9a8eb..b211dab2d88 100644 --- a/packages/rs-drive-proof-verifier/Cargo.toml +++ b/packages/rs-drive-proof-verifier/Cargo.toml @@ -32,6 +32,8 @@ dash-context-provider = { path = "../rs-context-provider", features = [ "mocks", ] } bincode = { version = "=2.0.1", features = ["serde"] } +ciborium = { version = "0.2.2" } +platform-query-wire = { path = "../rs-platform-query-wire" } platform-serialization-derive = { path = "../rs-platform-serialization-derive", optional = true } platform-serialization = { path = "../rs-platform-serialization" } tenderdash-abci = { git = "https://github.com/dashpay/rs-tenderdash-abci", tag = "v1.5.1", features = [ diff --git a/packages/rs-drive-proof-verifier/src/lib.rs b/packages/rs-drive-proof-verifier/src/lib.rs index 55c201b1cf9..a2a20a31a52 100644 --- a/packages/rs-drive-proof-verifier/src/lib.rs +++ b/packages/rs-drive-proof-verifier/src/lib.rs @@ -33,6 +33,7 @@ pub use proof::document_having::{verify_having_range_proof, DocumentHavingEntrie /// that binds the proof's reconstructed root hash to the signed app /// hash and returns the whole verified [`drive::query::RankedPage`]. pub use proof::document_ranked::{verify_ranked_top_k_proof, DocumentRankedEntries}; +pub use proof::document_request::{DocumentWireQuery, RequestedDocuments}; pub use proof::document_split_count::DocumentSplitCounts; // Re-export `SplitCountEntry` from rs-drive at the proof-verifier // crate root so SDK consumers don't have to depend on rs-drive diff --git a/packages/rs-drive-proof-verifier/src/proof.rs b/packages/rs-drive-proof-verifier/src/proof.rs index 39a7dd34497..69c3e4268e4 100644 --- a/packages/rs-drive-proof-verifier/src/proof.rs +++ b/packages/rs-drive-proof-verifier/src/proof.rs @@ -18,6 +18,7 @@ pub mod document_having; /// plus the attested rank the page starts at, read from an indexed /// tree's per-axis secondary (grovedb PR 657); see the file's docs. pub mod document_ranked; +pub mod document_request; /// Per-entry verified average result. One `(in_key, key, count, sum)` /// tuple per matched group; client divides per-entry to obtain /// per-group averages. diff --git a/packages/rs-drive-proof-verifier/src/proof/document_request.rs b/packages/rs-drive-proof-verifier/src/proof/document_request.rs new file mode 100644 index 00000000000..80721ec5105 --- /dev/null +++ b/packages/rs-drive-proof-verifier/src/proof/document_request.rs @@ -0,0 +1,480 @@ +//! Request-driven document proof verification. +//! +//! [`FromProof`] reconstructs the [`DriveDocumentQuery`] +//! an honest server ran from the *wire request bytes that were actually sent*, +//! then verifies the proved response against it. This is the entry point for +//! embedders that own their transport (the Dash Core platform GUI, explorers): +//! their request only ever exists as protobuf bytes, so the verifier must +//! rebuild the query the same way the server did. +//! +//! The reconstruction runs the server's own pipeline: +//! - the wire decode is the shared `platform_query_wire` decoder rs-drive-abci +//! decodes incoming requests with, so server and verifier cannot drift on +//! clause interpretation; +//! - the lowering into a [`DriveDocumentQuery`] is rs-drive's +//! [`DriveDocumentQuery::from_typed_clauses`] under the default +//! [`DriveConfig`] (the limit contract every deployed server runs with) and +//! the platform version the response was produced under; +//! - request shapes the server would never have answered with a plain proved +//! document set (aggregate projections, `HAVING`, `GROUP BY`, `OFFSET`, +//! `prove = false`, a wire version outside the served bounds) are rejected +//! up front, since a proof can never belong to them. + +use crate::from_request::TryFromRequest; +use crate::types::Documents; +use crate::{ContextProvider, Error, FromProof, Length}; +use dapi_grpc::platform::v0::get_documents_request::{ + get_documents_request_v0::Start as V0Start, get_documents_request_v1::Start as V1Start, + GetDocumentsRequestV0, GetDocumentsRequestV1, Version, +}; +use dapi_grpc::platform::v0::{GetDocumentsRequest, GetDocumentsResponse, Proof, ResponseMetadata}; +use dapi_grpc::platform::VersionedGrpcResponse; +use dpp::dashcore::Network; +use dpp::data_contract::accessors::v0::DataContractV0Getters; +use dpp::platform_value::Value; +use dpp::prelude::{DataContract, Identifier}; +use dpp::version::PlatformVersion; +use drive::config::DriveConfig; +use drive::query::{ + resolve_time_range_bucket_clause, DriveDocumentQuery, OrderClause, ResolvedTimeRange, + SelectProjection, TimeRangeGridSpec, TimeRangeSelector, WhereClause, +}; +use platform_query_wire::proto_conversions as wire; +use std::sync::Arc; + +/// The [`GetDocumentsRequest`] fields a proved plain-document response is +/// verified against, decoded off the wire but not yet bound to a contract. +/// +/// Built by [`TryFromRequest`] from either wire version. The data contract is +/// resolved separately (through the [`ContextProvider`]) because a +/// [`DriveDocumentQuery`] borrows it. +#[derive(Debug, Clone, PartialEq)] +pub struct DocumentWireQuery { + /// Contract the request targets. + pub data_contract_id: Identifier, + /// Document type name on that contract. + pub document_type_name: String, + /// Decoded `WHERE` clauses, with any `IN_TIME_RANGE` selections still + /// pending in `time_ranges`. + pub where_clauses: Vec, + /// `IN_TIME_RANGE` selections (v1 only), resolved against the + /// quorum-signed block time once the response is known. + pub time_ranges: Vec, + /// Decoded `ORDER BY` clauses. + pub order_by: Vec, + /// Wire limit; `None` means "server default". A v0 request's `0` is + /// normalised to `None` here, since on that wire `0` is the only way to + /// leave the limit unset; a v1 `Some(0)` is refused at decode. + pub limit: Option, + /// Pagination cursor and whether it is inclusive. + pub start_at: Option<[u8; 32]>, + /// `true` for `start_at`, `false` for `start_after`; the server default + /// when no cursor is given. + pub start_at_included: bool, +} + +/// A pending `IN_TIME_RANGE` selection: `(field, selector, grid)`. +pub type TimeRangeSelection = (String, TimeRangeSelector, Option); + +impl TryFromRequest for DocumentWireQuery { + fn try_from_request(grpc_request: GetDocumentsRequest) -> Result { + match grpc_request.version.ok_or(Error::EmptyVersion)? { + Version::V0(v0) => Self::try_from_v0(v0), + Version::V1(v1) => Self::try_from_v1(v1), + } + } + + /// The wire encoder for document queries lives with the rich + /// `DocumentQuery` builder in `dash-platform-queries` + /// (`GetDocumentsRequest::try_from_platform_versioned`); this type only + /// runs the decode direction, and refuses rather than duplicating it. + fn try_to_request(&self) -> Result { + Err(request_error( + "DocumentWireQuery is decode-only; encode a dash_platform_queries DocumentQuery", + )) + } +} + +fn request_error(error: impl std::fmt::Display) -> Error { + Error::RequestError { + error: error.to_string(), + } +} + +fn cursor(bytes: Vec, what: &str) -> Result<[u8; 32], Error> { + bytes + .try_into() + .map_err(|_| request_error(format!("{what} should be a 32 byte identifier"))) +} + +impl DocumentWireQuery { + fn try_from_v0(request: GetDocumentsRequestV0) -> Result { + let GetDocumentsRequestV0 { + data_contract_id, + document_type, + r#where, + order_by, + limit, + prove, + start, + } = request; + reject_unproved(prove)?; + + // The v0 wire carries CBOR arrays of `[field, operator, value]` / + // `[field, "asc"|"desc"]`; decode them through the same + // `from_components` parsers the server's v0 handler uses. + let where_clauses = match cbor_array(&r#where, "where")? { + None => Vec::new(), + Some(clauses) => clauses + .iter() + .map(|clause| match clause { + Value::Array(components) => { + WhereClause::from_components(components).map_err(request_error) + } + _ => Err(request_error("where clause must be an array")), + }) + .collect::>()?, + }; + let order_by = match cbor_array(&order_by, "order_by")? { + None => Vec::new(), + Some(clauses) => clauses + .iter() + .map(|clause| match clause { + Value::Array(components) => OrderClause::from_components(components) + .map_err(|_| request_error("invalid order_by clause components")), + _ => Err(request_error("order_by clause must be an array")), + }) + .collect::>()?, + }; + let (start_at, start_at_included) = match start { + None => (None, true), + Some(V0Start::StartAt(at)) => (Some(cursor(at, "start at")?), true), + Some(V0Start::StartAfter(after)) => (Some(cursor(after, "start after")?), false), + }; + Ok(Self { + data_contract_id: Identifier::from_bytes(&data_contract_id).map_err(request_error)?, + document_type_name: document_type, + where_clauses, + time_ranges: Vec::new(), + order_by, + limit: (limit != 0).then_some(limit), + start_at, + start_at_included, + }) + } + + fn try_from_v1(request: GetDocumentsRequestV1) -> Result { + // Destructured non-exhaustively: the generated request gains fields + // as the query surface grows (e.g. `sub_queries` behind a feature), + // and any field this verifier does not understand is checked below + // through `Default` rather than silently ignored. + let GetDocumentsRequestV1 { + data_contract_id, + document_type, + where_clauses, + order_by, + limit, + start, + prove, + selects, + group_by, + having, + offset, + chained, + .. + } = request.clone(); + reject_unproved(prove)?; + let recognised = GetDocumentsRequestV1 { + data_contract_id: request.data_contract_id.clone(), + document_type: request.document_type.clone(), + where_clauses: request.where_clauses.clone(), + order_by: request.order_by.clone(), + limit: request.limit, + start: request.start.clone(), + prove: request.prove, + selects: request.selects.clone(), + group_by: request.group_by.clone(), + having: request.having.clone(), + offset: request.offset, + chained: request.chained.clone(), + ..Default::default() + }; + if recognised != request { + return Err(request_error( + "request carries fields this verifier does not understand (e.g. sub-queries); \ + no proved plain-document response can be verified against it", + )); + } + + // Shapes the server routes anywhere but the plain document fetch. + // Each mirrors a gate in rs-drive-abci's `query_documents_v1`; a + // proved plain-document response can never belong to a request that + // trips one, so refuse before touching proof machinery. + if chained.is_some() { + return Err(request_error( + "chained document requests are verified through ChainedDocumentQuery", + )); + } + if selects.len() > 1 { + return Err(request_error( + "multi-projection SELECT is not served; no proved response can belong to it", + )); + } + if let Some(select) = selects.into_iter().next() { + let select = wire::select_from_proto(select).map_err(request_error)?; + if select != SelectProjection::documents() { + return Err(request_error(format!( + "only SELECT DOCUMENTS is verified here; {select:?} is an aggregate \ + projection with its own proof shape" + ))); + } + } + if !group_by.is_empty() { + return Err(request_error( + "GROUP BY is refused by the server under SELECT DOCUMENTS", + )); + } + if !having.is_empty() { + return Err(request_error( + "HAVING is refused by the server for a non-aggregate SELECT", + )); + } + if let Some(offset) = offset { + return Err(request_error(format!( + "OFFSET {offset} is only served on the ranked surface, never for a document fetch" + ))); + } + + let (time_range_proto, normal_proto): (Vec<_>, Vec<_>) = where_clauses + .into_iter() + .partition(wire::is_time_range_clause); + let time_ranges = time_range_proto + .into_iter() + .map(|clause| wire::time_range_clause_from_proto(clause).map_err(request_error)) + .collect::>()?; + let where_clauses = wire::where_clauses_from_proto(normal_proto).map_err(request_error)?; + let order_by = wire::order_clauses_from_proto(order_by).map_err(request_error)?; + let (start_at, start_at_included) = match start { + None => (None, true), + Some(V1Start::StartAt(at)) => (Some(cursor(at, "start at")?), true), + Some(V1Start::StartAfter(after)) => (Some(cursor(after, "start after")?), false), + }; + // The v1 wire has `optional uint32 limit`, so "use the default" is + // spelled `None`; the server refuses an explicit `Some(0)` outright + // (`validate_and_route`), unlike v0 where `0` is the only way to + // leave the limit unset. + if limit == Some(0) { + return Err(request_error( + "limit = 0 is not a valid v1 wire value; omit the limit for the server default", + )); + } + Ok(Self { + data_contract_id: Identifier::from_bytes(&data_contract_id).map_err(request_error)?, + document_type_name: document_type, + where_clauses, + time_ranges, + order_by, + limit, + start_at, + start_at_included, + }) + } + + /// Lower into the [`DriveDocumentQuery`] the server ran, exactly as + /// rs-drive-abci's `query_documents_typed` does: the same + /// `from_typed_clauses` constructor, the default [`DriveConfig`] limit + /// contract, and the platform version the response was produced under. + /// + /// `block_time_ms` is the quorum-signed response time; `IN_TIME_RANGE` + /// selections resolve against it to the same bucket the server used. + pub fn to_drive_query<'a>( + &self, + contract: &'a DataContract, + block_time_ms: Option, + platform_version: &PlatformVersion, + ) -> Result, Error> { + if contract.id() != self.data_contract_id { + return Err(request_error(format!( + "request targets data contract {} but the supplied contract is {}", + self.data_contract_id, + contract.id() + ))); + } + let document_type = contract + .document_type_for_name(&self.document_type_name) + .map_err(|e| request_error(format!("document type: {e}")))?; + + let mut where_clauses = self.where_clauses.clone(); + let mut resolved_time_ranges: Vec = Vec::new(); + if !self.time_ranges.is_empty() { + let block_time_ms = block_time_ms.ok_or_else(|| { + request_error("time range query needs the response block time to resolve") + })?; + for (field, selector, grid) in &self.time_ranges { + let (clause, resolved) = resolve_time_range_bucket_clause( + field, + *selector, + *grid, + document_type, + block_time_ms, + )?; + where_clauses.push(clause); + resolved_time_ranges.push(resolved); + } + } + + // Same translation as the server: `None` falls back to the config + // default inside `from_typed_clauses`; values above `u16::MAX` are + // refused before the cast. + let limit = match self.limit { + Some(n) if n > u32::from(u16::MAX) => { + return Err(request_error(format!("limit {n} out of bounds"))); + } + None => None, + Some(n) => Some(n as u16), + }; + + let mut query = DriveDocumentQuery::from_typed_clauses( + where_clauses, + self.order_by.clone(), + limit, + self.start_at, + self.start_at_included, + None, + contract, + document_type, + &DriveConfig::default(), + platform_version, + )?; + query.resolved_time_ranges = resolved_time_ranges; + Ok(query) + } +} + +fn reject_unproved(prove: bool) -> Result<(), Error> { + if prove { + Ok(()) + } else { + Err(request_error( + "request carries prove=false, so an honest server answered it unproved; a proved \ + response cannot belong to it", + )) + } +} + +/// Decode a v0 CBOR clause field into its top-level array, `None` when the +/// field is empty or CBOR null (both mean "no clauses" to the server). +fn cbor_array(bytes: &[u8], field: &str) -> Result>, Error> { + if bytes.is_empty() { + return Ok(None); + } + let value: Value = ciborium::de::from_reader(bytes) + .map_err(|_| request_error(format!("unable to decode '{field}' query from cbor")))?; + match value { + Value::Null => Ok(None), + Value::Array(clauses) => Ok(Some(clauses)), + _ => Err(request_error(format!("{field} must be an array"))), + } +} + +/// Reject a request whose wire version (`V0`/`V1`) is outside the +/// `document_query` feature-version bounds the given platform version's +/// server serves. The server answers such a request with +/// `UnsupportedQueryVersion`, so no proved response can belong to it. +fn check_wire_version_is_served( + request: &GetDocumentsRequest, + platform_version: &PlatformVersion, +) -> Result<(), Error> { + let feature_version: u16 = match &request.version { + Some(Version::V0(_)) => 0, + Some(Version::V1(_)) => 1, + None => return Err(Error::EmptyVersion), + }; + let bounds = &platform_version.drive_abci.query.document_query; + if !bounds.check_version(feature_version) { + return Err(request_error(format!( + "GetDocumentsRequest wire version V{feature_version} is outside the document_query \ + bounds {}..={} served at platform version {}", + bounds.min_version, bounds.max_version, platform_version.protocol_version + ))); + } + Ok(()) +} + +/// Documents verified against the wire request that fetched them. +/// +/// A distinct type from [`Documents`] because that one carries a blanket +/// `FromProof` for every `Q: TryInto`, which +/// coherence will not let a `GetDocumentsRequest` impl sit beside. +/// `Deref`s to the underlying [`Documents`] map. +#[derive(Debug, Clone, PartialEq, Default)] +pub struct RequestedDocuments(pub Documents); + +impl std::ops::Deref for RequestedDocuments { + type Target = Documents; + fn deref(&self) -> &Documents { + &self.0 + } +} + +impl From for Documents { + fn from(value: RequestedDocuments) -> Self { + value.0 + } +} + +impl Length for RequestedDocuments { + fn count_some(&self) -> usize { + self.0.count_some() + } + fn count(&self) -> usize { + self.0.count() + } +} + +impl FromProof for RequestedDocuments { + type Request = GetDocumentsRequest; + type Response = GetDocumentsResponse; + + fn maybe_from_proof_with_metadata<'a, I: Into, O: Into>( + request: I, + response: O, + network: Network, + platform_version: &PlatformVersion, + provider: &'a dyn ContextProvider, + ) -> Result<(Option, ResponseMetadata, Proof), Error> + where + Self: 'a, + { + let request: Self::Request = request.into(); + let response: Self::Response = response.into(); + + check_wire_version_is_served(&request, platform_version)?; + let wire_query = DocumentWireQuery::try_from_request(request)?; + + let contract: Arc = provider + .get_data_contract(&wire_query.data_contract_id, platform_version)? + .ok_or_else(|| { + request_error(format!( + "context provider has no data contract {}", + wire_query.data_contract_id + )) + })?; + + // The block time is read off the response *before* verification only + // to resolve time-range buckets; it is bound by the quorum signature + // checked inside the delegated `FromProof`, so a lie fails there. + let block_time_ms = response.metadata().ok().map(|mtd| mtd.time_ms); + let drive_query = wire_query.to_drive_query(&contract, block_time_ms, platform_version)?; + + let (documents, mtd, proof) = + >::maybe_from_proof_with_metadata( + drive_query, + response, + network, + platform_version, + provider, + )?; + Ok((documents.map(RequestedDocuments), mtd, proof)) + } +} diff --git a/packages/rs-drive-proof-verifier/tests/vectors_document_request.rs b/packages/rs-drive-proof-verifier/tests/vectors_document_request.rs new file mode 100644 index 00000000000..767663932c4 --- /dev/null +++ b/packages/rs-drive-proof-verifier/tests/vectors_document_request.rs @@ -0,0 +1,472 @@ +//! Request-driven document verification (`FromProof`), +//! replayed against the proof-vector corpus. +//! +//! The documents fixtures store placeholder payloads at the document +//! positions, so (as in `vectors_documents.rs`) the positive half pins that +//! the wire request decodes into exactly the `DriveDocumentQuery` the fixture +//! proof was generated for: the grovedb layer verifies to the pinned root +//! hash, and `FromProof` then fails cleanly at document decode. The negative +//! half pins the request-shape gates: shapes an honest server would never +//! have answered with a plain proved document set are refused before any +//! proof machinery runs. + +#![cfg(feature = "mocks")] + +mod common; + +use common::{identifier, load_case, Case, Expected, RequestSpec, NETWORK}; +use dapi_grpc::platform::v0::get_documents_request::get_documents_request_v1::{ + select, Select as ProtoSelect, +}; +use dapi_grpc::platform::v0::get_documents_request::{ + document_field_value, get_documents_request_v1::Start as V1Start, DocumentFieldValue, + GetDocumentsRequestV0, GetDocumentsRequestV1, OrderClause as ProtoOrderClause, Version, + WhereClause as ProtoWhereClause, WhereOperator as ProtoWhereOperator, +}; +use dapi_grpc::platform::v0::{self as platform, get_documents_response, GetDocumentsRequest}; +use dpp::data_contract::accessors::v0::DataContractV0Getters; +use dpp::platform_value::Value; +use dpp::system_data_contracts::{load_system_data_contract, SystemDataContract}; +use drive_proof_verifier::{DocumentWireQuery, Error, FromProof, RequestedDocuments}; + +fn text(value: &str) -> DocumentFieldValue { + DocumentFieldValue { + variant: Some(document_field_value::Variant::Text(value.to_string())), + } +} + +fn identifier_value(hex: &str) -> DocumentFieldValue { + DocumentFieldValue { + variant: Some(document_field_value::Variant::BytesValue( + identifier(hex).to_vec(), + )), + } +} + +fn equal(field: &str, value: DocumentFieldValue) -> ProtoWhereClause { + ProtoWhereClause { + field: field.to_string(), + operator: ProtoWhereOperator::Equal as i32, + value: Some(value), + time_range: None, + } +} + +fn asc(field: &str) -> ProtoOrderClause { + ProtoOrderClause { + target: Some( + dapi_grpc::platform::v0::get_documents_request::order_clause::Target::Field( + field.to_string(), + ), + ), + ascending: true, + } +} + +/// The v1 wire request the Dash Core transport sends for each fixture case +/// (same shapes `vectors_documents.rs` pins as hand-built drive queries). +fn v1_request(case: &Case, contract_id: Vec) -> GetDocumentsRequest { + let (document_type, where_clauses, order_by, limit) = match &case.manifest.request { + RequestSpec::DocumentsDpnsExact { + normalized_label, + limit, + } => ( + "domain", + vec![ + equal("normalizedParentDomainName", text("dash")), + equal("normalizedLabel", text(normalized_label)), + ], + vec![], + *limit, + ), + RequestSpec::DocumentsDpnsPrefix { + normalized_prefix, + limit, + } => ( + "domain", + vec![ + equal("normalizedParentDomainName", text("dash")), + ProtoWhereClause { + field: "normalizedLabel".to_string(), + operator: ProtoWhereOperator::StartsWith as i32, + value: Some(text(normalized_prefix)), + time_range: None, + }, + ], + vec![asc("normalizedLabel")], + *limit, + ), + RequestSpec::DocumentsDashpayProfile { owner_id } => ( + "profile", + vec![equal("$ownerId", identifier_value(owner_id))], + vec![], + 1, + ), + RequestSpec::DocumentsDashpayContacts { + identity_id, + to_identity, + limit, + } => ( + "contactRequest", + vec![equal( + if *to_identity { "toUserId" } else { "$ownerId" }, + identifier_value(identity_id), + )], + vec![asc("$createdAt")], + *limit, + ), + _ => panic!("{}: not a documents request", case.name), + }; + GetDocumentsRequest { + version: Some(Version::V1(GetDocumentsRequestV1 { + data_contract_id: contract_id, + document_type: document_type.to_string(), + where_clauses, + order_by, + limit: Some(u32::from(limit)), + start: None, + prove: true, + selects: vec![], + group_by: vec![], + having: vec![], + offset: None, + chained: None, + ..Default::default() + })), + } +} + +fn contract_for(case: &Case) -> SystemDataContract { + match &case.manifest.request { + RequestSpec::DocumentsDpnsExact { .. } | RequestSpec::DocumentsDpnsPrefix { .. } => { + SystemDataContract::DPNS + } + RequestSpec::DocumentsDashpayProfile { .. } + | RequestSpec::DocumentsDashpayContacts { .. } => SystemDataContract::Dashpay, + _ => panic!("{}: not a documents request", case.name), + } +} + +fn response(case: &Case) -> platform::GetDocumentsResponse { + platform::GetDocumentsResponse { + version: Some(get_documents_response::Version::V1( + get_documents_response::GetDocumentsResponseV1 { + metadata: Some(case.metadata()), + result: Some( + get_documents_response::get_documents_response_v1::Result::Proof( + case.grpc_proof(), + ), + ), + }, + )), + } +} + +fn run_case(name: &str) { + let case = load_case(name); + let Expected::DocumentsPlaceholder { .. } = &case.manifest.expected else { + panic!("{name}: manifest expectation mismatch"); + }; + let platform_version = case.platform_version(); + let contract = load_system_data_contract(contract_for(&case), platform_version) + .expect("load system data contract"); + let request = v1_request(&case, contract.id().to_vec()); + + // The decoded request must lower to the query the fixture proof was made + // for: the grovedb layer verifies to the pinned root hash. + let wire_query = >::try_from_request(request.clone()) + .expect("decode wire request"); + let drive_query = wire_query + .to_drive_query(&contract, None, platform_version) + .expect("lower to drive query"); + let (root_hash, _) = drive_query + .verify_proof_keep_serialized(&case.grovedb_proof, platform_version) + .unwrap_or_else(|e| panic!("{name}: grovedb layer must verify: {e}")); + assert_eq!( + hex::encode(root_hash), + case.manifest + .expected_root_hash_hex + .as_deref() + .expect("documents cases pin a root hash"), + "{name}: root hash" + ); + + // Through FromProof the placeholder payload fails document decode cleanly. + let error = RequestedDocuments::maybe_from_proof_with_metadata( + request, + response(&case), + NETWORK, + platform_version, + &case.provider(), + ) + .expect_err("placeholder document payload must fail to decode"); + assert!( + matches!( + error, + Error::DriveError { .. } | Error::ProtocolError { .. } + ), + "{name}: expected a document decode error, got: {error:?}" + ); +} + +#[test] +fn dpns_domain_exact_from_wire_request() { + run_case("dpns-domain-exact"); +} + +#[test] +fn dpns_domain_prefix_from_wire_request() { + run_case("dpns-domain-prefix"); +} + +#[test] +fn dashpay_profile_from_wire_request() { + run_case("dashpay-profile"); +} + +#[test] +fn dashpay_contacts_incoming_from_wire_request() { + run_case("dashpay-contacts-incoming"); +} + +/// A v0 request carrying the same query as CBOR decodes to the same drive +/// query, so both wire generations verify against one proof. +#[test] +fn v0_cbor_request_lowers_to_the_same_query() { + let case = load_case("dpns-domain-exact"); + let platform_version = case.platform_version(); + let contract = + load_system_data_contract(SystemDataContract::DPNS, platform_version).expect("dpns"); + let RequestSpec::DocumentsDpnsExact { + normalized_label, + limit, + } = &case.manifest.request + else { + panic!("expected dpns exact"); + }; + let where_value = Value::Array(vec![ + Value::Array(vec![ + Value::Text("normalizedParentDomainName".into()), + Value::Text("==".into()), + Value::Text("dash".into()), + ]), + Value::Array(vec![ + Value::Text("normalizedLabel".into()), + Value::Text("==".into()), + Value::Text(normalized_label.clone()), + ]), + ]); + let request = GetDocumentsRequest { + version: Some(Version::V0(GetDocumentsRequestV0 { + data_contract_id: contract.id().to_vec(), + document_type: "domain".to_string(), + r#where: { + let mut bytes = Vec::new(); + ciborium::ser::into_writer(&where_value, &mut bytes).expect("cbor"); + bytes + }, + order_by: vec![], + limit: u32::from(*limit), + prove: true, + start: None, + })), + }; + let wire_query = >::try_from_request(request) + .expect("decode v0 request"); + let drive_query = wire_query + .to_drive_query(&contract, None, platform_version) + .expect("lower"); + let (root_hash, _) = drive_query + .verify_proof_keep_serialized(&case.grovedb_proof, platform_version) + .expect("grovedb layer must verify"); + assert_eq!( + hex::encode(root_hash), + case.manifest.expected_root_hash_hex.as_deref().unwrap() + ); +} + +fn v1(mutate: impl FnOnce(&mut GetDocumentsRequestV1)) -> GetDocumentsRequest { + let case = load_case("dpns-domain-exact"); + let contract = + load_system_data_contract(SystemDataContract::DPNS, case.platform_version()).expect("dpns"); + let mut request = v1_request(&case, contract.id().to_vec()); + let Some(Version::V1(inner)) = request.version.as_mut() else { + unreachable!() + }; + mutate(inner); + request +} + +fn assert_refused(request: GetDocumentsRequest, needle: &str) { + let case = load_case("dpns-domain-exact"); + let error = RequestedDocuments::maybe_from_proof_with_metadata( + request, + response(&case), + NETWORK, + case.platform_version(), + &case.provider(), + ) + .expect_err("request shape must be refused"); + match &error { + Error::RequestError { error } => assert!( + error.contains(needle), + "expected a rejection mentioning {needle:?}, got: {error}" + ), + other => panic!("expected RequestError, got {other:?}"), + } +} + +#[test] +fn refuses_unproved_request() { + assert_refused(v1(|r| r.prove = false), "prove=false"); +} + +#[test] +fn refuses_aggregate_projection() { + assert_refused( + v1(|r| { + r.selects = vec![ProtoSelect { + function: select::Function::Count as i32, + field: String::new(), + }] + }), + "aggregate projection", + ); +} + +#[test] +fn refuses_group_by_having_and_offset() { + assert_refused(v1(|r| r.group_by = vec!["label".into()]), "GROUP BY"); + assert_refused( + v1(|r| { + r.having = vec![ + dapi_grpc::platform::v0::get_documents_request::HavingClause { + aggregate: None, + operator: 0, + right: None, + }, + ] + }), + "HAVING", + ); + assert_refused(v1(|r| r.offset = Some(3)), "OFFSET"); +} + +#[test] +fn refuses_limit_above_server_cap() { + let case = load_case("dpns-domain-exact"); + let error = RequestedDocuments::maybe_from_proof_with_metadata( + v1(|r| r.limit = Some(101)), + response(&case), + NETWORK, + case.platform_version(), + &case.provider(), + ) + .expect_err("limit above the server cap must be refused"); + assert!( + matches!(error, Error::DriveError { .. }), + "expected the server's InvalidLimit as a drive error, got {error:?}" + ); +} + +#[test] +fn refuses_contract_the_provider_does_not_know() { + assert_refused( + v1(|r| r.data_contract_id = vec![9u8; 32]), + "no data contract", + ); +} + +#[test] +fn refuses_v1_limit_zero_but_normalises_v0_zero_to_default() { + assert_refused(v1(|r| r.limit = Some(0)), "limit = 0"); + + let case = load_case("dpns-domain-exact"); + let contract = + load_system_data_contract(SystemDataContract::DPNS, case.platform_version()).expect("dpns"); + let request = GetDocumentsRequest { + version: Some(Version::V0(GetDocumentsRequestV0 { + data_contract_id: contract.id().to_vec(), + document_type: "domain".to_string(), + r#where: vec![], + order_by: vec![], + limit: 0, + prove: true, + start: None, + })), + }; + let wire_query = >::try_from_request(request) + .expect("v0 decodes"); + assert_eq!(wire_query.limit, None, "v0 limit 0 is the unset sentinel"); + let drive_query = wire_query + .to_drive_query(&contract, None, case.platform_version()) + .expect("lower"); + assert_eq!( + drive_query.limit, + Some(100), + "unset lowers to the server default" + ); +} + +#[test] +fn refuses_chained_requests() { + assert_refused( + v1(|r| { + r.chained = Some(Default::default()); + }), + "chained", + ); +} + +#[test] +fn cursors_keep_the_server_inclusion_semantics() { + let case = load_case("dpns-domain-exact"); + let contract = + load_system_data_contract(SystemDataContract::DPNS, case.platform_version()).expect("dpns"); + let decode = |request: GetDocumentsRequest| { + >::try_from_request(request) + }; + let none = decode(v1(|_| {})).expect("no cursor"); + assert_eq!((none.start_at, none.start_at_included), (None, true)); + let at = decode(v1(|r| r.start = Some(V1Start::StartAt(vec![7u8; 32])))).expect("start_at"); + assert_eq!((at.start_at, at.start_at_included), (Some([7u8; 32]), true)); + let after = + decode(v1(|r| r.start = Some(V1Start::StartAfter(vec![8u8; 32])))).expect("start_after"); + assert_eq!( + (after.start_at, after.start_at_included), + (Some([8u8; 32]), false) + ); + decode(v1(|r| r.start = Some(V1Start::StartAt(vec![1u8; 31])))) + .expect_err("a cursor must be 32 bytes"); + let _ = contract; +} + +#[test] +fn refuses_wire_versions_the_platform_version_does_not_serve() { + // Protocol version 1 predates the v1 getDocuments wire entirely. + let case = load_case("dpns-domain-exact"); + let contract = + load_system_data_contract(SystemDataContract::DPNS, case.platform_version()).expect("dpns"); + let request = v1_request(&case, contract.id().to_vec()); + let old = dpp::version::PlatformVersion::get(1).expect("protocol version 1"); + let error = RequestedDocuments::maybe_from_proof_with_metadata( + request, + response(&case), + NETWORK, + old, + &case.provider(), + ) + .expect_err("v1 wire is not served at protocol version 1"); + match &error { + Error::RequestError { error } => assert!(error.contains("wire version"), "{error}"), + other => panic!("expected RequestError, got {other:?}"), + } +} diff --git a/packages/rs-sdk/src/platform/dashpay/contact_request.rs b/packages/rs-sdk/src/platform/dashpay/contact_request.rs index adf48b41150..bc26966e577 100644 --- a/packages/rs-sdk/src/platform/dashpay/contact_request.rs +++ b/packages/rs-sdk/src/platform/dashpay/contact_request.rs @@ -5,23 +5,23 @@ use crate::platform::transition::put_document::PutDocument; use crate::platform::Document; use crate::{Error, Sdk}; +use dash_platform_queries::dashpay::{ + build_contact_request_document, ContactRequestDocumentParams, +}; use dpp::dashcore::secp256k1::rand::rngs::StdRng; use dpp::dashcore::secp256k1::rand::{RngCore, SeedableRng}; use dpp::dashcore::secp256k1::{PublicKey, SecretKey}; use dpp::data_contract::accessors::v0::DataContractV0Getters; -use dpp::data_contract::document_type::accessors::DocumentTypeV0Getters; -use dpp::document::DocumentV0; use dpp::identity::accessors::IdentityGettersV0; use dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; use dpp::identity::identity_public_key::Purpose; use dpp::identity::signer::Signer; use dpp::identity::{Identity, IdentityPublicKey}; -use dpp::platform_value::{Bytes32, Value}; +use dpp::platform_value::Bytes32; use dpp::prelude::Identifier; use platform_encryption::{ derive_shared_key_ecdh, encrypt_account_label, encrypt_extended_public_key, COMPACT_XPUB_LEN, }; -use std::collections::BTreeMap; /// ECDH provider for contact request encryption /// @@ -105,13 +105,9 @@ pub struct ContactRequestInput { /// Result of creating a contact request document #[derive(Debug)] pub struct ContactRequestResult { - /// The document ID - pub id: Identifier, - /// The owner ID (sender identity ID) - pub owner_id: Identifier, - /// The document properties - pub properties: BTreeMap, - /// The entropy used to derive `id`. + /// The assembled `contactRequest` document (not yet broadcast). + pub document: Document, + /// The entropy used to derive the document id. /// /// This must be reused when broadcasting the document so that the /// document id computed at creation matches the id platform consensus @@ -259,16 +255,6 @@ impl Sdk { H: FnOnce(u32) -> Hut, Hut: std::future::Future, Error>>, { - // Validate auto accept proof size if provided - if let Some(ref proof) = input.auto_accept_proof { - if proof.len() < 38 || proof.len() > 102 { - return Err(Error::Generic(format!( - "autoAcceptProof must be 38-102 bytes, got {}", - proof.len() - ))); - } - } - // Fetch recipient identity if only ID was provided let recipient_identity = match input.recipient { RecipientIdentity::Identity(identity) => identity, @@ -366,27 +352,12 @@ impl Sdk { let encrypted_public_key = encrypt_extended_public_key(&shared_key, &xpub_iv, &extended_public_key); - // Validate encrypted public key size (must be exactly 96 bytes: 16-byte IV + 80-byte encrypted data) - if encrypted_public_key.len() != 96 { - return Err(Error::Generic(format!( - "Encrypted public key size mismatch: expected 96 bytes, got {}", - encrypted_public_key.len() - ))); - } - // Encrypt the account label if provided (includes IV prepended) let encrypted_account_label = if let Some(ref label) = input.account_label { let mut label_iv = [0u8; 16]; rng.fill_bytes(&mut label_iv); let encrypted = encrypt_account_label(&shared_key, &label_iv, label); - // Validate encrypted label size (48-80 bytes: 16-byte IV + 32-64 byte encrypted data) - if encrypted.len() < 48 || encrypted.len() > 80 { - return Err(Error::Generic(format!( - "Encrypted account label size out of range: expected 48-80 bytes, got {}", - encrypted.len() - ))); - } Some(encrypted) } else { None @@ -395,66 +366,28 @@ impl Sdk { // Fetch DashPay contract let dashpay_contract = self.fetch_dashpay_contract().await?; - // Get contactRequest document type - let contact_request_document_type = dashpay_contract - .document_type_for_name("contactRequest") - .map_err(|_| { - Error::Generic("DashPay contactRequest document type not found".to_string()) - })?; - // Generate entropy for document ID let mut rng = StdRng::from_entropy(); let entropy = Bytes32::random_with_rng(&mut rng); - // Generate document ID - let sender_id = input.sender_identity.id().to_owned(); - let document_id = Document::generate_document_id_v0( - &dashpay_contract.id(), - &sender_id, - contact_request_document_type.name(), - entropy.as_slice(), - ); - - // Build document properties - let mut properties = BTreeMap::new(); - let recipient_id = recipient_identity.id().to_owned(); - properties.insert( - "toUserId".to_string(), - Value::Identifier(recipient_id.to_buffer()), - ); - properties.insert( - "encryptedPublicKey".to_string(), - Value::Bytes(encrypted_public_key), - ); - properties.insert( - "senderKeyIndex".to_string(), - Value::U32(input.sender_key_index), - ); - properties.insert( - "recipientKeyIndex".to_string(), - Value::U32(input.recipient_key_index), - ); - properties.insert( - "accountReference".to_string(), - Value::U32(input.account_reference), - ); - - // Add optional fields - if let Some(label) = encrypted_account_label { - properties.insert("encryptedAccountLabel".to_string(), Value::Bytes(label)); - } - if let Some(proof) = input.auto_accept_proof { - properties.insert("autoAcceptProof".to_string(), Value::Bytes(proof)); - } - - // Return the essential fields for the contact request, including the - // entropy that derived `document_id` so the broadcast path can reuse it. - Ok(ContactRequestResult { - id: document_id, - owner_id: sender_id, - properties, - entropy, - }) + let document = build_contact_request_document( + &dashpay_contract, + ContactRequestDocumentParams { + sender_id: input.sender_identity.id().to_owned(), + recipient_id: recipient_identity.id().to_owned(), + sender_key_index: input.sender_key_index, + recipient_key_index: input.recipient_key_index, + account_reference: input.account_reference, + encrypted_public_key, + encrypted_account_label, + auto_accept_proof: input.auto_accept_proof, + entropy: entropy.0, + }, + )?; + + // Return the assembled document together with the entropy that + // derived its id so the broadcast path can reuse it. + Ok(ContactRequestResult { document, entropy }) } /// Send a contact request to the platform @@ -516,30 +449,10 @@ impl Sdk { Error::Generic("DashPay contactRequest document type not found".to_string()) })?; - // Reuse the entropy that derived result.id during creation. Platform - // consensus recomputes the document id from this entropy and rejects the - // create transition unless it matches result.id, so a freshly generated - // entropy here would always be rejected (InvalidDocumentTransitionIdError). + // Reuse the entropy that derived the document id during creation: + // consensus recomputes the id from it and rejects a mismatch. let entropy = result.entropy; - - // Create the document from the result - let document = Document::V0(DocumentV0 { - contract_version: None, - id: result.id, - owner_id: result.owner_id, - properties: result.properties, - revision: None, - created_at: None, - updated_at: None, - transferred_at: None, - created_at_block_height: None, - updated_at_block_height: None, - transferred_at_block_height: None, - created_at_core_block_height: None, - updated_at_core_block_height: None, - transferred_at_core_block_height: None, - creator_id: None, - }); + let document = result.document; // Submit the document to the platform let platform_document = document @@ -634,47 +547,48 @@ mod tests { #[test] fn contact_request_result_entropy_derives_returned_id() { - // Regression for G2 entropy mismatch: the document id returned by - // create_contact_request must be derivable from the entropy carried in - // ContactRequestResult. send_contact_request reuses ContactRequestResult::entropy - // when broadcasting, and platform consensus rejects the create transition - // (InvalidDocumentTransitionIdError) unless - // generate_document_id_v0(contract, owner, "contactRequest", entropy) == base.id. - // - // Without the `entropy` field on ContactRequestResult, - // send_contact_request would generate fresh entropy E2 != E1 and this - // invariant could not even be expressed. This test pins it. + // send_contact_request reuses ContactRequestResult::entropy when + // broadcasting; consensus recomputes the document id from it and + // rejects the create transition on mismatch. Pin that the shared + // builder derives the document id from exactly that entropy. + use dpp::document::DocumentV0Getters; + use dpp::system_data_contracts::{load_system_data_contract, SystemDataContract}; + use dpp::version::PlatformVersion; + let mut rng = StdRng::seed_from_u64(0x6732_4732); // deterministic, no network let entropy = Bytes32::random_with_rng(&mut rng); - - let contract_id = Identifier::from([1u8; 32]); + let contract = + load_system_data_contract(SystemDataContract::Dashpay, PlatformVersion::latest()) + .expect("dashpay contract"); let owner_id = Identifier::from([2u8; 32]); - let id = Document::generate_document_id_v0( - &contract_id, - &owner_id, - "contactRequest", - entropy.as_slice(), - ); - - let result = ContactRequestResult { - id, - owner_id, - properties: BTreeMap::new(), - entropy, - }; + let document = build_contact_request_document( + &contract, + ContactRequestDocumentParams { + sender_id: owner_id, + recipient_id: Identifier::from([3u8; 32]), + sender_key_index: 0, + recipient_key_index: 0, + account_reference: 0, + encrypted_public_key: vec![0u8; 96], + encrypted_account_label: None, + auto_accept_proof: None, + entropy: entropy.0, + }, + ) + .expect("assemble contact request"); + let result = ContactRequestResult { document, entropy }; - // The entropy that send_contact_request will broadcast must regenerate the - // exact id that was returned at creation time. let regenerated = Document::generate_document_id_v0( - &contract_id, - &result.owner_id, + &contract.id(), + &result.document.owner_id(), "contactRequest", result.entropy.as_slice(), ); assert_eq!( - regenerated, result.id, - "entropy carried in ContactRequestResult must derive the returned document id" + regenerated, + result.document.id(), + "entropy carried in ContactRequestResult must derive the document id" ); } diff --git a/packages/rs-sdk/src/platform/dashpay/mod.rs b/packages/rs-sdk/src/platform/dashpay/mod.rs index 182edd8854b..0a771e3ed63 100644 --- a/packages/rs-sdk/src/platform/dashpay/mod.rs +++ b/packages/rs-sdk/src/platform/dashpay/mod.rs @@ -12,6 +12,9 @@ pub use contact_request::{ EcdhProvider, RecipientIdentity, SendContactRequestInput, SendContactRequestResult, }; pub use contact_request_queries::ContactRequestDocuments; +pub use dash_platform_queries::dashpay::{ + build_contact_request_document, ContactRequestDocumentParams, +}; use crate::platform::Fetch; use crate::{Error, Sdk}; diff --git a/packages/rs-sdk/src/platform/dpns_usernames/mod.rs b/packages/rs-sdk/src/platform/dpns_usernames/mod.rs index 0955ea24454..a6d3c83576d 100644 --- a/packages/rs-sdk/src/platform/dpns_usernames/mod.rs +++ b/packages/rs-sdk/src/platform/dpns_usernames/mod.rs @@ -3,7 +3,8 @@ mod queries; pub use contested_queries::ContestedDpnsUsername; pub use dash_platform_queries::dpns_usernames::{ - convert_to_homograph_safe_chars, is_contested_username, is_valid_username, + build_dpns_domain_document, build_dpns_preorder_document, convert_to_homograph_safe_chars, + is_contested_username, is_valid_username, salted_domain_hash, }; pub use queries::DpnsUsername; @@ -14,14 +15,12 @@ use dash_context_provider::ContextProvider; use dpp::dashcore::secp256k1::rand::rngs::StdRng; use dpp::dashcore::secp256k1::rand::{Rng, SeedableRng}; use dpp::data_contract::accessors::v0::DataContractV0Getters; -use dpp::data_contract::document_type::accessors::DocumentTypeV0Getters; -use dpp::document::{DocumentV0, DocumentV0Getters}; +use dpp::document::DocumentV0Getters; use dpp::identity::accessors::IdentityGettersV0; use dpp::identity::signer::Signer; use dpp::identity::{Identity, IdentityPublicKey}; use dpp::platform_value::{Bytes32, Value}; use dpp::prelude::Identifier; -use std::collections::BTreeMap; use std::sync::Arc; fn extract_dpns_label(name: &str) -> &str { @@ -45,14 +44,6 @@ fn normalize_dpns_label(input: &str) -> String { convert_to_homograph_safe_chars(extract_dpns_label(input)) } -/// Hash a buffer twice using SHA256 (double SHA256) -fn hash_double(data: Vec) -> [u8; 32] { - use dpp::dashcore::hashes::{sha256d, Hash}; - // sha256d already does double SHA256 - let hash = sha256d::Hash::hash(&data); - hash.to_byte_array() -} - /// Callback type for preorder document pub type PreorderCallback = Box; @@ -164,97 +155,17 @@ impl Sdk { let entropy = Bytes32::random_with_rng(&mut rng); let salt: [u8; 32] = rng.gen(); - // Generate document IDs let identity_id = input.identity.id().to_owned(); - let preorder_id = Document::generate_document_id_v0( - &dpns_contract.id(), - &identity_id, - preorder_document_type.name(), - entropy.as_slice(), - ); - let domain_id = Document::generate_document_id_v0( - &dpns_contract.id(), - &identity_id, - domain_document_type.name(), - entropy.as_slice(), - ); - - // Create salted domain hash for preorder + let preorder_document = build_dpns_preorder_document( + &dpns_contract, + identity_id, + &input.label, + salt, + entropy.0, + )?; + let domain_document = + build_dpns_domain_document(&dpns_contract, identity_id, &input.label, salt, entropy.0)?; let normalized_label = convert_to_homograph_safe_chars(&input.label); - let mut salted_domain_buffer: Vec = vec![]; - salted_domain_buffer.extend(salt); - salted_domain_buffer.extend((normalized_label.clone() + ".dash").as_bytes()); - let salted_domain_hash = hash_double(salted_domain_buffer); - - // Create preorder document - let preorder_document = Document::V0(DocumentV0 { - contract_version: None, - id: preorder_id, - owner_id: identity_id, - properties: BTreeMap::from([( - "saltedDomainHash".to_string(), - Value::Bytes32(salted_domain_hash), - )]), - revision: None, - created_at: None, - updated_at: None, - transferred_at: None, - created_at_block_height: None, - updated_at_block_height: None, - transferred_at_block_height: None, - created_at_core_block_height: None, - updated_at_core_block_height: None, - transferred_at_core_block_height: None, - creator_id: None, - }); - - // Create domain document - let domain_document = Document::V0(DocumentV0 { - contract_version: None, - id: domain_id, - owner_id: identity_id, - properties: BTreeMap::from([ - ( - "parentDomainName".to_string(), - Value::Text("dash".to_string()), - ), - ( - "normalizedParentDomainName".to_string(), - Value::Text("dash".to_string()), - ), - ("label".to_string(), Value::Text(input.label.clone())), - ( - "normalizedLabel".to_string(), - Value::Text(normalized_label.clone()), - ), - ("preorderSalt".to_string(), Value::Bytes32(salt)), - ( - "records".to_string(), - Value::Map(vec![( - Value::Text("identity".to_string()), - Value::Identifier(identity_id.to_buffer()), - )]), - ), - ( - "subdomainRules".to_string(), - Value::Map(vec![( - Value::Text("allowSubdomains".to_string()), - Value::Bool(false), - )]), - ), - ]), - revision: None, - created_at: None, - updated_at: None, - transferred_at: None, - created_at_block_height: None, - updated_at_block_height: None, - transferred_at_block_height: None, - created_at_core_block_height: None, - updated_at_core_block_height: None, - transferred_at_core_block_height: None, - creator_id: None, - }); // Submit preorder document first let platform_preorder_document = preorder_document diff --git a/packages/rs-sdk/src/platform/transition/put_document.rs b/packages/rs-sdk/src/platform/transition/put_document.rs index fa85a30a0dc..e932c0a5a4c 100644 --- a/packages/rs-sdk/src/platform/transition/put_document.rs +++ b/packages/rs-sdk/src/platform/transition/put_document.rs @@ -11,7 +11,6 @@ use dpp::data_contract::document_type::DocumentType; use dpp::document::{Document, DocumentV0Getters, DocumentV0Setters, INITIAL_REVISION}; use dpp::identity::signer::Signer; use dpp::identity::IdentityPublicKey; -use dpp::prelude::Identifier; use dpp::state_transition::batch_transition::methods::v0::DocumentsBatchTransitionMethodsV0; use dpp::state_transition::batch_transition::BatchTransition; use dpp::state_transition::StateTransition; @@ -88,21 +87,10 @@ impl> PutDocument for Document { } else { let (document, document_state_transition_entropy) = match document_state_transition_entropy { - Some(entropy) => { - // A caller-supplied entropy must derive the document's own id. - // Platform consensus recomputes generate_document_id_v0 from the - // transition entropy and rejects the create with - // InvalidDocumentTransitionIdError on mismatch, so guard here - // before broadcasting to fail locally (no wasted nonce/fee). - ensure_entropy_matches_document_id( - &document_type.data_contract_id(), - &document.owner_id(), - document_type.name(), - &entropy, - document.id(), - )?; - (document, entropy) - } + // A caller-supplied entropy must derive the document's own id; + // dpp's DocumentCreateTransition::from_document refuses a + // mismatch locally, before a nonce is bumped. + Some(entropy) => (document, entropy), None => { let mut rng = StdRng::from_entropy(); let mut document = document; @@ -171,45 +159,13 @@ fn prepare_document_for_transition(document: &Document, document_type: &Document document } -/// Ensures a caller-supplied `entropy` derives the same document id already set -/// on a create document. -/// -/// A document-create state transition carries both the document id and the -/// entropy, and Drive recomputes the id from the entropy during -/// `advanced_structure` validation, rejecting the transition with -/// `InvalidDocumentTransitionIdError` when they disagree. Because -/// [`PutDocument::put_to_platform`] trusts the caller's id verbatim in the -/// `Some(entropy)` arm, a two-phase caller whose id and entropy have drifted -/// would only discover the mismatch after paying (a bumped identity-contract -/// nonce). This check surfaces the mismatch locally before broadcasting. -fn ensure_entropy_matches_document_id( - contract_id: &Identifier, - owner_id: &Identifier, - document_type_name: &str, - entropy: &[u8; 32], - document_id: Identifier, -) -> Result<(), Error> { - let expected_id = Document::generate_document_id_v0( - contract_id, - owner_id, - document_type_name, - entropy.as_slice(), - ); - if expected_id != document_id { - return Err(Error::Generic(format!( - "document id {document_id} does not match the id {expected_id} derived from the \ - supplied entropy; the entropy must be the one used to generate the document id" - ))); - } - Ok(()) -} - #[cfg(test)] mod tests { use super::*; use dpp::data_contract::config::DataContractConfig; use dpp::document::DocumentV0; use dpp::platform_value::{platform_value, Value}; + use dpp::prelude::Identifier; use dpp::version::PlatformVersion; use std::collections::BTreeMap; @@ -221,53 +177,6 @@ mod tests { Identifier::from([2u8; 32]) } - #[test] - fn matching_entropy_and_id_pass() { - let entropy = [7u8; 32]; - let id = Document::generate_document_id_v0( - &contract_id(), - &owner_id(), - "contactRequest", - entropy.as_slice(), - ); - - ensure_entropy_matches_document_id( - &contract_id(), - &owner_id(), - "contactRequest", - &entropy, - id, - ) - .expect("id derived from the supplied entropy must be accepted"); - } - - #[test] - fn mismatched_entropy_and_id_error_before_broadcast() { - // The id was derived from E1, but the caller passes E2 != E1 (mirroring - // the very drift consensus rejects with InvalidDocumentTransitionIdError). - let entropy_used = [1u8; 32]; - let id = Document::generate_document_id_v0( - &contract_id(), - &owner_id(), - "contactRequest", - entropy_used.as_slice(), - ); - - let different_entropy = [2u8; 32]; - let result = ensure_entropy_matches_document_id( - &contract_id(), - &owner_id(), - "contactRequest", - &different_entropy, - id, - ); - - assert!( - matches!(result, Err(Error::Generic(_))), - "a document id derived from a different entropy must be rejected locally" - ); - } - #[test] fn should_normalize_wasm_uint8_array_property_without_mutating_caller_document() { let platform_version = PlatformVersion::latest();