diff --git a/Cargo.lock b/Cargo.lock index 0a5f13e214..92c257cfa0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1706,6 +1706,7 @@ dependencies = [ name = "dash-platform-queries" version = "4.2.0-dev.1" dependencies = [ + "ciborium", "dapi-grpc", "dash-context-provider", "dash-platform-macros", @@ -2232,6 +2233,7 @@ dependencies = [ "console-subscriber", "dapi-grpc", "dash-platform-macros", + "dash-platform-queries", "delegate", "derive_more 1.0.0", "dotenvy", diff --git a/packages/dash-platform-queries/Cargo.toml b/packages/dash-platform-queries/Cargo.toml index f3d76e7c74..7c9870a739 100644 --- a/packages/dash-platform-queries/Cargo.toml +++ b/packages/dash-platform-queries/Cargo.toml @@ -17,6 +17,7 @@ mocks = [ ] [dependencies] +ciborium = { version = "0.2.2" } dapi-grpc = { path = "../dapi-grpc", default-features = false, features = [ "platform", "client", diff --git a/packages/dash-platform-queries/README.md b/packages/dash-platform-queries/README.md index a40be49698..ca6853c280 100644 --- a/packages/dash-platform-queries/README.md +++ b/packages/dash-platform-queries/README.md @@ -26,17 +26,19 @@ If you want networking, retries, and a managed connection pool, use ## What's here -- [`documents::DocumentQuery`] — rich document query builder with wire - encoding for both request versions. +- [`documents::DocumentQuery`] — rich document query builder, wire + encoding for both request versions, and decoding **from** the wire request + (`DocumentQuery::try_from_request`) using the same proto conversions the + server (`drive-abci`) uses, so client and server cannot drift. +- `documents::verify_documents_response` — request-driven proof verification + for document queries, delegating to `drive-proof-verifier`'s `FromProof`. - Aggregate proof helpers (count/sum/average/ranked) shared with `dash-sdk`. -- DPNS username helpers — label normalization/validation and the - convertibility/contested checks shared with `dash-sdk`. -- `transition::validation` — structural validation for state transitions - ahead of signing. - -Wire-request decoding (`DocumentQuery::try_from_request`), request-driven -proof verification, and pure DPNS/DashPay document builders arrive in the -next slice of this series. +- Pure DPNS builders — `build_dpns_preorder_and_domain_documents`, label + normalization/validation — and pure DashPay contact-request document + assembly (`dashpay::build_contact_request_document`); crypto material is + supplied by the caller, keys never enter this crate. +- `transition::validation` and document-transition helpers + (`ensure_entropy_matches_document_id`, `prepare_document_for_transition`). ## Feature flags diff --git a/packages/dash-platform-queries/src/dashpay.rs b/packages/dash-platform-queries/src/dashpay.rs new file mode 100644 index 0000000000..6690237830 --- /dev/null +++ b/packages/dash-platform-queries/src/dashpay.rs @@ -0,0 +1,294 @@ +//! Transport-free DashPay contact request document assembly. +//! +//! The Sdk-bound DashPay surface (recipient fetching, ECDH, encryption, +//! broadcasting) lives in `dash-sdk`; this module is the pure DIP-15 +//! `contactRequest` document assembly it shares with embedders. All crypto +//! material arrives here as bytes — key derivation and encryption stay with +//! the caller. + +use crate::Error; +use dpp::data_contract::accessors::v0::DataContractV0Getters; +use dpp::data_contract::document_type::accessors::DocumentTypeV0Getters; +use dpp::data_contract::DataContract; +use dpp::document::Document; +use dpp::platform_value::Value; +use dpp::prelude::Identifier; +use std::collections::BTreeMap; + +/// Already-derived crypto material and metadata for a DIP-15 +/// `contactRequest` document. +/// +/// Everything here is plain data: the ECDH/encryption that produced +/// `encrypted_public_key` and `encrypted_account_label`, and the randomness +/// that produced `entropy`, happen in the caller (`dash-sdk` or an +/// embedder). +#[derive(Debug, Clone)] +pub struct ContactRequestDocumentParams { + /// The sender's identity id (the document owner) + pub sender_id: Identifier, + /// The recipient's identity id (`toUserId`) + pub recipient_id: Identifier, + /// The sender's encryption key index used for ECDH + pub sender_key_index: u32, + /// The recipient's key index used for ECDH + pub recipient_key_index: u32, + /// Reference to the DashPay receiving account + pub account_reference: u32, + /// ECDH-encrypted extended public key: exactly 96 bytes + /// (16-byte IV + 80 bytes of encrypted DIP-15 compact xpub) + pub encrypted_public_key: Vec, + /// Optional encrypted account label: 48-80 bytes + /// (16-byte IV + 32-64 bytes of encrypted data) + pub encrypted_account_label: Option>, + /// Optional auto-accept proof (38-102 bytes) - not encrypted + pub auto_accept_proof: Option>, + /// The entropy that derives the document id; the same entropy must be + /// attached to the create transition, or platform consensus rejects it + /// with `InvalidDocumentTransitionIdError`. + pub entropy: [u8; 32], +} + +/// Validate the size of a DIP-15 `autoAcceptProof` (38-102 bytes). +pub fn validate_auto_accept_proof(proof: &[u8]) -> Result<(), Error> { + if proof.len() < 38 || proof.len() > 102 { + return Err(Error::InvalidInput(format!( + "autoAcceptProof must be 38-102 bytes, got {}", + proof.len() + ))); + } + Ok(()) +} + +/// Build the id and property map of a DIP-15 `contactRequest` document from +/// already-derived crypto material. +/// +/// This is the pure document-assembly half of `dash-sdk`'s +/// `create_contact_request`: the document id derives from +/// `params.entropy`, and the property map carries exactly the fields the +/// DashPay contract defines (`toUserId`, `encryptedPublicKey`, +/// `senderKeyIndex`, `recipientKeyIndex`, `accountReference`, plus the +/// optional `encryptedAccountLabel` and `autoAcceptProof`). +/// +/// Returns `(document_id, properties)`. +pub fn build_contact_request_document( + contract: &DataContract, + params: ContactRequestDocumentParams, +) -> Result<(Identifier, BTreeMap), Error> { + if let Some(ref proof) = params.auto_accept_proof { + validate_auto_accept_proof(proof)?; + } + + // Validate encrypted public key size (must be exactly 96 bytes: 16-byte IV + 80-byte encrypted data) + if params.encrypted_public_key.len() != 96 { + return Err(Error::InvalidInput(format!( + "Encrypted public key size mismatch: expected 96 bytes, got {}", + params.encrypted_public_key.len() + ))); + } + + // Validate encrypted label size (48-80 bytes: 16-byte IV + 32-64 byte encrypted data) + if let Some(ref label) = params.encrypted_account_label { + if label.len() < 48 || label.len() > 80 { + return Err(Error::InvalidInput(format!( + "Encrypted account label size out of range: expected 48-80 bytes, got {}", + label.len() + ))); + } + } + + let contact_request_document_type = + contract + .document_type_for_name("contactRequest") + .map_err(|_| { + Error::InvalidInput("DashPay contactRequest document type not found".to_string()) + })?; + + let document_id = Document::generate_document_id_v0( + &contract.id(), + ¶ms.sender_id, + contact_request_document_type.name(), + params.entropy.as_slice(), + ); + + let mut properties = BTreeMap::new(); + properties.insert( + "toUserId".to_string(), + Value::Identifier(params.recipient_id.to_buffer()), + ); + properties.insert( + "encryptedPublicKey".to_string(), + Value::Bytes(params.encrypted_public_key), + ); + properties.insert( + "senderKeyIndex".to_string(), + Value::U32(params.sender_key_index), + ); + properties.insert( + "recipientKeyIndex".to_string(), + Value::U32(params.recipient_key_index), + ); + properties.insert( + "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((document_id, properties)) +} + +#[cfg(test)] +mod tests { + use super::*; + use dpp::system_data_contracts::{load_system_data_contract, SystemDataContract}; + use dpp::version::PlatformVersion; + + fn dashpay_contract() -> DataContract { + load_system_data_contract(SystemDataContract::Dashpay, PlatformVersion::latest()) + .expect("should load DashPay system contract") + } + + fn valid_params() -> ContactRequestDocumentParams { + ContactRequestDocumentParams { + sender_id: Identifier::from([2u8; 32]), + recipient_id: Identifier::from([3u8; 32]), + sender_key_index: 1, + recipient_key_index: 2, + account_reference: 7, + encrypted_public_key: vec![0xAA; 96], + encrypted_account_label: None, + auto_accept_proof: None, + entropy: [5u8; 32], + } + } + + #[test] + fn entropy_derives_built_document_id() { + // Mirror of rs-sdk's contact_request_result_entropy_derives_returned_id: + // the id the builder returns must be exactly what consensus recomputes + // from the entropy attached to the create transition. + let contract = dashpay_contract(); + let params = valid_params(); + let entropy = params.entropy; + let sender_id = params.sender_id; + + let (id, _) = + build_contact_request_document(&contract, params).expect("valid params must build"); + + assert_eq!( + id, + Document::generate_document_id_v0( + &contract.id(), + &sender_id, + "contactRequest", + entropy.as_slice() + ), + "built document id must derive from the supplied entropy" + ); + } + + #[test] + fn builds_expected_property_map() { + let contract = dashpay_contract(); + let mut params = valid_params(); + params.encrypted_account_label = Some(vec![0xBB; 48]); + params.auto_accept_proof = Some(vec![0xCC; 38]); + + let (_, properties) = + build_contact_request_document(&contract, params).expect("valid params must build"); + + assert_eq!( + properties, + BTreeMap::from([ + ( + "toUserId".to_string(), + Value::Identifier(Identifier::from([3u8; 32]).to_buffer()) + ), + ( + "encryptedPublicKey".to_string(), + Value::Bytes(vec![0xAA; 96]) + ), + ("senderKeyIndex".to_string(), Value::U32(1)), + ("recipientKeyIndex".to_string(), Value::U32(2)), + ("accountReference".to_string(), Value::U32(7)), + ( + "encryptedAccountLabel".to_string(), + Value::Bytes(vec![0xBB; 48]) + ), + ("autoAcceptProof".to_string(), Value::Bytes(vec![0xCC; 38])), + ]) + ); + } + + #[test] + fn optional_fields_are_omitted_when_absent() { + let contract = dashpay_contract(); + let (_, properties) = build_contact_request_document(&contract, valid_params()) + .expect("valid params must build"); + + assert_eq!(properties.len(), 5); + assert!(!properties.contains_key("encryptedAccountLabel")); + assert!(!properties.contains_key("autoAcceptProof")); + } + + #[test] + fn rejects_wrong_encrypted_public_key_size() { + let contract = dashpay_contract(); + for bad_len in [0, 95, 97] { + let mut params = valid_params(); + params.encrypted_public_key = vec![0xAA; bad_len]; + assert!( + matches!( + build_contact_request_document(&contract, params), + Err(Error::InvalidInput(_)) + ), + "encrypted public key of {bad_len} bytes must be rejected" + ); + } + } + + #[test] + fn rejects_out_of_range_auto_accept_proof() { + let contract = dashpay_contract(); + for bad_len in [0, 37, 103] { + let mut params = valid_params(); + params.auto_accept_proof = Some(vec![0xCC; bad_len]); + assert!( + matches!( + build_contact_request_document(&contract, params), + Err(Error::InvalidInput(_)) + ), + "auto accept proof of {bad_len} bytes must be rejected" + ); + } + for good_len in [38, 70, 102] { + let mut params = valid_params(); + params.auto_accept_proof = Some(vec![0xCC; good_len]); + assert!( + build_contact_request_document(&contract, params).is_ok(), + "auto accept proof of {good_len} bytes must be accepted" + ); + } + } + + #[test] + fn rejects_out_of_range_encrypted_account_label() { + let contract = dashpay_contract(); + for bad_len in [0, 47, 81] { + let mut params = valid_params(); + params.encrypted_account_label = Some(vec![0xBB; bad_len]); + assert!( + matches!( + build_contact_request_document(&contract, params), + Err(Error::InvalidInput(_)) + ), + "encrypted account label of {bad_len} bytes must be rejected" + ); + } + } +} diff --git a/packages/dash-platform-queries/src/documents/document_query.rs b/packages/dash-platform-queries/src/documents/document_query.rs index 523a1364de..7717dc6fc2 100644 --- a/packages/dash-platform-queries/src/documents/document_query.rs +++ b/packages/dash-platform-queries/src/documents/document_query.rs @@ -2,6 +2,7 @@ use std::sync::Arc; +use super::proto_conversions; use crate::error::Error; use dapi_grpc::platform::v0::get_documents_request::Version::{V0, V1}; use dapi_grpc::platform::v0::{ @@ -382,6 +383,442 @@ impl DocumentQuery { ) -> Result { GetDocumentsRequest::try_from_platform_versioned(self, platform_version) } + + /// Decode a wire-format [`GetDocumentsRequest`] back into a rich + /// [`DocumentQuery`] — the inverse of + /// [`Self::try_into_request_for_version`], and the piece that lets + /// an embedder verify a proved response given only the request + /// bytes it sent (see [`verify_documents_response`]). + /// + /// Both wire versions are handled, mirroring how the server + /// decodes each: + /// - **V0** carries `where` / `order_by` as CBOR-encoded arrays of + /// clause components; they are decoded exactly as + /// rs-drive-abci's `query_documents_v0` does (ciborium → + /// `Value::Array` → `WhereClause::from_components` / + /// `OrderClause::from_components`). V0 has no `select` / + /// `group_by` / `having` / `offset`; those default to the + /// documents-fetch shape. + /// - **V1** carries typed proto clauses; they are decoded through + /// the same [`proto_conversions`](super::proto_conversions) + /// functions the server's v1 handler runs, so client and server + /// cannot disagree on what the bytes mean. Multi-projection + /// `selects` (len > 1) is rejected — a `DocumentQuery` carries a + /// single projection, matching what the server evaluates. + /// `limit: Some(0)` is rejected, mirroring the server's uniform + /// `InvalidLimit` contract (`None` = server default → `0` + /// sentinel here; only positive caps are representable). + /// + /// The `prove` flag is intentionally ignored: `DocumentQuery` has + /// no prove field (its encoders always set `prove: true`, because + /// the `FromProof` decoders only handle proved responses). + /// + /// `contract` must be the data contract the request targets — the + /// request's `data_contract_id` is checked against `contract.id()` + /// and the named document type must exist on it. + /// + /// Scope caveat: this mirrors the server's *wire-shape* decoding + /// (shared clause decoders), not its full `validate_and_route` + /// business rules — e.g. SUM/AVG requiring a non-empty field, + /// GROUP BY being illegal with SELECT DOCUMENTS, or HAVING being + /// unimplemented are enforced server-side only. A request violating + /// those decodes here but can never yield a provable response from + /// a real server. That gap matters precisely for fabricated + /// request/response pairs, so the proof-verifying entry point + /// [`verify_documents_response`] closes it: it rejects every such + /// shape before delegating, rather than letting the lowering to + /// [`DriveDocumentQuery`] silently drop it. + pub fn try_from_request( + request: GetDocumentsRequest, + contract: Arc, + ) -> Result { + match request.version { + Some(V0(request_v0)) => Self::try_from_request_v0(request_v0, contract), + Some(V1(request_v1)) => Self::try_from_request_v1(request_v1, contract), + None => Err(Error::Protocol(ProtocolError::DecodingError( + "GetDocumentsRequest has no version set".to_string(), + ))), + } + } + + fn try_from_request_v0( + request: GetDocumentsRequestV0, + contract: Arc, + ) -> Result { + let GetDocumentsRequestV0 { + data_contract_id, + document_type, + r#where, + order_by, + limit, + // See `try_from_request`: DocumentQuery has no prove field. + prove: _, + start, + } = request; + + check_request_targets_contract(&contract, &data_contract_id, &document_type)?; + + let where_clauses = where_clauses_from_cbor(&r#where)?; + let order_by_clauses = order_clauses_from_cbor(&order_by)?; + + Ok(Self { + select: SelectProjection::documents(), + data_contract: contract, + document_type_name: document_type, + where_clauses, + group_by: Vec::new(), + having: Vec::new(), + order_by_clauses, + // V0's plain `uint32` uses the same `0` = "unset" sentinel + // as this struct — pass through. + limit, + offset: None, + start, + }) + } + + fn try_from_request_v1( + request: GetDocumentsRequestV1, + contract: Arc, + ) -> Result { + let GetDocumentsRequestV1 { + data_contract_id, + document_type, + where_clauses, + order_by, + limit, + start, + // See `try_from_request`: DocumentQuery has no prove field. + prove: _, + selects, + group_by, + having, + offset, + } = request; + + check_request_targets_contract(&contract, &data_contract_id, &document_type)?; + + let where_clauses = proto_conversions::where_clauses_from_proto(where_clauses)?; + let order_by_clauses = proto_conversions::order_clauses_from_proto(order_by)?; + let having = proto_conversions::having_clauses_from_proto(having)?; + + // Same shape the server's v1 handler accepts: 0 selects → + // default documents projection, 1 select → decode it, more → + // reject (a `DocumentQuery` carries a single projection; + // multi-projection is wire-only today and the server refuses + // it too). + if selects.len() > 1 { + return Err(Error::Protocol(ProtocolError::DecodingError(format!( + "multi-projection SELECT is not supported: a DocumentQuery carries a \ + single projection, got {} selects", + selects.len() + )))); + } + let select = selects + .into_iter() + .next() + .map(proto_conversions::select_from_proto) + .transpose()? + .unwrap_or_else(SelectProjection::documents); + + // Mirror the server's uniform v1 limit contract: `None` = use + // the server default (the `0` sentinel here), positive = + // explicit cap, `Some(0)` invalid (and unrepresentable — this + // struct's `0` means "unset"). + let limit = match limit { + None => 0, + Some(0) => { + return Err(Error::Protocol(ProtocolError::DecodingError( + "limit = 0 is not a valid wire value on the v1 `optional uint32` \ + field; omit `limit` (None) to use the server's default, or pass \ + a positive integer for an explicit cap" + .to_string(), + ))); + } + Some(n) => n, + }; + + // V1 ships its own `Start` enum with the same shape as V0's; + // this struct stores the V0 type (see `encode_v1` for the + // inverse translation). + let start = start.map(|s| match s { + V1Start::StartAfter(b) => Start::StartAfter(b), + V1Start::StartAt(b) => Start::StartAt(b), + }); + + Ok(Self { + select, + data_contract: contract, + document_type_name: document_type, + where_clauses, + group_by, + having, + order_by_clauses, + limit, + offset, + start, + }) + } +} + +/// Shared request-vs-contract consistency check for both wire +/// versions: the request must target the supplied contract, and the +/// named document type must exist on it. +fn check_request_targets_contract( + contract: &DataContract, + data_contract_id: &[u8], + document_type_name: &str, +) -> Result<(), Error> { + if data_contract_id != contract.id().as_slice() { + return Err(Error::Protocol(ProtocolError::DecodingError(format!( + "GetDocumentsRequest targets data contract {} but the supplied contract is {}", + hex::encode(data_contract_id), + contract.id() + )))); + } + contract + .document_type_for_name(document_type_name) + .map_err(ProtocolError::DataContractError)?; + Ok(()) +} + +/// Decode a V0 `where` field — CBOR bytes carrying an array of +/// `[field, operator, value]` component arrays — into structured +/// clauses. Byte-for-byte mirror of the decode the server's +/// `query_documents_v0` runs (empty bytes → no clauses; anything +/// else must be a CBOR array of arrays). +fn where_clauses_from_cbor(bytes: &[u8]) -> Result, Error> { + if bytes.is_empty() { + return Ok(Vec::new()); + } + let value: Value = ciborium::de::from_reader(bytes).map_err(|_| { + Error::Protocol(ProtocolError::DecodingError( + "unable to decode 'where' query from cbor".to_string(), + )) + })?; + match value { + Value::Null => Ok(Vec::new()), + Value::Array(clauses) => clauses + .iter() + .map(|wc| match wc { + Value::Array(components) => { + WhereClause::from_components(components).map_err(Error::Drive) + } + _ => Err(Error::Protocol(ProtocolError::DecodingError( + "where clause must be an array".to_string(), + ))), + }) + .collect(), + _ => Err(Error::Protocol(ProtocolError::DecodingError( + "where clause must be an array".to_string(), + ))), + } +} + +/// Decode a V0 `order_by` field — CBOR bytes carrying an array of +/// `[field, "asc"|"desc"]` component arrays — into structured +/// clauses. Mirror of the server-side decode, like +/// [`where_clauses_from_cbor`]. +fn order_clauses_from_cbor(bytes: &[u8]) -> Result, Error> { + if bytes.is_empty() { + return Ok(Vec::new()); + } + let value: Value = ciborium::de::from_reader(bytes).map_err(|_| { + Error::Protocol(ProtocolError::DecodingError( + "unable to decode 'order_by' query from cbor".to_string(), + )) + })?; + match value { + Value::Null => Ok(Vec::new()), + Value::Array(clauses) => clauses + .iter() + .map(|oc| match oc { + Value::Array(components) => { + OrderClause::from_components(components).map_err(|_| { + Error::Protocol(ProtocolError::DecodingError( + "invalid order_by clause components".to_string(), + )) + }) + } + _ => Err(Error::Protocol(ProtocolError::DecodingError( + "order_by clause must be an array".to_string(), + ))), + }) + .collect(), + _ => Err(Error::Protocol(ProtocolError::DecodingError( + "order_by must be an array".to_string(), + ))), + } +} + +/// Reject the request shapes that can never have produced the proved +/// plain-document response being verified. +/// +/// Each rejection mirrors a gate an honest server runs before it would +/// ever build such a proof, and each covers a field the +/// `DocumentQuery` → [`DriveDocumentQuery`] lowering discards — which +/// is exactly the set an attacker could vary freely while replaying a +/// genuine proof. See [`verify_documents_response`] for the threat +/// model. +/// +/// Server counterparts, all in +/// `packages/rs-drive-abci/src/query/document_query/v1/mod.rs`: +/// `validate_and_route` rejects a non-empty HAVING for any +/// non-aggregate SELECT and a non-empty GROUP BY under SELECT +/// DOCUMENTS; `reject_offset_off_the_ranked_path` rejects any OFFSET +/// that did not route to the ranked executor (a documents fetch never +/// does). `prove: false` is not a server rejection — it makes the +/// server return an unproved response, so a proved response cannot +/// have come from one. +fn reject_request_the_server_would_not_have_proved( + query: &DocumentQuery, + prove: bool, +) -> Result<(), drive_proof_verifier::Error> { + let reject = |error: String| Err(drive_proof_verifier::Error::RequestError { error }); + + if !prove { + return reject( + "request carries prove=false, so an honest server would have answered it with an \ + unproved response; a proved response cannot belong to this request" + .to_string(), + ); + } + // This entry point verifies plain document fetches only. An aggregate + // projection (COUNT/SUM/AVG) is proved with a different proof shape; + // handing it to the Documents verifier would surface as an opaque + // low-level proof error, so reject it up front instead. + if query.select != drive::query::SelectProjection::documents() { + return reject(format!( + "verify_documents_response only verifies plain document fetches; the request \ + carries a {:?} projection — use the aggregate proof helpers instead", + query.select.function + )); + } + if !query.having.is_empty() { + return reject(format!( + "request carries {} HAVING clause(s), which the server refuses for a \ + non-aggregate SELECT; no proved document response can belong to it", + query.having.len() + )); + } + if !query.group_by.is_empty() { + return reject(format!( + "request carries GROUP BY {:?}, which the server refuses under SELECT DOCUMENTS; \ + no proved document response can belong to it", + query.group_by + )); + } + if let Some(offset) = query.offset { + return reject(format!( + "request carries OFFSET {offset}, which the server accepts only on the ranked \ + surface, never for a document fetch; no proved document response can belong to it" + )); + } + Ok(()) +} + +/// Embedder entry point: verify a proved [`GetDocumentsResponse`] +/// directly against the wire request that produced it. +/// +/// This is the transport-free glue an embedder needs when it drives +/// its own transport: it holds the `GetDocumentsRequest` it sent and +/// the `GetDocumentsResponse` it got back, and this function does the +/// rest — decodes the request into a [`DocumentQuery`] (via +/// [`DocumentQuery::try_from_request`], on the same shared decoders +/// the server runs) and delegates to the existing +/// [`FromProof`] machinery, which resolves the +/// [`DriveDocumentQuery`] internally and cryptographically verifies +/// the proof against it. +/// +/// `contract` must be the data contract the request targets. If the +/// embedder's [`ContextProvider`] can resolve contracts, use +/// [`verify_documents_response_with_provider_contract`] instead and +/// skip the explicit parameter. +/// +/// # Binding the proof to the whole request +/// +/// GroveDB and Tenderdash proofs authenticate the state and the +/// resolved [`DriveDocumentQuery`] — not the request envelope. The +/// rich→drive lowering drops request fields that a documents query has +/// no place for (`group_by`, `having`, `offset`, `prove`), so +/// delegating without first checking them would let an untrusted +/// transport pair a request the real server would have *refused* with +/// a valid proof for the narrower query it lowers to, and this +/// function would accept it. Every such field is therefore rejected up +/// front, mirroring the server's own gates in +/// `rs-drive-abci`'s `validate_and_route` / +/// `reject_offset_off_the_ranked_path`. +pub fn verify_documents_response( + request: GetDocumentsRequest, + contract: Arc, + response: platform_proto::GetDocumentsResponse, + network: Network, + platform_version: &PlatformVersion, + provider: &dyn ContextProvider, +) -> Result<(Option, ResponseMetadata, Proof), drive_proof_verifier::Error> { + // `prove` does not survive decoding (a `DocumentQuery` has no such + // field), so read it off the wire request before it is consumed. + let prove = match &request.version { + Some(V0(v0)) => v0.prove, + Some(V1(v1)) => v1.prove, + // Missing version is reported by the decode below. + None => true, + }; + let query = DocumentQuery::try_from_request(request, contract).map_err(|e| { + drive_proof_verifier::Error::RequestError { + error: format!("failed to decode GetDocumentsRequest into a DocumentQuery: {e}"), + } + })?; + reject_request_the_server_would_not_have_proved(&query, prove)?; + >::maybe_from_proof_with_metadata( + query, + response, + network, + platform_version, + provider, + ) +} + +/// Variant of [`verify_documents_response`] that resolves the data +/// contract through the [`ContextProvider`] +/// ([`ContextProvider::get_data_contract`]) instead of taking it as a +/// parameter — for embedders whose provider already caches or fetches +/// contracts. +pub fn verify_documents_response_with_provider_contract( + request: GetDocumentsRequest, + response: platform_proto::GetDocumentsResponse, + network: Network, + platform_version: &PlatformVersion, + provider: &dyn ContextProvider, +) -> Result<(Option, ResponseMetadata, Proof), drive_proof_verifier::Error> { + let contract_id_bytes = match &request.version { + Some(V0(v0)) => v0.data_contract_id.as_slice(), + Some(V1(v1)) => v1.data_contract_id.as_slice(), + None => { + return Err(drive_proof_verifier::Error::RequestError { + error: "GetDocumentsRequest has no version set".to_string(), + }); + } + }; + let contract_id = Identifier::from_bytes(contract_id_bytes).map_err(|e| { + drive_proof_verifier::Error::RequestError { + error: format!("invalid data_contract_id in GetDocumentsRequest: {e}"), + } + })?; + let contract = provider + .get_data_contract(&contract_id, platform_version) + .map_err(drive_proof_verifier::Error::ContextProviderError)? + .ok_or_else(|| drive_proof_verifier::Error::RequestError { + error: format!("context provider has no data contract {contract_id}"), + })?; + verify_documents_response( + request, + contract, + response, + network, + platform_version, + provider, + ) } impl FromProof for Document { @@ -788,8 +1225,21 @@ impl<'a> TryFrom<&'a DocumentQuery> for DriveDocumentQuery<'a> { ) .map_err(Error::Drive)?; + // `DriveDocumentQuery`'s limit is a `u16`; the wire's is a `u32`. + // The server refuses anything above `u16::MAX` outright + // (`QuerySyntaxError::InvalidLimit`), so a checked conversion is + // what actually mirrors it — an `as` cast would wrap 65537 to a + // 1-document query and verify a proof for a query nobody asked + // for. `0` keeps its "unset → server default" sentinel meaning. let limit = if request.limit != 0 { - Some(request.limit as u16) + Some(u16::try_from(request.limit).map_err(|_| { + Error::Config(format!( + "limit {} does not fit a documents query's u16 limit (max {}); \ + the server rejects such limits with InvalidLimit", + request.limit, + u16::MAX + )) + })?) } else { None }; diff --git a/packages/dash-platform-queries/src/documents/mod.rs b/packages/dash-platform-queries/src/documents/mod.rs index 56eb5b4075..2296a85dae 100644 --- a/packages/dash-platform-queries/src/documents/mod.rs +++ b/packages/dash-platform-queries/src/documents/mod.rs @@ -29,5 +29,10 @@ pub mod document_split_sums; /// `document_count`. Lights up alongside grovedb PR 670. pub mod document_sum; pub(crate) mod having_proof_helpers; +/// Shared wire-proto → drive-type decoders for `getDocuments`, +/// used by both rs-drive-abci (server request decode) and +/// [`document_query::DocumentQuery::try_from_request`] (client +/// verification) so the two directions cannot drift. +pub mod proto_conversions; pub(crate) mod ranked_proof_helpers; pub(crate) mod sum_proof_helpers; diff --git a/packages/dash-platform-queries/src/documents/proto_conversions.rs b/packages/dash-platform-queries/src/documents/proto_conversions.rs new file mode 100644 index 0000000000..a368a38e7b --- /dev/null +++ b/packages/dash-platform-queries/src/documents/proto_conversions.rs @@ -0,0 +1,372 @@ +//! Wire-protobuf → drive type conversions for the `getDocuments` +//! query surface. +//! +//! This is the **single** proto-decode implementation, shared by: +//! - rs-drive-abci's v1 request handler (server side — decodes the +//! incoming request before routing/execution), and +//! - [`DocumentQuery::try_from_request`](super::document_query::DocumentQuery::try_from_request) +//! (client side — rebuilds the rich query from the wire request so +//! a proved response can be verified against exactly what was +//! asked). +//! +//! Both directions living on one implementation is the point: the +//! bytes the server decodes and the bytes the verifier decodes must +//! agree clause-for-clause, or a proof could verify against a +//! different query than the server answered. +//! +//! Conversion contract: +//! - Every fallible case maps to [`DecodeError::InvalidArgument`] +//! (malformed wire input, **not** future capability), except the +//! aggregate `ORDER BY` target which maps to +//! [`DecodeError::Unsupported`] (valid request shape, server +//! capability not yet wired). rs-drive-abci maps these onto its +//! `QueryError::InvalidArgument` / `QuerySyntaxError::Unsupported` +//! respectively, preserving its historical error surface. +//! - Conversion is schema-agnostic. `DocumentFieldValue` variants +//! map 1:1 to `dpp::platform_value::Value` variants without +//! consulting the document type's schema. The schema-driven +//! coercion (`document_type.serialize_value_for_key`) runs +//! downstream as it does for the CBOR-shaped v0 path — a `text` +//! variant against an identifier field decodes via base58, a +//! `bytes_value` against the same field decodes as raw 32-byte +//! identifier, and so on. The wire layer just names the +//! primitive; the schema decides the indexed type. + +use dapi_grpc::platform::v0::get_documents_request::{ + document_field_value, + get_documents_request_v1::{select, Select as ProtoSelect}, + having_aggregate, having_clause, order_clause, DocumentFieldValue as ProtoDocumentFieldValue, + HavingAggregate as ProtoHavingAggregate, HavingClause as ProtoHavingClause, + OrderClause as ProtoOrderClause, WhereClause as ProtoWhereClause, + WhereOperator as ProtoWhereOperator, +}; +use dpp::platform_value::Value; +use drive::query::{ + HavingAggregate, HavingAggregateFunction, HavingClause, HavingOperator, HavingRightOperand, + OrderClause, SelectFunction, SelectProjection, WhereClause, WhereOperator, +}; + +/// Neutral decode error for the shared proto → drive conversions. +/// +/// Deliberately not a server or client error type: rs-drive-abci +/// maps it onto its `QueryError`, and the client-side +/// `DocumentQuery` decoding maps it onto the crate +/// [`Error`](crate::error::Error), each preserving its own error +/// surface. +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +pub enum DecodeError { + /// Malformed wire input — bad discriminant, missing oneof arm, + /// over-deep list nesting. No future protocol version would make + /// this input valid. + #[error("{0}")] + InvalidArgument(String), + /// Well-formed wire input naming a capability the decode target + /// cannot represent yet (e.g. `ORDER BY` on an aggregate key). + /// The wording signals future capability, not malformed request. + #[error("{0}")] + Unsupported(String), +} + +/// Map a wire-level [`ProtoWhereOperator`] discriminant onto +/// drive's [`WhereOperator`]. Unknown discriminants are wire-level +/// garbage (no future protocol value would map a malformed integer +/// to a valid behavior), so they surface as +/// [`DecodeError::InvalidArgument`]. +pub fn where_operator_from_proto(op: i32) -> Result { + let proto_op = ProtoWhereOperator::try_from(op).map_err(|_| { + DecodeError::InvalidArgument(format!( + "unknown WhereOperator discriminant: {} (valid values: 0..=10, see \ + `get_documents_request::WhereOperator`)", + op + )) + })?; + Ok(match proto_op { + ProtoWhereOperator::Equal => WhereOperator::Equal, + ProtoWhereOperator::GreaterThan => WhereOperator::GreaterThan, + ProtoWhereOperator::GreaterThanOrEquals => WhereOperator::GreaterThanOrEquals, + ProtoWhereOperator::LessThan => WhereOperator::LessThan, + ProtoWhereOperator::LessThanOrEquals => WhereOperator::LessThanOrEquals, + ProtoWhereOperator::Between => WhereOperator::Between, + ProtoWhereOperator::BetweenExcludeBounds => WhereOperator::BetweenExcludeBounds, + ProtoWhereOperator::BetweenExcludeLeft => WhereOperator::BetweenExcludeLeft, + ProtoWhereOperator::BetweenExcludeRight => WhereOperator::BetweenExcludeRight, + ProtoWhereOperator::In => WhereOperator::In, + ProtoWhereOperator::StartsWith => WhereOperator::StartsWith, + }) +} + +/// Map a wire [`ProtoDocumentFieldValue`] onto a +/// `dpp::platform_value::Value`. Schema-agnostic — variants map +/// 1:1 by primitive type and recurse for `list` up to a depth of +/// 1 (the only nesting level the query surface needs: `IN` / +/// `BETWEEN*` take a flat list of scalars). Anything deeper is +/// rejected as malformed wire input rather than recursed into, +/// so a hostile client can't blow the call stack with +/// `list(list(list(...)))` before schema validation. +/// +/// `None` (oneof unset on the wire) is rejected — a where-clause +/// operand is always concrete; empty where-clauses are expressed +/// by an empty `where_clauses` field at the request level, not by +/// sending an empty `DocumentFieldValue`. +pub fn value_from_proto(value: ProtoDocumentFieldValue) -> Result { + value_from_proto_at_depth(value, 0) +} + +/// Recursion-bounded form of [`value_from_proto`]. `depth = 0` is +/// the request-level operand; the only legal child shape is a +/// flat list (`depth = 1` for `IN` / `BETWEEN*` candidates), so a +/// `list` encountered at `depth >= 1` is wire-malformed. +fn value_from_proto_at_depth( + value: ProtoDocumentFieldValue, + depth: u8, +) -> Result { + let variant = value.variant.ok_or_else(|| { + DecodeError::InvalidArgument( + "DocumentFieldValue has no variant set; a where-clause operand must \ + be a concrete value" + .to_string(), + ) + })?; + Ok(match variant { + document_field_value::Variant::BoolValue(b) => Value::Bool(b), + document_field_value::Variant::Int64Value(i) => Value::I64(i), + document_field_value::Variant::Uint64Value(u) => Value::U64(u), + document_field_value::Variant::DoubleValue(f) => Value::Float(f), + document_field_value::Variant::Text(s) => Value::Text(s), + document_field_value::Variant::BytesValue(b) => Value::Bytes(b), + document_field_value::Variant::List(list) => { + if depth >= 1 { + return Err(DecodeError::InvalidArgument( + "nested DocumentFieldValue.list is not supported; the v1 \ + query surface accepts at most one level of nesting \ + (`IN` / `BETWEEN*` candidate lists of scalars)" + .to_string(), + )); + } + Value::Array( + list.values + .into_iter() + .map(|v| value_from_proto_at_depth(v, depth + 1)) + .collect::, _>>()?, + ) + } + // The bool payload is a placeholder — picking the + // `null_value` variant means "this operand is null" and + // the bool itself is ignored. See the proto-side comment + // on the field for the rationale. + document_field_value::Variant::NullValue(_) => Value::Null, + }) +} + +/// Map a wire [`ProtoWhereClause`] onto drive's structured +/// [`WhereClause`]. Errors surface as +/// [`DecodeError::InvalidArgument`] for both operator-discriminant +/// and value-shape failures. +pub fn where_clause_from_proto(clause: ProtoWhereClause) -> Result { + let operator = where_operator_from_proto(clause.operator)?; + let value = clause.value.ok_or_else(|| { + DecodeError::InvalidArgument(format!( + "WhereClause on field '{}' has no value set; every clause must carry a \ + concrete `DocumentFieldValue`", + clause.field + )) + })?; + let value = value_from_proto(value)?; + Ok(WhereClause { + field: clause.field, + operator, + value, + }) +} + +/// Plural form of [`where_clause_from_proto`] for the request-level +/// `repeated WhereClause` field. Returns an error on the first +/// malformed clause. +pub fn where_clauses_from_proto( + clauses: Vec, +) -> Result, DecodeError> { + clauses.into_iter().map(where_clause_from_proto).collect() +} + +/// Map a wire [`ProtoOrderClause`] onto drive's [`OrderClause`]. +/// +/// The `target` oneof currently has two variants on the wire: +/// `field` (plain column name — evaluated today) and `aggregate` +/// (aggregate function applied to a field — wire-only, rejected +/// with [`DecodeError::Unsupported`]). Unset (`None`) is rejected +/// as malformed wire input. +pub fn order_clause_from_proto(clause: ProtoOrderClause) -> Result { + let ascending = clause.ascending; + match clause.target { + Some(order_clause::Target::Field(field)) => Ok(OrderClause { field, ascending }), + Some(order_clause::Target::Aggregate(_)) => Err(DecodeError::Unsupported( + "ORDER BY on aggregate keys is not yet implemented".to_string(), + )), + None => Err(DecodeError::InvalidArgument( + "OrderClause has no target set; every clause must carry either a \ + `field` (plain column name) or an `aggregate` (aggregate-function \ + ordering target)" + .to_string(), + )), + } +} + +/// Plural form of [`order_clause_from_proto`] for the request-level +/// `repeated OrderClause` field. Returns the first error +/// encountered. +pub fn order_clauses_from_proto( + clauses: Vec, +) -> Result, DecodeError> { + clauses.into_iter().map(order_clause_from_proto).collect() +} + +/// Map a wire [`having_aggregate::Function`] discriminant onto +/// drive's [`HavingAggregateFunction`]. Unknown discriminants are +/// wire-level garbage (no future protocol value would map a +/// malformed integer to a valid behavior), so they surface as +/// [`DecodeError::InvalidArgument`]. +fn having_function_from_proto(function: i32) -> Result { + let proto = having_aggregate::Function::try_from(function).map_err(|_| { + DecodeError::InvalidArgument(format!( + "unknown HavingAggregate.Function discriminant: {} (valid values: 0..=2, see \ + `get_documents_request::having_aggregate::Function`)", + function + )) + })?; + Ok(match proto { + having_aggregate::Function::Count => HavingAggregateFunction::Count, + having_aggregate::Function::Sum => HavingAggregateFunction::Sum, + having_aggregate::Function::Avg => HavingAggregateFunction::Avg, + }) +} + +/// Map a wire [`having_clause::Operator`] discriminant onto +/// drive's [`HavingOperator`]. Same error contract as +/// [`having_function_from_proto`]. +fn having_operator_from_proto(operator: i32) -> Result { + let proto = having_clause::Operator::try_from(operator).map_err(|_| { + DecodeError::InvalidArgument(format!( + "unknown HavingClause.Operator discriminant: {} (valid values: 0..=10, see \ + `get_documents_request::having_clause::Operator`)", + operator + )) + })?; + Ok(match proto { + having_clause::Operator::Equal => HavingOperator::Equal, + having_clause::Operator::NotEqual => HavingOperator::NotEqual, + having_clause::Operator::GreaterThan => HavingOperator::GreaterThan, + having_clause::Operator::GreaterThanOrEquals => HavingOperator::GreaterThanOrEquals, + having_clause::Operator::LessThan => HavingOperator::LessThan, + having_clause::Operator::LessThanOrEquals => HavingOperator::LessThanOrEquals, + having_clause::Operator::Between => HavingOperator::Between, + having_clause::Operator::BetweenExcludeBounds => HavingOperator::BetweenExcludeBounds, + having_clause::Operator::BetweenExcludeLeft => HavingOperator::BetweenExcludeLeft, + having_clause::Operator::BetweenExcludeRight => HavingOperator::BetweenExcludeRight, + having_clause::Operator::In => HavingOperator::In, + }) +} + +/// Map a wire [`ProtoHavingAggregate`] onto drive's +/// [`HavingAggregate`]. The aggregate-function ↔ field +/// consistency check (`field` required for everything except +/// `Count`) runs inside the evaluator when HAVING execution +/// lands; the converter only enforces that the proto shape is +/// well-formed. +fn having_aggregate_from_proto( + aggregate: ProtoHavingAggregate, +) -> Result { + Ok(HavingAggregate { + function: having_function_from_proto(aggregate.function)?, + field: aggregate.field, + }) +} + +/// Map a wire [`ProtoHavingClause`] onto drive's structured +/// [`HavingClause`]. Errors surface as +/// [`DecodeError::InvalidArgument`] for any wire-level +/// malformation: unknown discriminant on the aggregate function or +/// operator; missing aggregate; missing right operand (oneof unset +/// on the wire); inner value-shape failures on the literal-value +/// branch. +/// +/// `HAVING` is a boolean per-group predicate and nothing else, so the +/// wire's `right` oneof has exactly one arm and this function has +/// exactly one thing to decode. Cross-group ranking is expressed with +/// SQL's own ordering surface — `ORDER BY DESC +/// LIMIT n [OFFSET m]` — which arrives as an `OrderClause` and never +/// reaches here. +pub fn having_clause_from_proto(clause: ProtoHavingClause) -> Result { + let aggregate = clause.aggregate.ok_or_else(|| { + DecodeError::InvalidArgument( + "HavingClause has no aggregate set; every clause must carry an \ + aggregate function + field operand" + .to_string(), + ) + })?; + let aggregate = having_aggregate_from_proto(aggregate)?; + let operator = having_operator_from_proto(clause.operator)?; + let right = clause.right.ok_or_else(|| { + DecodeError::InvalidArgument( + "HavingClause has no right operand set; every clause must carry a \ + concrete `DocumentFieldValue` (`right.value`)" + .to_string(), + ) + })?; + let right = match right { + having_clause::Right::Value(v) => HavingRightOperand::Value(value_from_proto(v)?), + }; + Ok(HavingClause { + aggregate, + operator, + right, + }) +} + +/// Plural form of [`having_clause_from_proto`] for the request- +/// level `repeated HavingClause` field. Returns an error on the +/// first malformed clause. +pub fn having_clauses_from_proto( + clauses: Vec, +) -> Result, DecodeError> { + clauses.into_iter().map(having_clause_from_proto).collect() +} + +/// Map a wire [`select::Function`] discriminant onto drive's +/// [`SelectFunction`]. Unknown discriminants are wire-level +/// garbage (no future protocol value would map a malformed +/// integer to a valid behavior), so they surface as +/// [`DecodeError::InvalidArgument`]. +fn select_function_from_proto(function: i32) -> Result { + let proto = select::Function::try_from(function).map_err(|_| { + DecodeError::InvalidArgument(format!( + "unknown Select.Function discriminant: {} (valid values: 0..=5, see \ + `get_documents_request::get_documents_request_v1::select::Function`)", + function + )) + })?; + Ok(match proto { + select::Function::Documents => SelectFunction::Documents, + select::Function::Count => SelectFunction::Count, + select::Function::Sum => SelectFunction::Sum, + select::Function::Avg => SelectFunction::Avg, + select::Function::Min => SelectFunction::Min, + select::Function::Max => SelectFunction::Max, + }) +} + +/// Map a wire [`ProtoSelect`] onto drive's [`SelectProjection`]. +/// An unset `select` field on the request decodes as the proto- +/// default `Select { function: DOCUMENTS, field: "" }`, which +/// maps to [`SelectProjection::documents()`] — keeps callers that +/// don't set the field on the v0-style document-fetch path. +/// +/// Per-function field constraints (e.g. `DOCUMENTS` must have +/// empty `field`, `SUM`/`AVG` require non-empty) are checked at +/// routing time by the server's `validate_and_route`, not here, so +/// the converter only enforces well-formed proto. +pub fn select_from_proto(select: ProtoSelect) -> Result { + Ok(SelectProjection { + function: select_function_from_proto(select.function)?, + field: select.field, + }) +} diff --git a/packages/dash-platform-queries/src/dpns_usernames.rs b/packages/dash-platform-queries/src/dpns_usernames.rs index 3f452519b2..14d933e572 100644 --- a/packages/dash-platform-queries/src/dpns_usernames.rs +++ b/packages/dash-platform-queries/src/dpns_usernames.rs @@ -1,8 +1,151 @@ //! Transport-free DPNS username helpers. //! //! 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. +//! resolution) lives in `dash-sdk`; the free functions here are the pure +//! pieces shared with embedders: string validation/normalization and the +//! preorder/domain document assembly used to register a name. + +use crate::Error; +use dpp::data_contract::accessors::v0::DataContractV0Getters; +use dpp::data_contract::document_type::accessors::DocumentTypeV0Getters; +use dpp::data_contract::DataContract; +use dpp::document::{Document, DocumentV0}; +use dpp::platform_value::Value; +use dpp::prelude::Identifier; +use std::collections::BTreeMap; + +/// 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() +} + +/// Build the DPNS `preorder` and `domain` documents that register +/// `label`.dash for `identity_id`, exactly as platform consensus expects +/// them. +/// +/// This is the pure document-assembly half of `dash-sdk`'s +/// `register_dpns_name`: no networking, and no randomness — the caller +/// supplies the `entropy` that derives both document ids (the same entropy +/// must later be attached to both create transitions) and the preorder +/// `salt`, whose double-SHA256 over `salt ‖ ".dash"` +/// becomes the preorder's `saltedDomainHash`. +/// +/// The `label` must satisfy [`is_consensus_valid_label`]; the raw label is stored +/// in the domain document's `label` property while its +/// [homograph-safe](convert_to_homograph_safe_chars) form is stored in +/// `normalizedLabel`. +/// +/// Returns `(preorder_document, domain_document)`. +pub fn build_dpns_preorder_and_domain_documents( + contract: &DataContract, + identity_id: Identifier, + label: &str, + entropy: [u8; 32], + salt: [u8; 32], +) -> Result<(Document, Document), Error> { + if !is_consensus_valid_label(label) { + return Err(Error::InvalidInput(format!( + "Invalid DPNS label \"{label}\": must be 3-63 characters, alphanumeric and hyphens \ + only, starting and ending with an alphanumeric character" + ))); + } + + let preorder_document_type = contract + .document_type_for_name("preorder") + .map_err(|_| Error::InvalidInput("DPNS preorder document type not found".to_string()))?; + + let domain_document_type = contract + .document_type_for_name("domain") + .map_err(|_| Error::InvalidInput("DPNS domain document type not found".to_string()))?; + + let preorder_id = Document::generate_document_id_v0( + &contract.id(), + &identity_id, + preorder_document_type.name(), + entropy.as_slice(), + ); + let domain_id = Document::generate_document_id_v0( + &contract.id(), + &identity_id, + domain_document_type.name(), + entropy.as_slice(), + ); + + // Create salted domain hash for preorder + let normalized_label = convert_to_homograph_safe_chars(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); + + let preorder_document = Document::V0(DocumentV0 { + 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, + }); + + let domain_document = Document::V0(DocumentV0 { + 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(label.to_string())), + ("normalizedLabel".to_string(), Value::Text(normalized_label)), + ("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, + }); + + Ok((preorder_document, domain_document)) +} /// Convert a string to homograph-safe characters by replacing 'o', 'i', and 'l' /// with '0', '1', and '1' respectively to prevent homograph attacks @@ -18,15 +161,34 @@ pub fn convert_to_homograph_safe_chars(input: &str) -> String { .collect() } -/// Check if a username is valid according to DPNS rules -/// -/// A username is valid if: -/// - It's between 3 and 63 characters long -/// - It starts and ends with alphanumeric characters (a-zA-Z0-9) -/// - It contains only alphanumeric characters and hyphens -/// - It doesn't have consecutive hyphens (enforced by the pattern) +/// Check whether a label satisfies the DPNS contract's `label` schema +/// pattern — exactly what consensus enforces, nothing stricter. /// /// Pattern: `^[a-zA-Z0-9][a-zA-Z0-9-]{0,61}[a-zA-Z0-9]$` +/// (3-63 characters, alphanumeric and hyphens, alphanumeric at both ends; +/// consecutive hyphens ARE allowed by consensus). +pub fn is_consensus_valid_label(label: &str) -> bool { + if label.len() < 3 || label.len() > 63 { + return false; + } + let chars: Vec = label.chars().collect(); + if !chars[0].is_ascii_alphanumeric() || !chars[chars.len() - 1].is_ascii_alphanumeric() { + return false; + } + chars[1..chars.len() - 1] + .iter() + .all(|&ch| ch.is_ascii_alphanumeric() || ch == '-') +} + +/// Check if a username is valid according to this crate's recommended +/// client-side policy: the consensus pattern plus a stricter rejection of +/// consecutive hyphens. +/// +/// This is deliberately narrower than [`is_consensus_valid_label`] — a name +/// like `ab--cd` is consensus-valid but rejected here, matching the +/// pre-existing policy of the mobile SDK FFI and wasm-sdk gates. Callers +/// that must accept every consensus-valid label should use +/// [`is_consensus_valid_label`] instead. /// /// # Arguments /// @@ -36,38 +198,7 @@ pub fn convert_to_homograph_safe_chars(input: &str) -> String { /// /// Returns `true` if the username is valid, `false` otherwise pub fn is_valid_username(label: &str) -> bool { - // Check length - if label.len() < 3 || label.len() > 63 { - return false; - } - - let chars: Vec = label.chars().collect(); - - // Check first character (must be alphanumeric) - if !chars[0].is_ascii_alphanumeric() { - return false; - } - - // Check last character (must be alphanumeric) - if !chars[chars.len() - 1].is_ascii_alphanumeric() { - return false; - } - - // Check middle characters (can be alphanumeric or hyphen) - for &ch in &chars[1..chars.len() - 1] { - if !ch.is_ascii_alphanumeric() && ch != '-' { - return false; - } - } - - // Additional check: no consecutive hyphens (good practice) - for i in 0..chars.len() - 1 { - if chars[i] == '-' && chars[i + 1] == '-' { - return false; - } - } - - true + is_consensus_valid_label(label) && !label.contains("--") } /// Check if a username is contested (requires masternode voting) @@ -100,6 +231,165 @@ pub fn is_contested_username(label: &str) -> bool { #[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 dpns_contract() -> DataContract { + load_system_data_contract(SystemDataContract::DPNS, PlatformVersion::latest()) + .expect("should load DPNS system contract") + } + + #[test] + fn build_dpns_documents_known_vector() { + // Fixed (label, entropy, salt) must always produce the same document + // ids and property maps: platform consensus recomputes the ids from + // the entropy, and the resolved name matches on these exact fields. + let contract = dpns_contract(); + let identity_id = Identifier::from([2u8; 32]); + let entropy = [3u8; 32]; + let salt = [4u8; 32]; + + let (preorder, domain) = build_dpns_preorder_and_domain_documents( + &contract, + identity_id, + "Alice", + entropy, + salt, + ) + .expect("valid label must build"); + + // Pinned vectors: any drift in the id derivation (contract id, owner, + // type name, entropy layout) or the salted-hash preimage + // (salt ‖ "a11ce.dash", double SHA256) changes these values. + assert_eq!( + preorder + .id() + .to_string(dpp::platform_value::string_encoding::Encoding::Base58), + "8orwov4SyqCiCppTiEogdtFHSpGyPJUfR4MtHZW8mPBB" + ); + assert_eq!( + domain + .id() + .to_string(dpp::platform_value::string_encoding::Encoding::Base58), + "CeNRVgX6wseeTeoiJEspAJstmACSV57VfsRjHChDh5ec" + ); + + // Both ids derive from the SAME entropy (only the document type name + // differs), which is what lets one entropy drive both create + // transitions. + assert_eq!( + preorder.id(), + Document::generate_document_id_v0( + &contract.id(), + &identity_id, + "preorder", + entropy.as_slice() + ) + ); + assert_eq!( + domain.id(), + Document::generate_document_id_v0( + &contract.id(), + &identity_id, + "domain", + entropy.as_slice() + ) + ); + assert_eq!(preorder.owner_id(), identity_id); + assert_eq!(domain.owner_id(), identity_id); + assert_eq!(preorder.revision(), None); + assert_eq!(domain.revision(), None); + + // saltedDomainHash = sha256d(salt ‖ "a11ce.dash"), pinned as a vector. + let expected_hash: [u8; 32] = + hex::decode("5396e080af450f80f4f8ddbfc3eb0a885674c9cf6edbea815dad7305b558e253") + .expect("valid hex") + .try_into() + .expect("32 bytes"); + assert_eq!( + preorder.properties(), + &BTreeMap::from([( + "saltedDomainHash".to_string(), + Value::Bytes32(expected_hash) + )]) + ); + + assert_eq!( + domain.properties(), + &BTreeMap::from([ + ( + "parentDomainName".to_string(), + Value::Text("dash".to_string()) + ), + ( + "normalizedParentDomainName".to_string(), + Value::Text("dash".to_string()) + ), + ("label".to_string(), Value::Text("Alice".to_string())), + ( + "normalizedLabel".to_string(), + Value::Text("a11ce".to_string()) + ), + ("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) + )]) + ), + ]) + ); + } + + #[test] + fn build_dpns_documents_rejects_invalid_label() { + let contract = dpns_contract(); + let identity_id = Identifier::from([2u8; 32]); + + for bad in ["", "ab", "-alice", "alice-", "alice_bob"] { + let result = build_dpns_preorder_and_domain_documents( + &contract, + identity_id, + bad, + [3u8; 32], + [4u8; 32], + ); + assert!( + matches!(result, Err(Error::InvalidInput(_))), + "label {bad:?} must be rejected" + ); + } + } + + /// Consecutive hyphens are consensus-valid (the DPNS contract pattern + /// `^[a-zA-Z0-9][a-zA-Z0-9-]{0,61}[a-zA-Z0-9]$` allows them), so the + /// builder must accept them even though the stricter client-side + /// [`is_valid_username`] policy rejects them. + #[test] + fn build_dpns_documents_accepts_consensus_valid_double_hyphen() { + let contract = dpns_contract(); + let identity_id = Identifier::from([2u8; 32]); + + assert!(is_consensus_valid_label("alice--bob")); + assert!(!is_valid_username("alice--bob")); + build_dpns_preorder_and_domain_documents( + &contract, + identity_id, + "alice--bob", + [3u8; 32], + [4u8; 32], + ) + .expect("consensus-valid label with consecutive hyphens must build"); + } #[test] fn test_convert_to_homograph_safe_chars() { diff --git a/packages/dash-platform-queries/src/error.rs b/packages/dash-platform-queries/src/error.rs index 0d8727763c..f30573c3a7 100644 --- a/packages/dash-platform-queries/src/error.rs +++ b/packages/dash-platform-queries/src/error.rs @@ -15,6 +15,12 @@ pub enum Error { /// Query is not configured properly for the target platform version #[error("SDK misconfigured: {0}")] Config(String), + /// Input to a document builder failed validation (bad label, wrong + /// ciphertext length, unknown document type, ...). `dash-sdk` maps this + /// to its `Error::Generic`, preserving the messages these checks + /// produced before they moved here. + #[error("{0}")] + InvalidInput(String), /// Drive error #[error("Drive error: {0}")] Drive(#[from] drive::error::Error), @@ -23,6 +29,22 @@ pub enum Error { Protocol(#[from] ProtocolError), } +impl From for Error { + fn from(value: crate::documents::proto_conversions::DecodeError) -> Self { + use crate::documents::proto_conversions::DecodeError; + match value { + // Malformed wire bytes — a decoding failure, not a + // misconfiguration. + DecodeError::InvalidArgument(msg) => Self::Protocol(ProtocolError::DecodingError(msg)), + // Well-formed wire shape the decode target can't express + // yet — same classification the server gives it. + DecodeError::Unsupported(msg) => Self::Drive(drive::error::Error::Query( + drive::error::query::QuerySyntaxError::Unsupported(msg), + )), + } + } +} + 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 9da47cf643..29b71b8f43 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/dash-platform-queries/src/transition/mod.rs b/packages/dash-platform-queries/src/transition/mod.rs index 3a0f1376ad..097952677c 100644 --- a/packages/dash-platform-queries/src/transition/mod.rs +++ b/packages/dash-platform-queries/src/transition/mod.rs @@ -1,2 +1,3 @@ //! Transport-free state transition helpers. +pub mod put_document; pub mod validation; diff --git a/packages/dash-platform-queries/src/transition/put_document.rs b/packages/dash-platform-queries/src/transition/put_document.rs new file mode 100644 index 0000000000..3449bb5e67 --- /dev/null +++ b/packages/dash-platform-queries/src/transition/put_document.rs @@ -0,0 +1,176 @@ +//! Transport-free helpers for document create/replace transitions. +//! +//! `dash-sdk`'s `PutDocument` broadcast path calls these; embedders that +//! assemble their own transitions share the same preparation and +//! entropy/id consistency check. + +use crate::Error; +use dpp::data_contract::document_type::methods::DocumentTypeV0Methods; +use dpp::data_contract::document_type::DocumentType; +use dpp::document::{Document, DocumentV0Getters}; +use dpp::prelude::Identifier; + +/// Returns a copy of `document` with its properties sanitized for the given +/// document type (e.g. integer arrays coerced back into byte arrays after a +/// WASM boundary crossing), leaving the caller's document untouched. +pub fn prepare_document_for_transition( + document: &Document, + document_type: &DocumentType, +) -> Document { + let mut document = document.clone(); + document_type + .as_ref() + .sanitize_document_properties(document.properties_mut()); + 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 the +/// broadcast path trusts the caller's id verbatim when entropy is supplied, +/// 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. +pub 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::InvalidInput(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, INITIAL_REVISION}; + use dpp::platform_value::{platform_value, Value}; + use dpp::version::PlatformVersion; + use std::collections::BTreeMap; + + fn contract_id() -> Identifier { + Identifier::from([1u8; 32]) + } + + fn owner_id() -> Identifier { + 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::InvalidInput(_))), + "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(); + let config = DataContractConfig::default_for_version(platform_version) + .expect("should create default data contract config"); + let document_type = DocumentType::try_from_schema( + contract_id(), + 1, + config.version(), + "preorder", + platform_value!({ + "type": "object", + "properties": { + "saltedDomainHash": { + "type": "array", + "byteArray": true, + "minItems": 32_u32, + "maxItems": 32_u32, + "position": 0 + } + }, + "required": ["saltedDomainHash"], + "additionalProperties": false, + }), + None, + &BTreeMap::new(), + &config, + false, + &mut Vec::new(), + platform_version, + ) + .expect("should create DPNS-like document type"); + let integer_array = Value::Array(vec![Value::U64(7); 32]); + let document = Document::V0(DocumentV0 { + id: Identifier::new([3; 32]), + owner_id: owner_id(), + properties: BTreeMap::from([("saltedDomainHash".to_string(), integer_array.clone())]), + revision: Some(INITIAL_REVISION), + ..Default::default() + }); + + let prepared = prepare_document_for_transition(&document, &document_type); + + assert_eq!( + prepared.properties().get("saltedDomainHash"), + Some(&Value::Bytes32([7; 32])) + ); + assert_eq!( + document.properties().get("saltedDomainHash"), + Some(&integer_array) + ); + } +} diff --git a/packages/dash-platform-queries/tests/document_query_wire_roundtrip.rs b/packages/dash-platform-queries/tests/document_query_wire_roundtrip.rs new file mode 100644 index 0000000000..de1e95522e --- /dev/null +++ b/packages/dash-platform-queries/tests/document_query_wire_roundtrip.rs @@ -0,0 +1,470 @@ +//! Round-trip tests for the wire codec of [`DocumentQuery`]: +//! `DocumentQuery` → [`GetDocumentsRequest`] → +//! [`DocumentQuery::try_from_request`] must reproduce the original +//! query exactly, on both the V0 (CBOR clause) and V1 (typed proto +//! clause) wire encodings — this is what lets an embedder verify a +//! proved response against nothing but the request bytes it sent. + +use std::sync::Arc; + +use dapi_grpc::platform::v0::get_documents_request::get_documents_request_v0::Start; +use dapi_grpc::platform::v0::get_documents_request::{GetDocumentsRequestV0, Version}; +use dapi_grpc::platform::v0::GetDocumentsRequest; +use dash_platform_queries::documents::document_query::DocumentQuery; +use dash_platform_queries::Error; +use dpp::data_contract::accessors::v0::DataContractV0Getters; +use dpp::platform_value::Value; +use dpp::prelude::DataContract; +use dpp::tests::fixtures::get_data_contract_fixture; +use dpp::version::PlatformVersion; +use drive::query::{DriveDocumentQuery, OrderClause, SelectProjection, WhereClause, WhereOperator}; + +fn test_contract() -> Arc { + let platform_version = PlatformVersion::latest(); + Arc::new( + get_data_contract_fixture(None, 0, platform_version.protocol_version).data_contract_owned(), + ) +} + +/// A protocol version whose `drive_abci.query.document_query` +/// feature-version is `0` — encodes onto the V0 (CBOR-clause) wire. +fn v0_platform_version() -> &'static PlatformVersion { + let version = PlatformVersion::get(1).expect("protocol version 1 exists"); + assert_eq!( + version + .drive_abci + .query + .document_query + .default_current_version, + 0, + "protocol version 1 should encode the V0 documents wire" + ); + version +} + +/// The latest protocol version — encodes onto the V1 (typed proto +/// clause) wire. +fn v1_platform_version() -> &'static PlatformVersion { + let version = PlatformVersion::latest(); + assert_eq!( + version + .drive_abci + .query + .document_query + .default_current_version, + 1, + "latest protocol version should encode the V1 documents wire" + ); + version +} + +fn roundtrip(query: &DocumentQuery, platform_version: &PlatformVersion) -> DocumentQuery { + let contract = Arc::clone(&query.data_contract); + let request = query + .clone() + .try_into_request_for_version(platform_version) + .expect("query should encode onto the wire"); + DocumentQuery::try_from_request(request, contract) + .expect("wire request should decode back into a query") +} + +#[test] +fn v1_roundtrip_documents_query_full_surface() { + let contract = test_contract(); + let mut query = DocumentQuery::new(Arc::clone(&contract), "niceDocument") + .expect("document type exists") + .with_where(WhereClause { + field: "firstName".to_string(), + operator: WhereOperator::Equal, + value: Value::Text("Alice".to_string()), + }) + .with_where(WhereClause { + field: "age".to_string(), + operator: WhereOperator::In, + value: Value::Array(vec![Value::U64(21), Value::U64(42)]), + }) + .with_where(WhereClause { + field: "balance".to_string(), + operator: WhereOperator::GreaterThan, + value: Value::I64(-5), + }) + .with_order_by(OrderClause { + field: "firstName".to_string(), + ascending: true, + }) + .with_order_by(OrderClause { + field: "age".to_string(), + ascending: false, + }) + .with_limit(42) + .with_offset(7); + query.start = Some(Start::StartAt(vec![1u8; 32])); + + assert_eq!(roundtrip(&query, v1_platform_version()), query); +} + +#[test] +fn v1_roundtrip_start_after() { + let contract = test_contract(); + let mut query = DocumentQuery::new(contract, "niceDocument").expect("document type exists"); + query.start = Some(Start::StartAfter(vec![2u8; 32])); + + assert_eq!(roundtrip(&query, v1_platform_version()), query); +} + +#[test] +fn v1_roundtrip_grouped_count() { + let contract = test_contract(); + let query = DocumentQuery::new(contract, "niceDocument") + .expect("document type exists") + .with_select(SelectProjection::count_star()) + .with_group_by("age") + .with_where(WhereClause { + field: "age".to_string(), + operator: WhereOperator::GreaterThanOrEquals, + value: Value::U64(18), + }) + .with_limit(5); + + assert_eq!(roundtrip(&query, v1_platform_version()), query); +} + +#[test] +fn v0_roundtrip_documents_query() { + let contract = test_contract(); + let mut query = DocumentQuery::new(Arc::clone(&contract), "niceDocument") + .expect("document type exists") + .with_where(WhereClause { + field: "firstName".to_string(), + operator: WhereOperator::Equal, + value: Value::Text("Alice".to_string()), + }) + .with_where(WhereClause { + field: "age".to_string(), + operator: WhereOperator::In, + value: Value::Array(vec![Value::U64(21), Value::U64(42)]), + }) + .with_where(WhereClause { + field: "balance".to_string(), + operator: WhereOperator::GreaterThan, + value: Value::I64(-5), + }) + .with_order_by(OrderClause { + field: "firstName".to_string(), + ascending: true, + }) + .with_order_by(OrderClause { + field: "age".to_string(), + ascending: false, + }) + .with_limit(10); + query.start = Some(Start::StartAfter(vec![3u8; 32])); + + let request = query + .clone() + .try_into_request_for_version(v0_platform_version()) + .expect("query should encode onto the V0 wire"); + assert!( + matches!(request.version, Some(Version::V0(_))), + "protocol version 1 must produce the V0 wire shape" + ); + let decoded = DocumentQuery::try_from_request(request, contract) + .expect("V0 wire request should decode back into a query"); + assert_eq!(decoded, query); +} + +#[test] +fn v0_rejects_malformed_where_cbor() { + let contract = test_contract(); + let request = GetDocumentsRequest { + version: Some(Version::V0(GetDocumentsRequestV0 { + data_contract_id: contract.id().to_vec(), + document_type: "niceDocument".to_string(), + r#where: vec![0x9F], // truncated CBOR array + order_by: vec![], + limit: 0, + prove: true, + start: None, + })), + }; + + let error = DocumentQuery::try_from_request(request, contract) + .expect_err("truncated where CBOR must be rejected"); + assert!( + error.to_string().contains("unable to decode 'where' query"), + "unexpected error: {error}" + ); +} + +#[test] +fn v1_rejects_unknown_where_operator() { + let contract = test_contract(); + let query = DocumentQuery::new(Arc::clone(&contract), "niceDocument") + .expect("document type exists") + .with_where(WhereClause { + field: "firstName".to_string(), + operator: WhereOperator::Equal, + value: Value::Text("Alice".to_string()), + }); + let mut request = query + .try_into_request_for_version(v1_platform_version()) + .expect("query should encode onto the wire"); + let Some(Version::V1(request_v1)) = &mut request.version else { + panic!("expected the V1 wire shape"); + }; + request_v1.where_clauses[0].operator = 99; + + let error = DocumentQuery::try_from_request(request, contract) + .expect_err("unknown operator discriminant must be rejected"); + assert!( + error + .to_string() + .contains("unknown WhereOperator discriminant: 99"), + "unexpected error: {error}" + ); +} + +#[test] +fn v1_rejects_explicit_zero_limit() { + let contract = test_contract(); + let query = + DocumentQuery::new(Arc::clone(&contract), "niceDocument").expect("document type exists"); + let mut request = query + .try_into_request_for_version(v1_platform_version()) + .expect("query should encode onto the wire"); + let Some(Version::V1(request_v1)) = &mut request.version else { + panic!("expected the V1 wire shape"); + }; + request_v1.limit = Some(0); + + let error = DocumentQuery::try_from_request(request, contract) + .expect_err("explicit zero limit must be rejected, mirroring the server"); + assert!( + error.to_string().contains("limit = 0"), + "unexpected error: {error}" + ); +} + +#[test] +fn v1_rejects_multi_projection_select() { + let contract = test_contract(); + let query = + DocumentQuery::new(Arc::clone(&contract), "niceDocument").expect("document type exists"); + let mut request = query + .try_into_request_for_version(v1_platform_version()) + .expect("query should encode onto the wire"); + let Some(Version::V1(request_v1)) = &mut request.version else { + panic!("expected the V1 wire shape"); + }; + let extra_select = request_v1.selects[0].clone(); + request_v1.selects.push(extra_select); + + let error = DocumentQuery::try_from_request(request, contract) + .expect_err("multi-projection SELECT must be rejected"); + assert!( + error.to_string().contains("multi-projection SELECT"), + "unexpected error: {error}" + ); +} + +#[test] +fn rejects_contract_mismatch() { + let contract = test_contract(); + let query = + DocumentQuery::new(Arc::clone(&contract), "niceDocument").expect("document type exists"); + let mut request = query + .try_into_request_for_version(v1_platform_version()) + .expect("query should encode onto the wire"); + let Some(Version::V1(request_v1)) = &mut request.version else { + panic!("expected the V1 wire shape"); + }; + request_v1.data_contract_id = vec![9u8; 32]; + + let error = DocumentQuery::try_from_request(request, contract) + .expect_err("mismatched contract id must be rejected"); + assert!( + matches!(error, Error::Protocol(_)), + "unexpected error: {error}" + ); + assert!( + error.to_string().contains("targets data contract"), + "unexpected error: {error}" + ); +} + +/// A `u32` wire limit above `u16::MAX` must be refused, not wrapped. +/// +/// `DriveDocumentQuery`'s limit is a `u16`; the server rejects anything +/// larger with `InvalidLimit` and so never proves such a query. An `as` +/// cast would silently turn a request for 65537 documents into a +/// 1-document query, and a proof for *that* query would then verify — +/// binding the proof to something the caller never asked for. +#[test] +fn limit_above_u16_max_is_rejected_not_truncated() { + let contract = test_contract(); + let query = DocumentQuery::new(contract, "niceDocument") + .expect("document type exists") + .with_limit(u16::MAX as u32 + 2); + + let error = DriveDocumentQuery::try_from(&query) + .expect_err("a limit above u16::MAX must not be silently truncated"); + assert!( + error.to_string().contains("65537"), + "unexpected error: {error}" + ); +} + +/// Request-shape checks in [`verify_documents_response`]. +/// +/// Every field asserted here is dropped by the `DocumentQuery` → +/// `DriveDocumentQuery` lowering, so without an explicit rejection an +/// untrusted transport could pair a request the real server would have +/// refused with a genuine proof for the narrower query it lowers to. +/// +/// The provider panics on every call, which also pins *when* the +/// rejection happens: before any proof machinery runs. +mod verify_binds_the_whole_request { + use super::*; + use dapi_grpc::platform::v0::GetDocumentsResponse; + use dash_context_provider::{ContextProvider, ContextProviderError}; + use dash_platform_queries::documents::document_query::verify_documents_response; + use dpp::dashcore::Network; + use dpp::data_contract::associated_token::token_configuration::TokenConfiguration; + use dpp::prelude::{CoreBlockHeight, Identifier}; + use drive::query::{ + HavingAggregate, HavingAggregateFunction, HavingClause, HavingOperator, HavingRightOperand, + }; + + /// Fails the test if proof verification is reached at all. + struct NeverCalledProvider; + + impl ContextProvider for NeverCalledProvider { + fn get_data_contract( + &self, + _id: &Identifier, + _platform_version: &PlatformVersion, + ) -> Result>, ContextProviderError> { + panic!("request must be rejected before proof verification starts") + } + + fn get_token_configuration( + &self, + _token_id: &Identifier, + ) -> Result, ContextProviderError> { + panic!("request must be rejected before proof verification starts") + } + + fn get_quorum_public_key( + &self, + _quorum_type: u32, + _quorum_hash: [u8; 32], + _core_chain_locked_height: u32, + ) -> Result<[u8; 48], ContextProviderError> { + panic!("request must be rejected before proof verification starts") + } + + fn get_platform_activation_height(&self) -> Result { + panic!("request must be rejected before proof verification starts") + } + } + + /// Encode `query` onto the V1 wire, let `mutate` reshape the request + /// the way a hostile transport could, and return the rejection. + fn verify_error( + query: DocumentQuery, + mutate: impl FnOnce(&mut GetDocumentsRequest), + ) -> drive_proof_verifier::Error { + let contract = Arc::clone(&query.data_contract); + let mut request = query + .try_into_request_for_version(v1_platform_version()) + .expect("query should encode onto the wire"); + mutate(&mut request); + + verify_documents_response( + request, + contract, + GetDocumentsResponse::default(), + Network::Testnet, + PlatformVersion::latest(), + &NeverCalledProvider, + ) + .expect_err("the reshaped request must be rejected") + } + + fn documents_query() -> DocumentQuery { + DocumentQuery::new(test_contract(), "niceDocument").expect("document type exists") + } + + /// The server refuses GROUP BY under SELECT DOCUMENTS + /// (`validate_and_route`), so no proved document response can + /// belong to such a request. + #[test] + fn rejects_group_by() { + let error = verify_error(documents_query().with_group_by("age"), |_| {}); + assert!( + error.to_string().contains("GROUP BY"), + "unexpected error: {error}" + ); + } + + /// The server refuses a non-empty HAVING for any non-aggregate + /// SELECT (`validate_and_route`). + #[test] + fn rejects_having() { + let query = documents_query().with_having(vec![HavingClause { + aggregate: HavingAggregate { + function: HavingAggregateFunction::Count, + field: String::new(), + }, + operator: HavingOperator::GreaterThan, + right: HavingRightOperand::Value(Value::U64(0)), + }]); + let error = verify_error(query, |_| {}); + assert!( + error.to_string().contains("HAVING"), + "unexpected error: {error}" + ); + } + + /// The server accepts OFFSET only on the ranked surface + /// (`reject_offset_off_the_ranked_path`); a document fetch never + /// routes there. + #[test] + fn rejects_offset() { + let error = verify_error(documents_query().with_offset(7), |_| {}); + assert!( + error.to_string().contains("OFFSET"), + "unexpected error: {error}" + ); + } + + /// `prove: false` makes an honest server answer without a proof, so + /// a proved response cannot belong to such a request. + #[test] + fn rejects_unproved_request() { + let error = verify_error(documents_query(), |request| { + let Some(Version::V1(v1)) = &mut request.version else { + panic!("expected the V1 wire shape"); + }; + v1.prove = false; + }); + assert!( + error.to_string().contains("prove=false"), + "unexpected error: {error}" + ); + } + + /// An aggregate projection is proved with a different proof shape; + /// the pre-existing guard stays alongside the new ones. + #[test] + fn rejects_aggregate_projection() { + let error = verify_error( + documents_query().with_select(SelectProjection::count_star()), + |_| {}, + ); + assert!( + error.to_string().contains("plain document fetches"), + "unexpected error: {error}" + ); + } +} diff --git a/packages/rs-drive-abci/Cargo.toml b/packages/rs-drive-abci/Cargo.toml index 23069f0158..668415ae2a 100644 --- a/packages/rs-drive-abci/Cargo.toml +++ b/packages/rs-drive-abci/Cargo.toml @@ -42,6 +42,7 @@ dapi-grpc = { path = "../dapi-grpc", default-features = false, features = [ "server", "platform", ] } +dash-platform-queries = { path = "../dash-platform-queries", default-features = false } tracing-subscriber = { version = "0.3.22", default-features = false, features = [ "env-filter", "ansi", diff --git a/packages/rs-drive-abci/src/query/document_query/v1/conversions.rs b/packages/rs-drive-abci/src/query/document_query/v1/conversions.rs index fa5c4da140..3f67747e5d 100644 --- a/packages/rs-drive-abci/src/query/document_query/v1/conversions.rs +++ b/packages/rs-drive-abci/src/query/document_query/v1/conversions.rs @@ -1,367 +1,77 @@ //! Wire-protobuf → drive type conversions for the v1 document //! query surface. //! -//! Lives next to the v1 handler because rs-drive-abci is the only -//! crate that needs the proto-decode direction (the SDK ships the -//! inverse direction in -//! `rs-sdk/src/platform/documents/document_query.rs`). Keeping the -//! two directions in their respective crates avoids forcing -//! `dapi-grpc` into rs-drive's dependency graph just to host shared -//! conversion code. -//! -//! Conversion contract: -//! - Every fallible case maps to [`QueryError::InvalidArgument`] -//! (malformed wire input, **not** future capability). The v1 -//! handler distinguishes this from -//! [`QuerySyntaxError::Unsupported`] (valid request shape, server -//! capability not yet wired) — see `v1/mod.rs`'s +//! The decode logic itself lives in +//! `dash_platform_queries::documents::proto_conversions`, shared +//! with the client-side `DocumentQuery::try_from_request` so the +//! bytes the server decodes and the bytes the proof verifier decodes +//! cannot drift. This module only maps the shared crate's neutral +//! [`DecodeError`] onto this crate's [`QueryError`] surface: +//! - [`DecodeError::InvalidArgument`] (malformed wire input) → +//! [`QueryError::InvalidArgument`]. The v1 handler distinguishes +//! this from [`QuerySyntaxError::Unsupported`] (valid request +//! shape, server capability not yet wired) — see `v1/mod.rs`'s //! `not_yet_implemented` helper. -//! - Conversion is schema-agnostic. `DocumentFieldValue` variants -//! map 1:1 to `dpp::platform_value::Value` variants without -//! consulting the document type's schema. The schema-driven -//! coercion (`document_type.serialize_value_for_key`) runs -//! downstream as it does for the CBOR-shaped v0 path — a `text` -//! variant against an identifier field decodes via base58, a -//! `bytes_value` against the same field decodes as raw 32-byte -//! identifier, and so on. The wire layer just names the -//! primitive; the schema decides the indexed type. +//! - [`DecodeError::Unsupported`] (well-formed shape the decoder +//! deliberately refuses, e.g. `ORDER BY` on aggregate keys) → +//! [`QueryError::Query`]\([`QuerySyntaxError::Unsupported`]\). +//! +//! Both mappings preserve the exact message strings this module +//! produced when it owned the decode logic, so the server's error +//! surface is unchanged. use crate::error::query::QueryError; use dapi_grpc::platform::v0::get_documents_request::{ - document_field_value, - get_documents_request_v1::{select, Select as ProtoSelect}, - having_aggregate, having_clause, order_clause, DocumentFieldValue as ProtoDocumentFieldValue, - HavingAggregate as ProtoHavingAggregate, HavingClause as ProtoHavingClause, + get_documents_request_v1::Select as ProtoSelect, HavingClause as ProtoHavingClause, OrderClause as ProtoOrderClause, WhereClause as ProtoWhereClause, - WhereOperator as ProtoWhereOperator, }; -use dpp::platform_value::Value; -use drive::query::{ - HavingAggregate, HavingAggregateFunction, HavingClause, HavingOperator, HavingRightOperand, - OrderClause, SelectFunction, SelectProjection, WhereClause, WhereOperator, -}; - -/// Map a wire-level [`ProtoWhereOperator`] discriminant onto -/// drive's [`WhereOperator`]. Unknown discriminants are wire-level -/// garbage (no future protocol value would map a malformed integer -/// to a valid behavior), so they surface as -/// [`QueryError::InvalidArgument`] — not `not_yet_implemented`. -pub(super) fn where_operator_from_proto(op: i32) -> Result { - let proto_op = ProtoWhereOperator::try_from(op).map_err(|_| { - QueryError::InvalidArgument(format!( - "unknown WhereOperator discriminant: {} (valid values: 0..=10, see \ - `get_documents_request::WhereOperator`)", - op - )) - })?; - Ok(match proto_op { - ProtoWhereOperator::Equal => WhereOperator::Equal, - ProtoWhereOperator::GreaterThan => WhereOperator::GreaterThan, - ProtoWhereOperator::GreaterThanOrEquals => WhereOperator::GreaterThanOrEquals, - ProtoWhereOperator::LessThan => WhereOperator::LessThan, - ProtoWhereOperator::LessThanOrEquals => WhereOperator::LessThanOrEquals, - ProtoWhereOperator::Between => WhereOperator::Between, - ProtoWhereOperator::BetweenExcludeBounds => WhereOperator::BetweenExcludeBounds, - ProtoWhereOperator::BetweenExcludeLeft => WhereOperator::BetweenExcludeLeft, - ProtoWhereOperator::BetweenExcludeRight => WhereOperator::BetweenExcludeRight, - ProtoWhereOperator::In => WhereOperator::In, - ProtoWhereOperator::StartsWith => WhereOperator::StartsWith, - }) -} - -/// Map a wire [`ProtoDocumentFieldValue`] onto a -/// `dpp::platform_value::Value`. Schema-agnostic — variants map -/// 1:1 by primitive type and recurse for `list` up to a depth of -/// 1 (the only nesting level the query surface needs: `IN` / -/// `BETWEEN*` take a flat list of scalars). Anything deeper is -/// rejected as malformed wire input rather than recursed into, -/// so a hostile client can't blow the call stack with -/// `list(list(list(...)))` before schema validation. -/// -/// `None` (oneof unset on the wire) is rejected — a where-clause -/// operand is always concrete; empty where-clauses are expressed -/// by an empty `where_clauses` field at the request level, not by -/// sending an empty `DocumentFieldValue`. -pub(super) fn value_from_proto(value: ProtoDocumentFieldValue) -> Result { - value_from_proto_at_depth(value, 0) -} - -/// Recursion-bounded form of [`value_from_proto`]. `depth = 0` is -/// the request-level operand; the only legal child shape is a -/// flat list (`depth = 1` for `IN` / `BETWEEN*` candidates), so a -/// `list` encountered at `depth >= 1` is wire-malformed. -fn value_from_proto_at_depth( - value: ProtoDocumentFieldValue, - depth: u8, -) -> Result { - let variant = value.variant.ok_or_else(|| { - QueryError::InvalidArgument( - "DocumentFieldValue has no variant set; a where-clause operand must \ - be a concrete value" - .to_string(), - ) - })?; - Ok(match variant { - document_field_value::Variant::BoolValue(b) => Value::Bool(b), - document_field_value::Variant::Int64Value(i) => Value::I64(i), - document_field_value::Variant::Uint64Value(u) => Value::U64(u), - document_field_value::Variant::DoubleValue(f) => Value::Float(f), - document_field_value::Variant::Text(s) => Value::Text(s), - document_field_value::Variant::BytesValue(b) => Value::Bytes(b), - document_field_value::Variant::List(list) => { - if depth >= 1 { - return Err(QueryError::InvalidArgument( - "nested DocumentFieldValue.list is not supported; the v1 \ - query surface accepts at most one level of nesting \ - (`IN` / `BETWEEN*` candidate lists of scalars)" - .to_string(), - )); - } - Value::Array( - list.values - .into_iter() - .map(|v| value_from_proto_at_depth(v, depth + 1)) - .collect::, _>>()?, - ) - } - // The bool payload is a placeholder — picking the - // `null_value` variant means "this operand is null" and - // the bool itself is ignored. See the proto-side comment - // on the field for the rationale. - document_field_value::Variant::NullValue(_) => Value::Null, - }) -} - -/// Map a wire [`ProtoWhereClause`] onto drive's structured -/// [`WhereClause`]. Errors surface as -/// [`QueryError::InvalidArgument`] for both operator-discriminant -/// and value-shape failures. -pub(super) fn where_clause_from_proto(clause: ProtoWhereClause) -> Result { - let operator = where_operator_from_proto(clause.operator)?; - let value = clause.value.ok_or_else(|| { - QueryError::InvalidArgument(format!( - "WhereClause on field '{}' has no value set; every clause must carry a \ - concrete `DocumentFieldValue`", - clause.field - )) - })?; - let value = value_from_proto(value)?; - Ok(WhereClause { - field: clause.field, - operator, - value, - }) +use dash_platform_queries::documents::proto_conversions::{self as shared, DecodeError}; +use drive::error::query::QuerySyntaxError; +use drive::query::{HavingClause, OrderClause, SelectProjection, WhereClause}; + +fn map_decode_error(error: DecodeError) -> QueryError { + match error { + DecodeError::InvalidArgument(msg) => QueryError::InvalidArgument(msg), + DecodeError::Unsupported(msg) => QueryError::Query(QuerySyntaxError::Unsupported(msg)), + } } -/// Plural form of [`where_clause_from_proto`] for the request-level -/// `repeated WhereClause` field. Returns an error on the first -/// malformed clause; the v1 handler surfaces this through +/// Decode the request-level `repeated WhereClause` field via the +/// shared decoder. Returns an error on the first malformed clause; +/// the v1 handler surfaces this through /// `QueryValidationResult::new_with_error` so the caller sees the /// rejection on the same response shape as a downstream validation /// failure. pub(super) fn where_clauses_from_proto( clauses: Vec, ) -> Result, QueryError> { - clauses.into_iter().map(where_clause_from_proto).collect() + shared::where_clauses_from_proto(clauses).map_err(map_decode_error) } -/// Map a wire [`ProtoOrderClause`] onto drive's [`OrderClause`]. -/// -/// The `target` oneof currently has two variants on the wire: -/// `field` (plain column name — evaluated today) and `aggregate` -/// (aggregate function applied to a field — wire-only, rejected -/// at routing time with `Unsupported("ORDER BY on aggregate …")`). -/// Unset (`None`) is rejected as malformed wire input. -pub(super) fn order_clause_from_proto(clause: ProtoOrderClause) -> Result { - let ascending = clause.ascending; - match clause.target { - Some(order_clause::Target::Field(field)) => Ok(OrderClause { field, ascending }), - Some(order_clause::Target::Aggregate(_)) => Err(QueryError::Query( - drive::error::query::QuerySyntaxError::Unsupported( - "ORDER BY on aggregate keys is not yet implemented".to_string(), - ), - )), - None => Err(QueryError::InvalidArgument( - "OrderClause has no target set; every clause must carry either a \ - `field` (plain column name) or an `aggregate` (aggregate-function \ - ordering target)" - .to_string(), - )), - } -} - -/// Plural form of [`order_clause_from_proto`] for the request-level -/// `repeated OrderClause` field. Returns the first error -/// encountered. +/// Decode the request-level `repeated OrderClause` field via the +/// shared decoder. Aggregate ordering targets are rejected with +/// `Unsupported("ORDER BY on aggregate keys is not yet implemented")`. pub(super) fn order_clauses_from_proto( clauses: Vec, ) -> Result, QueryError> { - clauses.into_iter().map(order_clause_from_proto).collect() -} - -// The `having_*_from_proto` family below decodes clauses the server -// then refuses: `having` evaluation is not implemented, so every -// non-empty HAVING is rejected at routing. Decoding still runs first -// (see `query_documents_v1`) so wire-malformed clauses surface as -// `InvalidArgument` rather than being masked by the capability -// rejection. The inner helpers keep a per-function -// `#[allow(dead_code)]` — rather than module-wide — so any future -// addition outside this family still trips the lint. - -/// Map a wire [`having_aggregate::Function`] discriminant onto -/// drive's [`HavingAggregateFunction`]. Unknown discriminants are -/// wire-level garbage (no future protocol value would map a -/// malformed integer to a valid behavior), so they surface as -/// [`QueryError::InvalidArgument`]. -#[allow(dead_code)] -fn having_function_from_proto(function: i32) -> Result { - let proto = having_aggregate::Function::try_from(function).map_err(|_| { - QueryError::InvalidArgument(format!( - "unknown HavingAggregate.Function discriminant: {} (valid values: 0..=2, see \ - `get_documents_request::having_aggregate::Function`)", - function - )) - })?; - Ok(match proto { - having_aggregate::Function::Count => HavingAggregateFunction::Count, - having_aggregate::Function::Sum => HavingAggregateFunction::Sum, - having_aggregate::Function::Avg => HavingAggregateFunction::Avg, - }) -} - -/// Map a wire [`having_clause::Operator`] discriminant onto -/// drive's [`HavingOperator`]. Same error contract as -/// [`having_function_from_proto`]. -#[allow(dead_code)] -fn having_operator_from_proto(operator: i32) -> Result { - let proto = having_clause::Operator::try_from(operator).map_err(|_| { - QueryError::InvalidArgument(format!( - "unknown HavingClause.Operator discriminant: {} (valid values: 0..=10, see \ - `get_documents_request::having_clause::Operator`)", - operator - )) - })?; - Ok(match proto { - having_clause::Operator::Equal => HavingOperator::Equal, - having_clause::Operator::NotEqual => HavingOperator::NotEqual, - having_clause::Operator::GreaterThan => HavingOperator::GreaterThan, - having_clause::Operator::GreaterThanOrEquals => HavingOperator::GreaterThanOrEquals, - having_clause::Operator::LessThan => HavingOperator::LessThan, - having_clause::Operator::LessThanOrEquals => HavingOperator::LessThanOrEquals, - having_clause::Operator::Between => HavingOperator::Between, - having_clause::Operator::BetweenExcludeBounds => HavingOperator::BetweenExcludeBounds, - having_clause::Operator::BetweenExcludeLeft => HavingOperator::BetweenExcludeLeft, - having_clause::Operator::BetweenExcludeRight => HavingOperator::BetweenExcludeRight, - having_clause::Operator::In => HavingOperator::In, - }) + shared::order_clauses_from_proto(clauses).map_err(map_decode_error) } -/// Map a wire [`ProtoHavingAggregate`] onto drive's -/// [`HavingAggregate`]. The aggregate-function ↔ field -/// consistency check (`field` required for everything except -/// `Count`) runs inside the evaluator when HAVING execution -/// lands; the converter only enforces that the proto shape is -/// well-formed. -#[allow(dead_code)] -fn having_aggregate_from_proto( - aggregate: ProtoHavingAggregate, -) -> Result { - Ok(HavingAggregate { - function: having_function_from_proto(aggregate.function)?, - field: aggregate.field, - }) -} - -/// Map a wire [`ProtoHavingClause`] onto drive's structured -/// [`HavingClause`]. Errors surface as -/// [`QueryError::InvalidArgument`] for any wire-level -/// malformation: unknown discriminant on the aggregate function or -/// operator; missing aggregate; missing right operand (oneof unset -/// on the wire); inner value-shape failures on the literal-value -/// branch. -/// -/// `HAVING` is a boolean per-group predicate and nothing else, so the -/// wire's `right` oneof has exactly one arm and this function has -/// exactly one thing to decode. Cross-group ranking is expressed with -/// SQL's own ordering surface — `ORDER BY DESC -/// LIMIT n [OFFSET m]` — which arrives as an `OrderClause` and never -/// reaches here. -#[allow(dead_code)] -pub(super) fn having_clause_from_proto( - clause: ProtoHavingClause, -) -> Result { - let aggregate = clause.aggregate.ok_or_else(|| { - QueryError::InvalidArgument( - "HavingClause has no aggregate set; every clause must carry an \ - aggregate function + field operand" - .to_string(), - ) - })?; - let aggregate = having_aggregate_from_proto(aggregate)?; - let operator = having_operator_from_proto(clause.operator)?; - let right = clause.right.ok_or_else(|| { - QueryError::InvalidArgument( - "HavingClause has no right operand set; every clause must carry a \ - concrete `DocumentFieldValue` (`right.value`)" - .to_string(), - ) - })?; - let right = match right { - having_clause::Right::Value(v) => HavingRightOperand::Value(value_from_proto(v)?), - }; - Ok(HavingClause { - aggregate, - operator, - right, - }) -} - -/// Plural form of [`having_clause_from_proto`] for the request- -/// level `repeated HavingClause` field. Returns an error on the -/// first malformed clause. -#[allow(dead_code)] +/// Decode the request-level `repeated HavingClause` field via the +/// shared decoder. Decoding runs before the capability rejection +/// (HAVING evaluation is not implemented) so wire-malformed clauses +/// surface as `InvalidArgument` rather than being masked by the +/// blanket "not yet implemented". pub(super) fn having_clauses_from_proto( clauses: Vec, ) -> Result, QueryError> { - clauses.into_iter().map(having_clause_from_proto).collect() -} - -/// Map a wire [`select::Function`] discriminant onto drive's -/// [`SelectFunction`]. Unknown discriminants are wire-level -/// garbage (no future protocol value would map a malformed -/// integer to a valid behavior), so they surface as -/// [`QueryError::InvalidArgument`]. -fn select_function_from_proto(function: i32) -> Result { - let proto = select::Function::try_from(function).map_err(|_| { - QueryError::InvalidArgument(format!( - "unknown Select.Function discriminant: {} (valid values: 0..=5, see \ - `get_documents_request::get_documents_request_v1::select::Function`)", - function - )) - })?; - Ok(match proto { - select::Function::Documents => SelectFunction::Documents, - select::Function::Count => SelectFunction::Count, - select::Function::Sum => SelectFunction::Sum, - select::Function::Avg => SelectFunction::Avg, - select::Function::Min => SelectFunction::Min, - select::Function::Max => SelectFunction::Max, - }) + shared::having_clauses_from_proto(clauses).map_err(map_decode_error) } -/// Map a wire [`ProtoSelect`] onto drive's [`SelectProjection`]. -/// An unset `select` field on the request decodes as the proto- -/// default `Select { function: DOCUMENTS, field: "" }`, which -/// maps to [`SelectProjection::documents()`] — keeps callers that -/// don't set the field on the v0-style document-fetch path. -/// -/// Per-function field constraints (e.g. `DOCUMENTS` must have -/// empty `field`, `SUM`/`AVG` require non-empty) are checked at -/// routing time in `validate_and_route`, not here, so the -/// converter only enforces well-formed proto. +/// Decode a wire `Select` into drive's [`SelectProjection`] via the +/// shared decoder. Per-function field constraints (e.g. `DOCUMENTS` +/// must have empty `field`, `SUM`/`AVG` require non-empty) are +/// checked at routing time in `validate_and_route`, not here. pub(super) fn select_from_proto(select: ProtoSelect) -> Result { - Ok(SelectProjection { - function: select_function_from_proto(select.function)?, - field: select.field, - }) + shared::select_from_proto(select).map_err(map_decode_error) } diff --git a/packages/rs-sdk/src/error.rs b/packages/rs-sdk/src/error.rs index cc8309ebcd..89ade69e74 100644 --- a/packages/rs-sdk/src/error.rs +++ b/packages/rs-sdk/src/error.rs @@ -137,6 +137,9 @@ impl From for Error { fn from(value: dash_platform_queries::Error) -> Self { match value { dash_platform_queries::Error::Config(msg) => Self::Config(msg), + // Builder input validation moved to the query core keeps surfacing + // as Generic with the exact messages it produced inside this crate. + dash_platform_queries::Error::InvalidInput(msg) => Self::Generic(msg), dash_platform_queries::Error::Drive(e) => Self::Drive(e), dash_platform_queries::Error::Protocol(e) => Self::Protocol(e), } diff --git a/packages/rs-sdk/src/platform/dashpay/contact_request.rs b/packages/rs-sdk/src/platform/dashpay/contact_request.rs index d595faaaed..fd2aaa395c 100644 --- a/packages/rs-sdk/src/platform/dashpay/contact_request.rs +++ b/packages/rs-sdk/src/platform/dashpay/contact_request.rs @@ -5,11 +5,13 @@ use crate::platform::transition::put_document::PutDocument; use crate::platform::Document; use crate::{Error, Sdk}; +use dash_platform_queries::dashpay::{ + build_contact_request_document, validate_auto_accept_proof, 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; @@ -259,14 +261,11 @@ impl Sdk { H: FnOnce(u32) -> Hut, Hut: std::future::Future, Error>>, { - // Validate auto accept proof size if provided + // Validate auto accept proof size if provided. The shared builder + // validates again, but checking here first keeps the failure local — + // before the recipient fetch and ECDH work below. 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() - ))); - } + validate_auto_accept_proof(proof)?; } // Fetch recipient identity if only ID was provided @@ -362,90 +361,45 @@ impl Sdk { let mut xpub_iv = [0u8; 16]; rng.fill_bytes(&mut xpub_iv); - // Encrypt the extended public key (includes IV prepended) + // Encrypt the extended public key (includes IV prepended). The shared + // builder rejects any ciphertext that isn't exactly 96 bytes + // (16-byte IV + 80-byte encrypted data). 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 { + // Encrypt the account label if provided (includes IV prepended). The + // shared builder rejects any ciphertext outside 48-80 bytes + // (16-byte IV + 32-64 byte encrypted data). + let encrypted_account_label = input.account_label.as_ref().map(|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 - }; + encrypt_account_label(&shared_key, &label_iv, label) + }); // 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 + // Assemble the document in the shared transport-free builder, so + // networked and embedder flows produce byte-identical documents. 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)); - } + let (document_id, properties) = build_contact_request_document( + &dashpay_contract, + ContactRequestDocumentParams { + sender_id, + 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 essential fields for the contact request, including the // entropy that derived `document_id` so the broadcast path can reuse it. diff --git a/packages/rs-sdk/src/platform/dashpay/mod.rs b/packages/rs-sdk/src/platform/dashpay/mod.rs index 182edd8854..a9e53b8778 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, validate_auto_accept_proof, 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 6de3e2950d..15021d9042 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_preorder_and_domain_documents, convert_to_homograph_safe_chars, + is_contested_username, is_valid_username, }; 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,95 +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 + // Assemble both documents in the shared transport-free builder, so + // networked and embedder flows produce byte-identical documents. + let (preorder_document, domain_document) = build_dpns_preorder_and_domain_documents( + &dpns_contract, + input.identity.id().to_owned(), + &input.label, + entropy.0, + salt, + )?; + 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 { - 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 { - 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 fa85a30a0d..75503ba142 100644 --- a/packages/rs-sdk/src/platform/transition/put_document.rs +++ b/packages/rs-sdk/src/platform/transition/put_document.rs @@ -3,15 +3,18 @@ use super::validation::ensure_valid_state_transition_structure; use super::waitable::Waitable; use crate::platform::transition::put_settings::PutSettings; use crate::{Error, Sdk}; +// Transport-free helpers shared with embedders; the implementations moved to +// `dash-platform-queries`. +pub use dash_platform_queries::transition::put_document::{ + ensure_entropy_matches_document_id, prepare_document_for_transition, +}; use dpp::dashcore::secp256k1::rand::rngs::StdRng; use dpp::dashcore::secp256k1::rand::{Rng, SeedableRng}; use dpp::data_contract::document_type::accessors::DocumentTypeV0Getters; -use dpp::data_contract::document_type::methods::DocumentTypeV0Methods; 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; @@ -162,162 +165,3 @@ impl> PutDocument for Document { Self::wait_for_response(sdk, state_transition, settings).await } } - -fn prepare_document_for_transition(document: &Document, document_type: &DocumentType) -> Document { - let mut document = document.clone(); - document_type - .as_ref() - .sanitize_document_properties(document.properties_mut()); - 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::version::PlatformVersion; - use std::collections::BTreeMap; - - fn contract_id() -> Identifier { - Identifier::from([1u8; 32]) - } - - fn owner_id() -> Identifier { - 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(); - let config = DataContractConfig::default_for_version(platform_version) - .expect("should create default data contract config"); - let document_type = DocumentType::try_from_schema( - contract_id(), - 1, - config.version(), - "preorder", - platform_value!({ - "type": "object", - "properties": { - "saltedDomainHash": { - "type": "array", - "byteArray": true, - "minItems": 32_u32, - "maxItems": 32_u32, - "position": 0 - } - }, - "required": ["saltedDomainHash"], - "additionalProperties": false, - }), - None, - &BTreeMap::new(), - &config, - false, - &mut Vec::new(), - platform_version, - ) - .expect("should create DPNS-like document type"); - let integer_array = Value::Array(vec![Value::U64(7); 32]); - let document = Document::V0(DocumentV0 { - id: Identifier::new([3; 32]), - owner_id: owner_id(), - properties: BTreeMap::from([("saltedDomainHash".to_string(), integer_array.clone())]), - revision: Some(INITIAL_REVISION), - ..Default::default() - }); - - let prepared = prepare_document_for_transition(&document, &document_type); - - assert_eq!( - prepared.properties().get("saltedDomainHash"), - Some(&Value::Bytes32([7; 32])) - ); - assert_eq!( - document.properties().get("saltedDomainHash"), - Some(&integer_array) - ); - } -}