diff --git a/Cargo.lock b/Cargo.lock index 92026cb5b5..f0a20a6cd5 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", 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 1587d9a58d..b4b71d1bab 100644 --- a/packages/dash-platform-queries/README.md +++ b/packages/dash-platform-queries/README.md @@ -39,17 +39,18 @@ If you want networking, retries, and a managed connection pool, use ## What's here -- `DocumentQuery` — rich document query builder with wire - encoding for both request versions. +- `DocumentQuery` — rich document query builder, wire encoding for both + request versions, and decoding **from** the wire request + (`DocumentQuery::try_from_request`) via decoders that mirror the server's + (`drive-abci`'s `v1/conversions.rs`) and are kept in lockstep with them. +- `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` helpers. ## 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..37d9bbf5fd --- /dev/null +++ b/packages/dash-platform-queries/src/dashpay.rs @@ -0,0 +1,143 @@ +//! 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)) +} diff --git a/packages/dash-platform-queries/src/documents/document_query.rs b/packages/dash-platform-queries/src/documents/document_query.rs index 113e6e1e47..5b9699a8dc 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::{ @@ -383,6 +384,516 @@ 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 a request whose wire version (the `V0`/`V1` oneof arm, +/// i.e. feature version 0/1) falls outside the supplied platform +/// version's `drive_abci.query.document_query` bounds. +/// +/// This is the same `check_version` gate the server runs before it +/// decodes anything (`Platform::query_documents` in rs-drive-abci, +/// which answers an out-of-bounds wire version with +/// `QueryError::UnsupportedQueryVersion`). Without it, an untrusted +/// transport could pair a request wire-version the supplied platform +/// version's server refuses to serve with a valid proof produced for +/// the other wire shape, and verification would accept the pair. +/// +/// A missing `version` oneof is deliberately let through — the decode +/// that follows reports it with its established error message. +fn check_wire_version_is_served( + request: &GetDocumentsRequest, + platform_version: &PlatformVersion, +) -> Result<(), drive_proof_verifier::Error> { + let Some(version) = &request.version else { + return Ok(()); + }; + let feature_version: u16 = match version { + V0(_) => 0, + V1(_) => 1, + }; + let bounds = &platform_version.drive_abci.query.document_query; + if !bounds.check_version(feature_version) { + return Err(drive_proof_verifier::Error::RequestError { + error: format!( + "GetDocumentsRequest wire version V{feature_version} is outside the \ + document_query feature-version bounds {}..={} served at platform version \ + {}; the server answers such a request with UnsupportedQueryVersion, so no \ + proved response can belong to it", + bounds.min_version, bounds.max_version, platform_version.protocol_version + ), + }); + } + Ok(()) +} + +/// 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. +/// +/// This runs inside the [`FromProof`] impl itself — the +/// shared choke point — so every `DocumentQuery`-keyed verification +/// gets it: `dash-sdk`'s own document fetches talk to the same +/// untrusted nodes an embedder's transport does, and are protected by +/// the same gates as the wire-request entry point +/// [`verify_documents_response`]. +/// +/// 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). +fn reject_request_the_server_would_not_have_proved( + query: &DocumentQuery, +) -> Result<(), drive_proof_verifier::Error> { + let reject = |error: String| Err(drive_proof_verifier::Error::RequestError { error }); + + // This path 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!( + "only a plain SELECT DOCUMENTS fetch can be verified here; the request carries \ + the projection {:?} — aggregate projections are verified by the aggregate \ + proof helpers", + query.select + )); + } + 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`], whose decoders mirror the +/// server's request decode) 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 +/// before any proof machinery runs — `prove` here on the wire request, +/// and the query-shape fields inside the shared +/// [`FromProof`] impl (so `dash-sdk`'s own fetches run +/// the identical gates) — mirroring the server's own gates in +/// `rs-drive-abci`'s `validate_and_route` / +/// `reject_offset_off_the_ranked_path`. +/// +/// The same reasoning covers the request envelope itself: the wire +/// version (`V0`/`V1` oneof arm) is checked against +/// `platform_version.drive_abci.query.document_query`'s bounds before +/// anything is decoded — the server's `query_documents` dispatch +/// refuses an out-of-bounds wire version with +/// `UnsupportedQueryVersion`, so a proof can never belong to one — and +/// the query limit contract is enforced during the +/// `DriveDocumentQuery` lowering exactly as +/// `DriveDocumentQuery::from_typed_clauses` enforces it server-side: +/// an omitted limit resolves to the server default and a limit above +/// [`DEFAULT_QUERY_LIMIT`] is rejected. +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> { + // First gate, mirroring the server's own dispatch order: a wire + // version the supplied platform version's server refuses to serve + // is rejected before any decoding or proof machinery. + check_wire_version_is_served(&request, platform_version)?; + // `prove` does not survive decoding (a `DocumentQuery` has no such + // field), so read it off the wire request before it is consumed. + // `prove: false` is not a server rejection — it makes the server + // return an unproved response, so a proved response cannot have + // come from one. + 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, + }; + if !prove { + return Err(drive_proof_verifier::Error::RequestError { + error: "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(), + }); + } + 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}"), + } + })?; + // The remaining request-shape gates + // (`reject_request_the_server_would_not_have_proved`) run inside the + // shared `FromProof` impl this delegates to. + >::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> { + // Same first gate as `verify_documents_response`, run here as well + // so an out-of-bounds wire version is rejected before the provider + // is asked for anything (the contract lookup below is already + // context-provider machinery). + check_wire_version_is_served(&request, platform_version)?; + 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 { @@ -439,6 +950,12 @@ impl FromProof for drive_proof_verifier::types::Documents { Self: Sized + 'a, { let request: Self::Request = request.into(); + // Server-parity request gates run here, at the shared choke + // point, so every `DocumentQuery`-keyed verification — dash-sdk + // fetches and the wire-request entry points alike — rejects + // request shapes no honest server would have proved before any + // proof machinery runs. + reject_request_the_server_would_not_have_proved(&request)?; let drive_query: DriveDocumentQuery = (&request) .try_into() diff --git a/packages/dash-platform-queries/src/documents/mod.rs b/packages/dash-platform-queries/src/documents/mod.rs index 65cde6af08..eb9d793817 100644 --- a/packages/dash-platform-queries/src/documents/mod.rs +++ b/packages/dash-platform-queries/src/documents/mod.rs @@ -11,5 +11,11 @@ pub mod document_split_counts; pub mod document_split_sums; pub mod document_sum; pub(crate) mod having_proof_helpers; +/// Client-side wire-proto → drive-type decoders for `getDocuments`, +/// consumed by +/// [`document_query::DocumentQuery::try_from_request`]. They mirror +/// rs-drive-abci's server request decode and must be kept in +/// lockstep with it (see the module docs). +pub(crate) 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..2328310f26 --- /dev/null +++ b/packages/dash-platform-queries/src/documents/proto_conversions.rs @@ -0,0 +1,377 @@ +//! Wire-protobuf → drive type conversions for the `getDocuments` +//! query surface, used by +//! [`DocumentQuery::try_from_request`](super::document_query::DocumentQuery::try_from_request) +//! to rebuild the rich query from the wire request so a proved +//! response can be verified against exactly what was asked. +//! +//! This is a client-side **mirror** of the server's decode — +//! rs-drive-abci's `query/document_query/v1/conversions.rs` — and +//! must be kept in lockstep with it, exactly as the v0 path's CBOR +//! clause decoding mirrors `query_documents_v0`. 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. Function bodies and error +//! message strings are copied verbatim from the server; any change +//! on either side must be replayed on the other. +//! +//! 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). These mirror the server's +//! `QueryError::InvalidArgument` / `QuerySyntaxError::Unsupported` +//! respectively, preserving its 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 proto → drive conversions. +/// +/// Mirrors the two error shapes the server's decode produces +/// (`QueryError::InvalidArgument` and `QuerySyntaxError::Unsupported`) +/// without depending on them; the client-side `DocumentQuery` +/// decoding maps it onto the crate [`Error`](crate::error::Error). +#[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(crate) 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(crate) 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(crate) 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(crate) 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(crate) 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..585ba8235b 100644 --- a/packages/dash-platform-queries/src/dpns_usernames.rs +++ b/packages/dash-platform-queries/src/dpns_usernames.rs @@ -1,8 +1,177 @@ //! 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`. +/// +/// # Salt secrecy and reveal order +/// +/// The preorder/domain split is DPNS's front-running protection: the +/// preorder commits to `saltedDomainHash` without revealing which name +/// is being registered, and only the later domain document discloses +/// the `label` and the `preorderSalt` that tie it to the commitment. +/// That protection holds only if the caller upholds what the networked +/// SDK (`dash-sdk`'s `register_dpns_name`) does automatically: +/// +/// - generate a **fresh 32-byte salt from a CSPRNG** for every +/// registration attempt (the SDK draws it from +/// `StdRng::from_entropy()`). A reused or predictable salt lets an +/// observer precompute `sha256d(salt ‖ ".dash")` for +/// candidate labels and identify — then front-run — the name from +/// the preorder alone; +/// - keep the salt, the label, and the assembled domain document +/// **private until the preorder create transition is confirmed** +/// (the SDK submits the preorder and waits for its response before +/// broadcasting the domain document). Revealing them earlier +/// discloses the name while it is still unclaimed, defeating the +/// commitment. +/// +/// Embedders driving their own transport inherit both obligations — +/// this builder takes `salt` as an argument precisely because it has +/// no randomness of its own and cannot enforce either one. +/// +/// 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 +187,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 +224,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) 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/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