From 5f69b2c7e78a7df2147e967b4915a60e1ff02bb6 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Fri, 4 Sep 2026 20:45:07 +0200 Subject: [PATCH 1/3] feat(sdk): composite document queries, the client stack The client half of composite document queries (a page plus the sub-queries derived from it, answered as ONE merged proof): - dash-platform-queries: `CompositeDocumentQuery` (page + `CompositeSubQuery` list with kind, fixed clauses, total row limit and binding), the V1 wire encoder (refuses V0, a zero page limit, cursors, offsets, projections, grouping and time ranges), the rich-to-drive conversion mirroring the server's limit contract, and `FromProof for CompositeDocuments`. - rs-drive-proof-verifier: `CompositeDocuments` plus `verify_composite_documents_tenderdash_proof`, which composes rs-drive's merged-proof verification with the tenderdash root binding; `CompositeSubQueryResult` re-exported at the crate root. - rs-sdk: `Query` impls, `impl Fetch for CompositeDocuments`, the mock round-trip, and the `platform` re-exports. - rs-drive-abci: a `composite_trust_boundary` suite running the SDK entry points against server-generated proofs: wire encoding, end-to-end FromProof (page, like counts, quoted-post join, cross-contract profile lookup with a proven absence), a wrong quorum key, tampered metadata, and a page-only proof presented for the composite query. Co-Authored-By: Claude Fable 5.1 --- .../src/documents/composite_document_query.rs | 647 ++++++++++++++++++ .../src/documents/document_query.rs | 4 +- .../src/documents/mod.rs | 1 + .../src/query/document_query/v1/tests.rs | 442 ++++++++++++ packages/rs-drive-proof-verifier/src/lib.rs | 8 + packages/rs-drive-proof-verifier/src/proof.rs | 4 + .../src/proof/composite_document.rs | 123 ++++ packages/rs-sdk/src/mock/requests.rs | 85 +++ packages/rs-sdk/src/platform.rs | 6 +- .../documents/composite_document_query_sdk.rs | 44 ++ .../src/platform/documents/fetch_bindings.rs | 5 + packages/rs-sdk/src/platform/documents/mod.rs | 7 +- 12 files changed, 1370 insertions(+), 6 deletions(-) create mode 100644 packages/dash-platform-queries/src/documents/composite_document_query.rs create mode 100644 packages/rs-drive-proof-verifier/src/proof/composite_document.rs create mode 100644 packages/rs-sdk/src/platform/documents/composite_document_query_sdk.rs diff --git a/packages/dash-platform-queries/src/documents/composite_document_query.rs b/packages/dash-platform-queries/src/documents/composite_document_query.rs new file mode 100644 index 00000000000..884b1f2ad01 --- /dev/null +++ b/packages/dash-platform-queries/src/documents/composite_document_query.rs @@ -0,0 +1,647 @@ +//! Composite document queries — the client half of "a page plus the +//! sub-queries derived from it", answered as ONE merged proof. +//! +//! The page is an ordinary [`DocumentQuery`] with an explicit limit. +//! Each [`CompositeSubQuery`] is a by-id join, an indexed lookup, a +//! grouped count, or an independent sibling, whose `IN` clause the +//! server derives from the proven page (or an earlier documents +//! sub-query) — the request never names the derived values, so the +//! responding node cannot steer them. The verifier bootstraps the page +//! from the merged proof and re-derives every sub-query with the same +//! builders, so a substituted, omitted or injected sub-result fails +//! verification. See `drive::query::composite_document_query` +//! for the shape rules and the trust model. + +use crate::documents::document_query::{ + order_clause_to_proto, where_clause_to_proto, DocumentQuery, +}; +use crate::error::Error; +use dapi_grpc::platform::v0::get_documents_request::get_documents_request_v1::{ + sub_query, SubQuery as ProtoSubQuery, +}; +use dapi_grpc::platform::v0::get_documents_request::Version as RequestVersion; +use dapi_grpc::platform::v0::{GetDocumentsRequest, GetDocumentsResponse, Proof, ResponseMetadata}; +use dapi_grpc::platform::VersionedGrpcResponse; +use dash_context_provider::ContextProvider; +use dpp::dashcore::Network; +use dpp::data_contract::accessors::v0::DataContractV0Getters; +use dpp::data_contract::DataContract; +use dpp::version::{PlatformVersion, TryFromPlatformVersioned}; +use dpp::ProtocolError; +use drive::config::DEFAULT_QUERY_LIMIT; +use drive::error::query::QuerySyntaxError; +use drive::query::{ + BindingSource, DriveDocumentQuery, DriveSubQuery, OrderClause, SelectProjection, + SubQueryBinding, SubQueryKind, WhereClause, +}; +use drive_proof_verifier::{ + verify_composite_documents_tenderdash_proof, CompositeDocuments, FromProof, +}; +use std::sync::Arc; + +/// Whose proven documents a sub-query's values are read from. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[cfg_attr(feature = "mocks", derive(serde::Serialize, serde::Deserialize))] +pub enum CompositeBindingSource { + /// The page. + Page, + /// An earlier documents sub-query, by its position in + /// [`CompositeDocumentQuery::sub_queries`]. + SubQuery(usize), +} + +/// The derived clause of a sub-query: ` IN `, where the +/// values are read off the source's proven documents. +#[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(feature = "mocks", derive(serde::Serialize, serde::Deserialize))] +pub struct CompositeBinding { + /// Whose documents supply the values. + pub source: CompositeBindingSource, + /// The source property read off each document: `$id`, `$ownerId`, + /// or an identifier-typed property (dotted paths reach nested + /// properties). Documents without it contribute nothing. + pub source_property: String, + /// The sub-query field receiving the `IN` clause. `$id` makes the + /// sub-query a by-id JOIN (the source property must then declare + /// `refersTo: permanentDocument` targeting the sub-query's type); + /// otherwise `$ownerId` or an indexed property (a LOOKUP). + pub field: String, +} + +/// What a sub-query returns. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[cfg_attr(feature = "mocks", derive(serde::Serialize, serde::Deserialize))] +pub enum CompositeSubQueryKind { + /// The matching documents. + Documents, + /// One count per derived value, read from the countable index + /// covering the fixed clauses plus the bound field. + Count, +} + +/// One sub-query of a composite request. +#[derive(Debug, Clone, PartialEq)] +#[cfg_attr(feature = "mocks", derive(serde::Serialize, serde::Deserialize))] +pub struct CompositeSubQuery { + /// The contract the sub-query targets — the page's, or any other + /// (profiles keyed by owner, names keyed by identity). + pub data_contract: Arc, + /// The document type queried. + pub document_type_name: String, + /// Documents or counts. + pub kind: CompositeSubQueryKind, + /// The FIXED clauses — everything but the derived `IN`, which must + /// not be named here. + pub where_clauses: Vec, + /// Ordering (documents only). Every component of the merged proof + /// walks in the page's direction: a bound field missing from here is + /// appended in that direction by the node and the verifier alike, and + /// an ordering that disagrees with the page's direction is refused + /// (turning a limited lookup around would change the rows it returns). + pub order_by_clauses: Vec, + /// Required for a documents lookup on a non-unique index: it caps the + /// rows the lookup returns in total, in walk order, like an ordinary + /// `IN` query's limit. Forbidden for a lookup already bounded by its + /// values, a by-id join and a count. + pub limit: Option, + /// The derived clause, or `None` for a sibling: an independent + /// documents query proven under the same root. + pub binding: Option, +} + +impl CompositeSubQuery { + fn new( + data_contract: Arc, + document_type_name: &str, + kind: CompositeSubQueryKind, + ) -> Result { + data_contract + .document_type_for_name(document_type_name) + .map_err(|e| Error::Protocol(ProtocolError::DataContractError(e)))?; + Ok(Self { + data_contract, + document_type_name: document_type_name.to_string(), + kind, + where_clauses: Vec::new(), + order_by_clauses: Vec::new(), + limit: None, + binding: None, + }) + } + + /// A documents sub-query against `document_type_name` of + /// `data_contract`. Unbound until [`Self::bound_to`] (a sibling + /// otherwise). + pub fn documents>>( + data_contract: C, + document_type_name: &str, + ) -> Result { + Self::new( + data_contract.into(), + document_type_name, + CompositeSubQueryKind::Documents, + ) + } + + /// A count sub-query against `document_type_name` of + /// `data_contract`. Must be bound. + pub fn count>>( + data_contract: C, + document_type_name: &str, + ) -> Result { + Self::new( + data_contract.into(), + document_type_name, + CompositeSubQueryKind::Count, + ) + } + + /// Bind `field` to the `source_property` values of `source`'s + /// proven documents. + pub fn bound_to( + mut self, + source: CompositeBindingSource, + source_property: impl Into, + field: impl Into, + ) -> Self { + self.binding = Some(CompositeBinding { + source, + source_property: source_property.into(), + field: field.into(), + }); + self + } + + /// Bind `field` to the `source_property` values of the page's + /// proven documents. + pub fn bound_to_page( + self, + source_property: impl Into, + field: impl Into, + ) -> Self { + self.bound_to(CompositeBindingSource::Page, source_property, field) + } + + /// Add a fixed `where` clause. + pub fn with_where(mut self, clause: WhereClause) -> Self { + self.where_clauses.push(clause); + self + } + + /// Add an `order_by` clause (documents only). + pub fn with_order_by(mut self, clause: OrderClause) -> Self { + self.order_by_clauses.push(clause); + self + } + + /// Set the total row limit of a documents lookup. + pub fn with_limit(mut self, limit: u32) -> Self { + self.limit = Some(limit); + self + } +} + +/// A composite document query: the page plus its sub-queries, in +/// binding order (a sub-query may only bind the page or an earlier +/// documents sub-query). +/// +/// The page MUST carry an explicit non-zero limit (it bounds every +/// derived clause; there is no server-default sentinel on this +/// surface) and supports where/order_by only: no cursor, offset, +/// projection, grouping or time-range selection — paginate with a +/// range clause on the page's ordering property. +#[derive(Debug, Clone, PartialEq, dash_platform_macros::Mockable)] +#[cfg_attr(feature = "mocks", derive(serde::Serialize, serde::Deserialize))] +pub struct CompositeDocumentQuery { + /// The page. + pub page: DocumentQuery, + /// The sub-queries, in request (and binding) order. + pub sub_queries: Vec, +} + +impl CompositeDocumentQuery { + /// A composite query around `page`, with no sub-queries yet. + pub fn new(page: DocumentQuery) -> Self { + Self { + page, + sub_queries: Vec::new(), + } + } + + /// Append a sub-query; its position is what later bindings name + /// through [`CompositeBindingSource::SubQuery`]. + pub fn with_sub_query(mut self, sub_query: CompositeSubQuery) -> Self { + self.sub_queries.push(sub_query); + self + } +} + +/// The page-side shape rules shared by the wire encoder and the drive +/// conversion: an explicit limit, a documents projection, and nothing +/// the composite surface cannot express. +fn check_page_shape(page: &DocumentQuery) -> Result<(), Error> { + if page.limit == 0 { + return Err(Error::Config( + "a composite document query requires an explicit non-zero page limit: it \ + bounds every derived sub-query clause, so there is no server-default sentinel" + .to_string(), + )); + } + if page.select != SelectProjection::documents() { + return Err(Error::Config( + "a composite page supports the DOCUMENTS projection only".to_string(), + )); + } + if !page.time_range_clauses.is_empty() + || page.start.is_some() + || page.offset.is_some() + || !page.group_by.is_empty() + || !page.having.is_empty() + { + return Err(Error::Config( + "a composite page supports where/order_by/limit only: no time-range \ + selections, cursors, offsets, group_by, or having (paginate with a range \ + clause on the page's ordering property)" + .to_string(), + )); + } + Ok(()) +} + +impl TryFromPlatformVersioned for GetDocumentsRequest { + type Error = Error; + + fn try_from_platform_versioned( + value: CompositeDocumentQuery, + platform_version: &PlatformVersion, + ) -> Result { + let CompositeDocumentQuery { page, sub_queries } = value; + check_page_shape(&page)?; + + let proto_sub_queries = sub_queries + .into_iter() + .map(|sub_query| { + let CompositeSubQuery { + data_contract, + document_type_name, + kind, + where_clauses, + order_by_clauses, + limit, + binding, + } = sub_query; + let kind = match kind { + CompositeSubQueryKind::Documents => sub_query::Kind::Documents, + CompositeSubQueryKind::Count => sub_query::Kind::Count, + }; + Ok(ProtoSubQuery { + // Always explicit: the server treats an empty id as + // "the page's contract", but naming it costs 32 + // bytes and removes a shape the verifier would + // otherwise have to mirror. + data_contract_id: data_contract.id().to_vec(), + document_type: document_type_name, + where_clauses: where_clauses + .into_iter() + .map(where_clause_to_proto) + .collect::, _>>()?, + order_by: order_by_clauses + .into_iter() + .map(order_clause_to_proto) + .collect(), + limit, + kind: kind as i32, + bind: binding.map(|binding| sub_query::Binding { + source: match binding.source { + CompositeBindingSource::Page => 0, + CompositeBindingSource::SubQuery(index) => index as u32 + 1, + }, + source_property: binding.source_property, + field: binding.field, + }), + }) + }) + .collect::, Error>>()?; + + // The composite surface rides the typed V1 wire: encode the + // page through the standard versioned encoder, then attach the + // sub-queries. A network still on the V0 (CBOR) wire cannot + // express the field, so refuse rather than silently sending a + // plain documents query. + let mut request = GetDocumentsRequest::try_from_platform_versioned(page, platform_version)?; + match request.version.as_mut() { + Some(RequestVersion::V1(v1)) => { + v1.sub_queries = proto_sub_queries; + } + _ => { + return Err(Error::Config( + "composite document queries require the V1 documents wire (Platform \ + v3.1+); this network's protocol version encodes V0" + .to_string(), + )); + } + } + Ok(request) + } +} + +impl<'a> TryFrom<&'a CompositeDocumentQuery> for DriveDocumentQuery<'a> { + type Error = Error; + + fn try_from(request: &'a CompositeDocumentQuery) -> Result { + check_page_shape(&request.page)?; + let page: DriveDocumentQuery<'a> = (&request.page).try_into()?; + + let sub_queries = request + .sub_queries + .iter() + .enumerate() + .map(|(index, sub_query)| { + let contract: &'a DataContract = &sub_query.data_contract; + let document_type = contract + .document_type_for_name(&sub_query.document_type_name) + .map_err(|e| Error::Protocol(ProtocolError::DataContractError(e)))?; + // Mirror the server's limit contract: `[1, + // max_query_limit]`, with anything else refused rather + // than clamped, so a proof can only ever verify against + // a query an honest server would have run. + let limit = match sub_query.limit { + None => None, + Some(limit) if limit >= 1 && limit <= u32::from(DEFAULT_QUERY_LIMIT) => { + Some(limit as u16) + } + Some(limit) => { + return Err(Error::Drive(drive::error::Error::Query( + QuerySyntaxError::InvalidLimit(format!( + "sub-query {}: limit must be in [1, {}], got {}", + index, DEFAULT_QUERY_LIMIT, limit + )), + ))); + } + }; + Ok(DriveSubQuery { + contract, + document_type, + kind: match sub_query.kind { + CompositeSubQueryKind::Documents => SubQueryKind::Documents, + CompositeSubQueryKind::Count => SubQueryKind::Count, + }, + where_clauses: sub_query.where_clauses.clone(), + order_by: sub_query.order_by_clauses.clone(), + limit, + binding: sub_query.binding.as_ref().map(|binding| SubQueryBinding { + source: match binding.source { + CompositeBindingSource::Page => BindingSource::Page, + CompositeBindingSource::SubQuery(index) => { + BindingSource::SubQuery(index) + } + }, + source_property: binding.source_property.clone(), + field: binding.field.clone(), + }), + }) + }) + .collect::, Error>>()?; + + Ok(page.with_sub_queries(sub_queries)) + } +} + +impl FromProof for CompositeDocuments { + type Request = CompositeDocumentQuery; + type Response = GetDocumentsResponse; + + fn maybe_from_proof_with_metadata<'a, I: Into, O: Into>( + request: I, + response: O, + _network: Network, + platform_version: &PlatformVersion, + provider: &'a dyn ContextProvider, + ) -> Result<(Option, ResponseMetadata, Proof), drive_proof_verifier::Error> + where + Self: 'a, + { + let request: Self::Request = request.into(); + let response: Self::Response = response.into(); + + let query: DriveDocumentQuery = (&request).try_into().map_err(|e: Error| { + drive_proof_verifier::Error::RequestError { + error: e.to_string(), + } + })?; + + // The standard envelope carries the single MERGED proof, and + // the proof alone is enough: the verifier bootstraps the page + // from it via a subset pass and re-derives the rest. + let proof = response + .proof() + .or(Err(drive_proof_verifier::Error::NoProofInResult))?; + let mtd = response + .metadata() + .or(Err(drive_proof_verifier::Error::EmptyResponseMetadata))?; + + let (_root_hash, composite) = verify_composite_documents_tenderdash_proof( + &query, + proof, + mtd, + platform_version, + provider, + )?; + + // An empty page is a valid, proven "nothing here" — surface it + // as Some(empty) rather than None so callers can tell it apart + // from a missing object. + Ok((Some(composite), mtd.clone(), proof.clone())) + } +} + +#[cfg(test)] +mod tests { + //! Offline tests for the composite client surface: the V1 + //! request-wire encoding, the page-shape rejections, and the + //! rich→drive conversion + shared shape validation against the + //! yappr-feed fixture. Proof verification is exercised end to end + //! in rs-drive's `composite_query_e2e_tests` and rs-drive-abci's + //! composite dispatch and trust-boundary tests, where a populated + //! Drive exists. + + use super::*; + use dpp::platform_value::Value; + use dpp::tests::json_document::json_document_to_contract; + use drive::query::WhereOperator; + + const FEED_CONTRACT_PATH: &str = + "../rs-drive/tests/supporting_files/contract/yappr-feed/yappr-feed-contract.json"; + const DASHPAY_CONTRACT_PATH: &str = + "../rs-drive/tests/supporting_files/contract/dashpay/dashpay-contract.json"; + + fn platform_version() -> &'static PlatformVersion { + PlatformVersion::latest() + } + + fn contract(path: &str) -> Arc { + Arc::new( + json_document_to_contract(path, false, platform_version()) + .expect("expected to parse the fixture contract"), + ) + } + + /// The feed card composition: `dash` posts, their like counts, the + /// posts they quote, and their authors' dashpay profiles. + fn feed_page(limit: u32) -> CompositeDocumentQuery { + let feed = contract(FEED_CONTRACT_PATH); + let dashpay = contract(DASHPAY_CONTRACT_PATH); + let page = DocumentQuery::new(feed.clone(), "post") + .expect("post doctype exists") + .with_where(WhereClause { + field: "hashtag".to_string(), + operator: WhereOperator::Equal, + value: Value::Text("dash".to_string()), + }) + .with_limit(limit); + CompositeDocumentQuery::new(page) + .with_sub_query( + CompositeSubQuery::count(feed.clone(), "like") + .expect("like doctype exists") + .bound_to_page("$id", "postId"), + ) + .with_sub_query( + CompositeSubQuery::documents(feed, "post") + .expect("post doctype exists") + .bound_to_page("quotedPostId", "$id"), + ) + .with_sub_query( + CompositeSubQuery::documents(dashpay, "profile") + .expect("profile doctype exists") + .bound_to_page("$ownerId", "$ownerId"), + ) + } + + #[test] + fn encodes_the_v1_wire_shape() { + let query = feed_page(10); + let dashpay_id = query.sub_queries[2].data_contract.id().to_vec(); + let request = GetDocumentsRequest::try_from_platform_versioned(query, platform_version()) + .expect("encodes"); + let Some(RequestVersion::V1(v1)) = request.version else { + panic!("expected a V1 request"); + }; + assert_eq!(v1.document_type, "post"); + assert_eq!(v1.limit, Some(10)); + assert!(v1.prove, "composite fetch always proves"); + assert!(v1.chained.is_none(), "composite and chained are exclusive"); + assert_eq!(v1.where_clauses.len(), 1); + assert_eq!(v1.sub_queries.len(), 3); + + let counts = &v1.sub_queries[0]; + assert_eq!(counts.document_type, "like"); + assert_eq!(counts.kind, sub_query::Kind::Count as i32); + assert_eq!(counts.limit, None); + let bind = counts.bind.as_ref().expect("bound"); + assert_eq!(bind.source, 0, "the page is source 0"); + assert_eq!(bind.source_property, "$id"); + assert_eq!(bind.field, "postId"); + + let quoted = &v1.sub_queries[1]; + assert_eq!(quoted.kind, sub_query::Kind::Documents as i32); + assert_eq!(quoted.bind.as_ref().expect("bound").field, "$id"); + + let profiles = &v1.sub_queries[2]; + assert_eq!(profiles.data_contract_id, dashpay_id); + assert_eq!(profiles.document_type, "profile"); + } + + #[test] + fn numbers_sub_query_sources_from_one() { + let feed = contract(FEED_CONTRACT_PATH); + let query = feed_page(10).with_sub_query( + CompositeSubQuery::count(feed, "like") + .expect("like doctype exists") + .bound_to(CompositeBindingSource::SubQuery(1), "$id", "postId"), + ); + let request = GetDocumentsRequest::try_from_platform_versioned(query, platform_version()) + .expect("encodes"); + let Some(RequestVersion::V1(v1)) = request.version else { + panic!("expected a V1 request"); + }; + assert_eq!( + v1.sub_queries[3].bind.as_ref().expect("bound").source, + 2, + "sub-query 1 is wire source 2" + ); + } + + #[test] + fn requires_a_page_limit() { + let refused = + GetDocumentsRequest::try_from_platform_versioned(feed_page(0), platform_version()); + assert!( + matches!(refused, Err(Error::Config(_))), + "a zero page limit must be refused, got {refused:?}" + ); + } + + #[test] + fn refuses_unsupported_page_features() { + let mut query = feed_page(10); + query.page.offset = Some(4); + let refused = GetDocumentsRequest::try_from_platform_versioned(query, platform_version()); + assert!( + matches!(refused, Err(Error::Config(_))), + "a page offset must be refused, got {refused:?}" + ); + } + + #[test] + fn converts_to_a_valid_drive_query() { + let query = feed_page(10); + let drive_query: DriveDocumentQuery = + (&query).try_into().expect("converts to a drive query"); + drive_query + .validate_composite(platform_version()) + .expect("the feed card shape validates"); + assert_eq!(drive_query.limit, Some(10)); + assert_eq!(drive_query.sub_queries.len(), 3); + assert_eq!(drive_query.sub_queries[0].kind, SubQueryKind::Count); + assert_eq!( + drive_query.sub_queries[2] + .binding + .as_ref() + .expect("bound") + .source, + BindingSource::Page + ); + } + + #[test] + fn conversion_refuses_an_out_of_range_sub_query_limit() { + let feed = contract(FEED_CONTRACT_PATH); + let query = feed_page(10).with_sub_query( + CompositeSubQuery::documents(feed, "repost") + .expect("repost doctype exists") + .bound_to_page("$id", "postId") + .with_limit(101), + ); + let refused: Result = (&query).try_into(); + assert!( + matches!(refused, Err(Error::Drive(_))), + "a sub-query limit above the server maximum must be refused, got {refused:?}" + ); + } + + #[test] + fn conversion_surfaces_shape_errors() { + let feed = contract(FEED_CONTRACT_PATH); + let query = feed_page(10).with_sub_query( + CompositeSubQuery::documents(feed, "post") + .expect("post doctype exists") + .bound_to_page("hashtag", "$id"), + ); + let drive_query: DriveDocumentQuery = + (&query).try_into().expect("conversion itself succeeds"); + assert!( + drive_query.validate_composite(platform_version()).is_err(), + "a by-id join off a non-refersTo property must fail validation" + ); + } +} diff --git a/packages/dash-platform-queries/src/documents/document_query.rs b/packages/dash-platform-queries/src/documents/document_query.rs index 45188fa642e..d02fa2cd498 100644 --- a/packages/dash-platform-queries/src/documents/document_query.rs +++ b/packages/dash-platform-queries/src/documents/document_query.rs @@ -1190,7 +1190,7 @@ impl<'a> TryFrom<&'a DocumentQuery> for DriveDocumentQuery<'a> { /// produced by the SDK's typical WhereClause builders, so a /// rejection here flags an unsupported caller construction at the /// wire boundary rather than silently dropping the value. -fn where_clause_to_proto(clause: WhereClause) -> Result { +pub(crate) fn where_clause_to_proto(clause: WhereClause) -> Result { Ok(ProtoWhereClause { field: clause.field, operator: where_operator_to_proto(clause.operator) as i32, @@ -1202,7 +1202,7 @@ fn where_clause_to_proto(clause: WhereClause) -> Result }) } -fn order_clause_to_proto(clause: OrderClause) -> ProtoOrderClause { +pub(crate) fn order_clause_to_proto(clause: OrderClause) -> ProtoOrderClause { // Drive's `OrderClause` carries a plain `field: String` — // emit the field-target variant of the wire's `target` oneof. // The aggregate-target variant (`ORDER BY COUNT(*)`) is diff --git a/packages/dash-platform-queries/src/documents/mod.rs b/packages/dash-platform-queries/src/documents/mod.rs index fcc5f578a02..bd068c33826 100644 --- a/packages/dash-platform-queries/src/documents/mod.rs +++ b/packages/dash-platform-queries/src/documents/mod.rs @@ -1,5 +1,6 @@ pub(crate) mod average_proof_helpers; pub mod chained_document_query; +pub mod composite_document_query; pub(crate) mod count_proof_helpers; pub mod document_average; pub mod document_count; diff --git a/packages/rs-drive-abci/src/query/document_query/v1/tests.rs b/packages/rs-drive-abci/src/query/document_query/v1/tests.rs index 44e189b95ba..ecb95b07147 100644 --- a/packages/rs-drive-abci/src/query/document_query/v1/tests.rs +++ b/packages/rs-drive-abci/src/query/document_query/v1/tests.rs @@ -6346,3 +6346,445 @@ mod chained_trust_boundary { ); } } + +mod composite_trust_boundary { + //! The client trust boundary for composite queries (a page plus + //! the sub-queries derived from it), exercised from the server + //! side — same split as [`super::having_trust_boundary`] and + //! [`super::chained_trust_boundary`]: rs-drive's e2e suite covers + //! the merk-level composition, and THIS suite runs the actual SDK + //! entry points — the dash-platform-queries wire encoding and the + //! `FromProof` composition, including the + //! tenderdash binding of the merged proof's root — against a + //! server-generated proof. It lives here because generating proofs + //! needs drive's server feature, which the client crates must not + //! enable even as dev-dependencies. + + use super::having_trust_boundary::{quorum_secret_key, signed_proof, TestQuorumProvider}; + use crate::query::tests::{setup_platform, store_data_contract, store_document}; + use dapi_grpc::platform::v0::get_documents_request::get_documents_request_v1::sub_query; + use dapi_grpc::platform::v0::get_documents_request::Version as RequestVersion; + use dapi_grpc::platform::v0::get_documents_response::{ + get_documents_response_v1, GetDocumentsResponseV1, Version as ResponseVersion, + }; + use dapi_grpc::platform::v0::{GetDocumentsRequest, GetDocumentsResponse, ResponseMetadata}; + use dash_platform_queries::documents::composite_document_query::{ + CompositeDocumentQuery, CompositeSubQuery, + }; + use dash_platform_queries::documents::document_query::DocumentQuery; + use dpp::dashcore::Network; + use dpp::data_contract::accessors::v0::DataContractV0Getters; + use dpp::data_contract::document_type::random_document::CreateRandomDocument; + use dpp::document::{DocumentV0Getters, DocumentV0Setters}; + use dpp::identifier::Identifier; + use dpp::platform_value::Value; + use dpp::prelude::DataContract; + use dpp::tests::json_document::json_document_to_contract; + use dpp::version::{PlatformVersion, TryFromPlatformVersioned}; + use drive::query::{DriveDocumentQuery, WhereClause, WhereOperator}; + use drive_proof_verifier::{CompositeDocuments, CompositeSubQueryResult, FromProof}; + use std::collections::BTreeMap; + use std::sync::Arc; + + const FEED_CONTRACT_PATH: &str = + "../rs-drive/tests/supporting_files/contract/yappr-feed/yappr-feed-contract.json"; + const DASHPAY_CONTRACT_PATH: &str = + "../rs-drive/tests/supporting_files/contract/dashpay/dashpay-contract.json"; + const POST_A: [u8; 32] = [0xA1; 32]; + const POST_B: [u8; 32] = [0xB2; 32]; + const POST_D: [u8; 32] = [0xD4; 32]; + const OWNER_1: [u8; 32] = [0x11; 32]; + const OWNER_2: [u8; 32] = [0x22; 32]; + + const CHAIN_ID: &str = "test-composite-chain"; + const HEIGHT: u64 = 778; + const CORE_LOCKED_HEIGHT: u32 = 1201; + const TIME_MS: u64 = 1_756_000_100_000; + + fn platform_version() -> &'static PlatformVersion { + PlatformVersion::latest() + } + + fn metadata() -> ResponseMetadata { + ResponseMetadata { + height: HEIGHT, + core_chain_locked_height: CORE_LOCKED_HEIGHT, + epoch: 0, + time_ms: TIME_MS, + protocol_version: platform_version().protocol_version, + chain_id: CHAIN_ID.to_string(), + } + } + + /// Two `dash` posts (A by owner 1 quoting D, B by owner 2), the + /// quoted `btc` post D, two likes on A and one on B, and a dashpay + /// profile for owner 1 only. + fn setup_feed_state() -> ( + crate::test::helpers::setup::TempPlatform, + DataContract, + DataContract, + ) { + let (platform, _state, version) = setup_platform(None, Network::Testnet, None); + let feed = json_document_to_contract(FEED_CONTRACT_PATH, false, version) + .expect("expected to parse the feed contract"); + let dashpay = json_document_to_contract(DASHPAY_CONTRACT_PATH, false, version) + .expect("expected to parse the dashpay contract"); + store_data_contract(&platform.platform, &feed, version); + store_data_contract(&platform.platform, &dashpay, version); + + let post_type = feed.document_type_for_name("post").expect("post doctype"); + let like_type = feed.document_type_for_name("like").expect("like doctype"); + let profile_type = dashpay + .document_type_for_name("profile") + .expect("profile doctype"); + + for (id, owner, hashtag, quoted, seed) in [ + (POST_D, OWNER_2, "btc", None, 4u64), + (POST_A, OWNER_1, "dash", Some(POST_D), 1), + (POST_B, OWNER_2, "dash", None, 2), + ] { + let mut post = post_type + .random_document(Some(seed), version) + .expect("post"); + let mut props = BTreeMap::new(); + props.insert("hashtag".to_string(), Value::Text(hashtag.to_string())); + props.insert("message".to_string(), Value::Text(format!("post {seed}"))); + if let Some(quoted) = quoted { + props.insert("quotedPostId".to_string(), Value::Identifier(quoted)); + } + post.set_properties(props); + post.set_id(Identifier::from(id)); + post.set_owner_id(Identifier::from(owner)); + store_document(&platform.platform, &feed, post_type, &post, version); + } + for (owner, post, seed) in [ + (OWNER_1, POST_A, 10u64), + (OWNER_2, POST_A, 11), + (OWNER_1, POST_B, 12), + ] { + let mut like = like_type + .random_document(Some(seed), version) + .expect("like"); + let mut props = BTreeMap::new(); + props.insert("hashtag".to_string(), Value::Text("dash".to_string())); + props.insert("postId".to_string(), Value::Identifier(post)); + like.set_properties(props); + like.set_owner_id(Identifier::from(owner)); + store_document(&platform.platform, &feed, like_type, &like, version); + } + let mut profile = profile_type + .random_document(Some(30), version) + .expect("profile"); + let mut props = BTreeMap::new(); + props.insert("displayName".to_string(), Value::Text("one".to_string())); + profile.set_properties(props); + profile.set_owner_id(Identifier::from(OWNER_1)); + store_document( + &platform.platform, + &dashpay, + profile_type, + &profile, + version, + ); + + (platform, feed, dashpay) + } + + /// The rich client-side query — the exact object an SDK caller + /// hands to `CompositeDocuments::fetch`: the `dash` page, its like + /// counts, the posts it quotes, and its authors' profiles. + fn client_query(feed: Arc, dashpay: Arc) -> CompositeDocumentQuery { + let page = DocumentQuery::new(feed.clone(), "post") + .expect("post doctype exists") + .with_where(WhereClause { + field: "hashtag".to_string(), + operator: WhereOperator::Equal, + value: Value::Text("dash".to_string()), + }) + .with_limit(10); + CompositeDocumentQuery::new(page) + .with_sub_query( + CompositeSubQuery::count(feed.clone(), "like") + .expect("like doctype exists") + .bound_to_page("$id", "postId"), + ) + .with_sub_query( + CompositeSubQuery::documents(feed, "post") + .expect("post doctype exists") + .bound_to_page("quotedPostId", "$id"), + ) + .with_sub_query( + CompositeSubQuery::documents(dashpay, "profile") + .expect("profile doctype exists") + .bound_to_page("$ownerId", "$ownerId"), + ) + } + + /// Server-side proof of the same shape, built from the rich query + /// through the SDK's own conversion. + fn prove( + platform: &crate::platform_types::platform::Platform, + query: &CompositeDocumentQuery, + ) -> (Vec, [u8; 32]) { + let composite: DriveDocumentQuery = query.try_into().expect("the rich query converts"); + let (proof, _page_documents) = platform + .drive + .query_composite_documents_with_proof(&composite, platform_version()) + .expect("composite proof generates"); + let root_hash = platform + .drive + .grove + .root_hash(None, &platform_version().drive.grove_version) + .unwrap() + .expect("root hash"); + (proof, root_hash) + } + + fn response_with( + proof: dapi_grpc::platform::v0::Proof, + mtd: ResponseMetadata, + ) -> GetDocumentsResponse { + GetDocumentsResponse { + version: Some(ResponseVersion::V1(GetDocumentsResponseV1 { + result: Some(get_documents_response_v1::Result::Proof(proof)), + metadata: Some(mtd), + })), + } + } + + /// The rich query encodes onto the typed V1 wire with the + /// sub-queries riding along — the encode path an SDK request + /// takes. + #[test] + fn should_encode_the_rich_query_onto_the_v1_wire() { + let (_platform, feed, dashpay) = setup_feed_state(); + let dashpay_id = dashpay.id().to_vec(); + let request = GetDocumentsRequest::try_from_platform_versioned( + client_query(Arc::new(feed), Arc::new(dashpay)), + platform_version(), + ) + .expect("encodes"); + let Some(RequestVersion::V1(v1)) = request.version else { + panic!("expected a V1 request"); + }; + assert!(v1.prove); + assert_eq!(v1.limit, Some(10)); + assert!(v1.chained.is_none()); + assert_eq!(v1.sub_queries.len(), 3); + assert_eq!(v1.sub_queries[0].kind, sub_query::Kind::Count as i32); + assert_eq!(v1.sub_queries[1].bind.as_ref().expect("bound").field, "$id"); + assert_eq!(v1.sub_queries[2].data_contract_id, dashpay_id); + } + + /// The full SDK composition end to end: FromProof bootstraps the + /// page from the merged proof, re-derives every sub-query, verifies + /// the composition, and binds the root to the quorum-signed app + /// hash. + #[test] + fn should_verify_the_composite_composition_through_from_proof() { + let (platform, feed, dashpay) = setup_feed_state(); + let query = client_query(Arc::new(feed), Arc::new(dashpay)); + let (grovedb_proof, root_hash) = prove(&platform.platform, &query); + let secret_key = quorum_secret_key(); + let quorum_hash = [6u8; 32]; + let mtd = metadata(); + let proof = signed_proof(grovedb_proof, &root_hash, &mtd, &secret_key, quorum_hash); + let provider = TestQuorumProvider { + pubkey: secret_key.public_key().0.to_compressed(), + }; + + let response = response_with(proof, mtd); + let drive_query: DriveDocumentQuery = (&query).try_into().expect("rich query converts"); + let (from_drive, _, _) = + >::maybe_from_proof_with_metadata( + drive_query, + response.clone(), + Network::Testnet, + platform_version(), + &provider, + ) + .expect("the unified Drive query verifies through the generic proof path"); + let (from_borrowed, _, _) = + >::maybe_from_proof_with_metadata( + &query, + response.clone(), + Network::Testnet, + platform_version(), + &provider, + ) + .expect("the borrowed rich query verifies through the generic proof path"); + + let (verified, _mtd, _proof) = + >::maybe_from_proof_with_metadata( + query, + response, + Network::Testnet, + platform_version(), + &provider, + ) + .expect("a correctly signed composite composition must verify"); + + assert_eq!(from_drive, verified); + assert_eq!(from_borrowed, verified); + let verified = verified.expect("a proven page is Some, even when empty"); + let page_ids: Vec<[u8; 32]> = verified + .page_documents + .iter() + .map(|d| d.id().to_buffer()) + .collect(); + assert_eq!(page_ids.len(), 2, "both dash posts come back verified"); + assert!(page_ids.contains(&POST_A) && page_ids.contains(&POST_B)); + assert_eq!(verified.sub_results.len(), 3); + + let CompositeSubQueryResult::Counts(counts) = &verified.sub_results[0] else { + panic!("the first sub-result is the like counts"); + }; + let counts: BTreeMap<[u8; 32], u64> = counts + .iter() + .map(|entry| { + let key: [u8; 32] = entry.key.as_slice().try_into().expect("identifier key"); + (key, entry.count.expect("present count")) + }) + .collect(); + assert_eq!(counts.get(&POST_A), Some(&2), "post A has two likes"); + assert_eq!(counts.get(&POST_B), Some(&1), "post B has one like"); + + let CompositeSubQueryResult::Documents(quoted) = &verified.sub_results[1] else { + panic!("the second sub-result is the quoted posts"); + }; + assert_eq!( + quoted + .iter() + .map(|d| d.id().to_buffer()) + .collect::>(), + vec![POST_D], + "the one quoted post comes back verified" + ); + + let CompositeSubQueryResult::Documents(profiles) = &verified.sub_results[2] else { + panic!("the third sub-result is the author profiles"); + }; + assert_eq!( + profiles + .iter() + .map(|d| d.owner_id().to_buffer()) + .collect::>(), + vec![OWNER_1], + "only owner 1 has a profile; owner 2's absence is proven" + ); + } + + /// A wrong quorum key fails the tenderdash binding — omitting or + /// miswiring `verify_tenderdash_proof` turns this red. + #[test] + fn should_reject_a_wrong_quorum_key() { + let (platform, feed, dashpay) = setup_feed_state(); + let query = client_query(Arc::new(feed), Arc::new(dashpay)); + let (grovedb_proof, root_hash) = prove(&platform.platform, &query); + let mtd = metadata(); + let proof = signed_proof( + grovedb_proof, + &root_hash, + &mtd, + &quorum_secret_key(), + [6u8; 32], + ); + let other_key = { + let mut bytes = [0u8; 32]; + bytes[31] = 44; + dpp::bls_signatures::SecretKey::::from_be_bytes( + &bytes, + ) + .into_option() + .expect("valid scalar") + }; + let provider = TestQuorumProvider { + pubkey: other_key.public_key().0.to_compressed(), + }; + + let refused = + >::maybe_from_proof_with_metadata( + query, + response_with(proof, mtd), + Network::Testnet, + platform_version(), + &provider, + ); + assert!( + refused.is_err(), + "a commit signed by a different quorum key must be refused" + ); + } + + /// Tampered response metadata breaks the canonical state id the + /// commit signed over. + #[test] + fn should_reject_tampered_metadata() { + let (platform, feed, dashpay) = setup_feed_state(); + let query = client_query(Arc::new(feed), Arc::new(dashpay)); + let (grovedb_proof, root_hash) = prove(&platform.platform, &query); + let secret_key = quorum_secret_key(); + let mtd = metadata(); + let proof = signed_proof(grovedb_proof, &root_hash, &mtd, &secret_key, [6u8; 32]); + let provider = TestQuorumProvider { + pubkey: secret_key.public_key().0.to_compressed(), + }; + let mut tampered = mtd; + tampered.height += 1; + + let refused = + >::maybe_from_proof_with_metadata( + query, + response_with(proof, tampered), + Network::Testnet, + platform_version(), + &provider, + ); + assert!( + refused.is_err(), + "metadata the commit did not sign over must be refused" + ); + } + + /// A plain documents proof of the page alone (what a node that + /// ignores `sub_queries` would return) must not verify against the + /// rich query: the verifier requires every sub-query's entries to + /// be present in the merged proof. + #[test] + fn should_reject_a_page_only_proof_for_the_composite_query() { + let (platform, feed, dashpay) = setup_feed_state(); + let feed = Arc::new(feed); + let dashpay = Arc::new(dashpay); + let rich = client_query(feed.clone(), dashpay.clone()); + let page: DriveDocumentQuery = (&rich.page).try_into().expect("the page converts"); + let (grovedb_proof, _cost) = page + .execute_with_proof(&platform.platform.drive, None, None, platform_version()) + .expect("page proof generates"); + let root_hash = platform + .platform + .drive + .grove + .root_hash(None, &platform_version().drive.grove_version) + .unwrap() + .expect("root hash"); + let secret_key = quorum_secret_key(); + let mtd = metadata(); + let proof = signed_proof(grovedb_proof, &root_hash, &mtd, &secret_key, [6u8; 32]); + let provider = TestQuorumProvider { + pubkey: secret_key.public_key().0.to_compressed(), + }; + + let refused = + >::maybe_from_proof_with_metadata( + client_query(feed, dashpay), + response_with(proof, mtd), + Network::Testnet, + platform_version(), + &provider, + ); + assert!( + refused.is_err(), + "a page-only proof must not verify the composite query" + ); + } +} diff --git a/packages/rs-drive-proof-verifier/src/lib.rs b/packages/rs-drive-proof-verifier/src/lib.rs index 55c201b1cf9..5fe9a825b64 100644 --- a/packages/rs-drive-proof-verifier/src/lib.rs +++ b/packages/rs-drive-proof-verifier/src/lib.rs @@ -12,6 +12,14 @@ pub use error::Error; pub use proof::chained_document::{ verify_chained_documents_proof as verify_chained_documents_tenderdash_proof, ChainedDocuments, }; +pub use proof::composite_document::{ + verify_composite_documents_proof as verify_composite_documents_tenderdash_proof, + CompositeDocuments, +}; +// Re-export the per-sub-query result of a composite query at the +// crate root, paralleling `SplitCountEntry` below, so SDK consumers can +// name it without depending on rs-drive directly. +pub use drive::query::SubQueryResult as CompositeSubQueryResult; pub use proof::document_count::{ verify_aggregate_count_proof, verify_carrier_aggregate_count_proof, verify_distinct_count_proof, verify_point_lookup_count_proof, diff --git a/packages/rs-drive-proof-verifier/src/proof.rs b/packages/rs-drive-proof-verifier/src/proof.rs index 39a7dd34497..f1645b4f7c5 100644 --- a/packages/rs-drive-proof-verifier/src/proof.rs +++ b/packages/rs-drive-proof-verifier/src/proof.rs @@ -2,6 +2,10 @@ /// proofs — the inner indexOnly page and the outer by-ids fetch derived /// from its proven values — bound to one quorum-signed root. pub mod chained_document; +/// Verified composite-document result: a page plus the sub-queries +/// derived from it (joins, lookups, counts, siblings), ONE merged +/// grovedb proof bound to one quorum-signed root. +pub mod composite_document; /// Verified average result. Holds the `(count, sum)` pair recovered /// from a `CountSumTree` / PCPS proof; client divides to obtain the /// average. Lights up alongside grovedb PR 670's diff --git a/packages/rs-drive-proof-verifier/src/proof/composite_document.rs b/packages/rs-drive-proof-verifier/src/proof/composite_document.rs new file mode 100644 index 00000000000..8bae50074cc --- /dev/null +++ b/packages/rs-drive-proof-verifier/src/proof/composite_document.rs @@ -0,0 +1,123 @@ +//! Verified **composite document** results: a page plus the +//! sub-queries derived from it, answered as ONE merged grovedb proof. +//! +//! The server proves the limited page and every sub-query (by-id +//! joins, indexed lookups, grouped counts, siblings) as one merged +//! path query. The verifier +//! ([`DriveDocumentQuery::verify_composite_documents_proof`]) +//! bootstraps the page from the proof with a subset pass, re-derives +//! every sub-query's `IN` clause from the PROVEN page (or the proven +//! earlier sub-query it binds), rebuilds the same merged query, verifies +//! it in one authoritative pass, and routes the proved entries back to +//! their components — refusing unclaimed entries, dangling joins and +//! derivation divergence. This module's [`FromProof`] impl composes +//! that with the tenderdash signature binding of the single root. +//! +//! There is deliberately **no unproven decoder with verification +//! semantics** here: an unproven composite response is free to +//! fabricate any sub-result, which is precisely what the surface exists +//! to prevent. [`CompositeDocuments`] can still be built from a trusted +//! node's unproven wire by the SDK if it chooses, but the canonical +//! path proves. + +use crate::error::MapGroveDbError; +use crate::verify::verify_tenderdash_proof; +use crate::{ContextProvider, Error, FromProof}; +use dapi_grpc::platform::v0::{GetDocumentsResponse, Proof, ResponseMetadata}; +use dapi_grpc::platform::VersionedGrpcResponse; +use dpp::dashcore::Network; +use dpp::document::Document; +use dpp::version::PlatformVersion; +use drive::query::{DriveDocumentQuery, SubQueryResult}; +use drive::verify::RootHash; + +/// The verified result of a composite document query. +#[derive(Debug, Clone, PartialEq, Default)] +pub struct CompositeDocuments { + /// The page, exactly as the page query alone would return it. + pub page_documents: Vec, + /// One result per sub-query, in request order: a by-id join's + /// documents in first-appearance order of their ids among the + /// source documents, a lookup's or sibling's in query order, or one + /// count per derived value that has a count tree (a value without + /// an entry counts zero). + pub sub_results: Vec, +} + +/// Verify a composite query's single merged proof and bind its root +/// hash to the quorum signature. +/// +/// The merk-level composition (bootstrap subset pass on the page, +/// re-derivation of every sub-query, authoritative full verification, +/// routing with the completeness checks) lives in rs-drive's +/// [`DriveDocumentQuery::verify_composite_documents_proof`]; +/// this wrapper adds the [`verify_tenderdash_proof`] binding — the root +/// hash the proof commits to is only an attested fact once it is tied +/// to the quorum-signed app hash, and this function exists so the +/// composition can never be skipped by accident. +pub fn verify_composite_documents_proof( + query: &DriveDocumentQuery, + proof: &Proof, + mtd: &ResponseMetadata, + platform_version: &PlatformVersion, + provider: &dyn ContextProvider, +) -> Result<(RootHash, CompositeDocuments), Error> { + let (root_hash, result) = query + .verify_composite_documents_proof(&proof.grovedb_proof, platform_version) + .map_drive_error(proof, mtd)?; + + verify_tenderdash_proof(proof, mtd, &root_hash, provider)?; + + Ok(( + root_hash, + CompositeDocuments { + page_documents: result.page_documents, + sub_results: result.sub_results, + }, + )) +} + +impl<'dq, Q> FromProof for CompositeDocuments +where + Q: TryInto> + Clone + 'dq, + Q::Error: std::fmt::Display, +{ + type Request = Q; + type Response = GetDocumentsResponse; + + fn maybe_from_proof_with_metadata<'a, I: Into, O: Into>( + request: I, + response: O, + _network: Network, + platform_version: &PlatformVersion, + provider: &'a dyn ContextProvider, + ) -> Result<(Option, ResponseMetadata, Proof), Error> + where + Self: 'a, + { + let request: Self::Request = request.into(); + let response: Self::Response = response.into(); + + let query: DriveDocumentQuery<'dq> = + request + .clone() + .try_into() + .map_err(|e: Q::Error| Error::RequestError { + error: e.to_string(), + })?; + + // The standard envelope carries the single MERGED proof, and + // the proof alone is enough: the verifier bootstraps the page + // from it via a subset pass and re-derives the rest. + let proof = response.proof().or(Err(Error::NoProofInResult))?; + let mtd = response.metadata().or(Err(Error::EmptyResponseMetadata))?; + + let (_root_hash, composite) = + verify_composite_documents_proof(&query, proof, mtd, platform_version, provider)?; + + // An empty page is a valid, proven "nothing here" — surface it + // as Some(empty) rather than None so callers can tell it apart + // from a missing object. + Ok((Some(composite), mtd.clone(), proof.clone())) + } +} diff --git a/packages/rs-sdk/src/mock/requests.rs b/packages/rs-sdk/src/mock/requests.rs index efb7fee74a7..9fb2a674911 100644 --- a/packages/rs-sdk/src/mock/requests.rs +++ b/packages/rs-sdk/src/mock/requests.rs @@ -863,3 +863,88 @@ impl MockResponse for drive_proof_verifier::ChainedDocuments { } } } + +/// Wire shape for one `CompositeDocuments` sub-result mock round-trip: +/// `(is_documents, documents, count triples)`, only one side populated. +type MockCompositeSubResult = (bool, Vec>, DocumentSplitCountTriples); + +/// Wire shape for `CompositeDocuments` mock round-trip: the page as a +/// per-document CBOR list, then one entry per sub-query. +type MockCompositeShape = (Vec>, Vec); + +impl MockResponse for drive_proof_verifier::CompositeDocuments { + /// The page and every documents sub-result as per-document CBOR, + /// count sub-results as `(in_key, key, count)` triples, all + /// bincode-framed in request order — list order IS the answer + /// (page order, a join's first-appearance order), so a map-shaped + /// encoding would destroy it. + fn mock_serialize(&self, _sdk: &MockDashPlatformSdk) -> Vec { + let bincode_config = standard(); + let encode = |documents: &[Document]| -> Vec> { + documents + .iter() + .map(|d| d.to_cbor().expect("encode document")) + .collect() + }; + let shape: MockCompositeShape = ( + encode(&self.page_documents), + self.sub_results + .iter() + .map(|result| match result { + drive_proof_verifier::CompositeSubQueryResult::Documents(documents) => { + (true, encode(documents), Vec::new()) + } + drive_proof_verifier::CompositeSubQueryResult::Counts(entries) => ( + false, + Vec::new(), + entries + .iter() + .map(|e| (e.in_key.clone(), e.key.clone(), e.count)) + .collect(), + ), + }) + .collect(), + ); + bincode::encode_to_vec(shape, bincode_config).expect("encode CompositeDocuments") + } + + fn mock_deserialize(sdk: &MockDashPlatformSdk, buf: &[u8]) -> Self + where + Self: Sized, + { + let bincode_config = standard(); + let ((page, sub_results), _): (MockCompositeShape, _) = + bincode::decode_from_slice(buf, bincode_config).expect("decode CompositeDocuments"); + let decode = |bufs: Vec>| -> Vec { + bufs.into_iter() + .map(|b| { + Document::from_cbor(&b, None, None, sdk.version()).expect("decode document") + }) + .collect() + }; + drive_proof_verifier::CompositeDocuments { + page_documents: decode(page), + sub_results: sub_results + .into_iter() + .map(|(is_documents, documents, triples)| { + if is_documents { + drive_proof_verifier::CompositeSubQueryResult::Documents(decode(documents)) + } else { + drive_proof_verifier::CompositeSubQueryResult::Counts( + triples + .into_iter() + .map( + |(in_key, key, count)| drive_proof_verifier::SplitCountEntry { + in_key, + key, + count, + }, + ) + .collect(), + ) + } + }) + .collect(), + } + } +} diff --git a/packages/rs-sdk/src/platform.rs b/packages/rs-sdk/src/platform.rs index 25df127e249..2be44a55b7b 100644 --- a/packages/rs-sdk/src/platform.rs +++ b/packages/rs-sdk/src/platform.rs @@ -31,6 +31,10 @@ pub use dash_context_provider::ContextProvider; #[cfg(feature = "mocks")] pub use dash_context_provider::MockContextProvider; pub use documents::chained_document_query::ChainedDocumentQuery; +pub use documents::composite_document_query::{ + CompositeBinding, CompositeBindingSource, CompositeDocumentQuery, CompositeSubQuery, + CompositeSubQueryKind, +}; pub use documents::document_history_query::DocumentHistoryQuery; pub use documents::document_query::DocumentQuery; /// Sdk-bound constructors for [`DocumentQuery`]. Must be in scope to call @@ -42,7 +46,7 @@ pub use dpp::{ prelude::{DataContract, Identifier, Identity, IdentityPublicKey, Revision}, }; pub use drive::query::DriveDocumentQuery; -pub use drive_proof_verifier::ChainedDocuments; +pub use drive_proof_verifier::{ChainedDocuments, CompositeDocuments, CompositeSubQueryResult}; pub use rs_dapi_client as dapi; pub use { fetch::Fetch, diff --git a/packages/rs-sdk/src/platform/documents/composite_document_query_sdk.rs b/packages/rs-sdk/src/platform/documents/composite_document_query_sdk.rs new file mode 100644 index 00000000000..43dadf4c156 --- /dev/null +++ b/packages/rs-sdk/src/platform/documents/composite_document_query_sdk.rs @@ -0,0 +1,44 @@ +//! Sdk-bound half of the composite document query surface: the rich → +//! wire encoding. The transport-free query type itself +//! ([`CompositeDocumentQuery`]) lives in `dash-platform-queries`. + +use dapi_grpc::platform::v0 as platform_proto; +use dapi_grpc::platform::v0::GetDocumentsRequest; +use dash_platform_queries::documents::composite_document_query::CompositeDocumentQuery; +use dpp::version::TryFromPlatformVersioned; + +use crate::Error; + +/// Encode a [`CompositeDocumentQuery`] onto the wire. +/// +/// The [`Fetch`](crate::platform::Fetch) trampoline for +/// [`drive_proof_verifier::CompositeDocuments`] splits `Query = +/// CompositeDocumentQuery` (rich, what `FromProof` binds to) from +/// `Request = GetDocumentsRequest` (wire); this impl is the rich→wire +/// step. +impl crate::platform::Query for CompositeDocumentQuery { + fn query( + &self, + settings: &crate::platform::QuerySettings<'_>, + ) -> Result { + GetDocumentsRequest::try_from_platform_versioned(self.clone(), settings.protocol_version) + .map_err(Error::from) + } +} + +// `CompositeDocumentQuery` does not implement `TransportRequest` (the +// wire form is `GetDocumentsRequest`), so the blanket `Query for T` +// does not apply — provide the identity impl explicitly, same as +// `DocumentQuery`'s, so the fetch trampoline can use it both as the +// user-supplied `Q` and as the rich `Self::Query`. +impl crate::platform::Query for CompositeDocumentQuery { + fn query( + &self, + settings: &crate::platform::QuerySettings<'_>, + ) -> Result { + if !settings.prove { + tracing::warn!(request= ?self, "sending query without proof, ensure data is trusted"); + } + Ok(self.clone()) + } +} diff --git a/packages/rs-sdk/src/platform/documents/fetch_bindings.rs b/packages/rs-sdk/src/platform/documents/fetch_bindings.rs index 20c5f4f8060..54871c1b487 100644 --- a/packages/rs-sdk/src/platform/documents/fetch_bindings.rs +++ b/packages/rs-sdk/src/platform/documents/fetch_bindings.rs @@ -56,3 +56,8 @@ impl Fetch for drive_proof_verifier::ChainedDocuments { type Query = dash_platform_queries::documents::chained_document_query::ChainedDocumentQuery; type Request = dapi_grpc::platform::v0::GetDocumentsRequest; } + +impl Fetch for drive_proof_verifier::CompositeDocuments { + type Query = dash_platform_queries::documents::composite_document_query::CompositeDocumentQuery; + type Request = dapi_grpc::platform::v0::GetDocumentsRequest; +} diff --git a/packages/rs-sdk/src/platform/documents/mod.rs b/packages/rs-sdk/src/platform/documents/mod.rs index ebfb4dd8ebf..16d104b367e 100644 --- a/packages/rs-sdk/src/platform/documents/mod.rs +++ b/packages/rs-sdk/src/platform/documents/mod.rs @@ -6,12 +6,13 @@ //! bindings, the contract-fetching constructor, and transition builders. pub use dash_platform_queries::documents::{ - chained_document_query, document_average, document_count, document_having_entries, - document_history_query, document_query, document_ranked_entries, document_split_averages, - document_split_counts, document_split_sums, document_sum, + chained_document_query, composite_document_query, document_average, document_count, + document_having_entries, document_history_query, document_query, document_ranked_entries, + document_split_averages, document_split_counts, document_split_sums, document_sum, }; pub mod chained_document_query_sdk; +pub mod composite_document_query_sdk; pub mod document_query_sdk; mod fetch_bindings; pub mod transitions; From 2a35ffe615ff2f4ab04dc8b8d8c88a5e142293a8 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Tue, 8 Sep 2026 06:43:44 +0700 Subject: [PATCH 2/3] refactor(sdk)!: consolidate composite queries into DocumentQuery Carry typed sub-queries directly on DocumentQuery and reuse the existing versioned encoder and SDK fetch adapter. Preserve contracts, bindings, clauses, ordering, and limits across Drive conversions. Keep CompositeDocuments as the result type and reject compositions in result paths that cannot return sub-queries. Cover conversions, bounds, legacy mock compatibility, SDK fetches, and signed proof verification with regression tests. Update ordinary query struct literals in the SDK, wallet, and WASM callers. BREAKING CHANGE: DocumentQuery struct literals must initialize sub_queries. CompositeDocumentQuery from the earlier PR draft is replaced by DocumentQuery::with_sub_query and DocumentQuery::with_sub_queries. --- .../src/documents/average_proof_helpers.rs | 1 + .../src/documents/chained_document_query.rs | 48 +- .../src/documents/composite_document_query.rs | 501 +++++++++++------- .../src/documents/count_proof_helpers.rs | 1 + .../src/documents/document_query.rs | 64 ++- .../src/documents/having_proof_helpers.rs | 1 + .../src/documents/ranked_proof_helpers.rs | 1 + .../src/documents/sum_proof_helpers.rs | 1 + .../src/query/document_query/v1/tests.rs | 109 +++- .../wallet/identity/network/contact_info.rs | 1 + .../identity/network/dpns_marketplace.rs | 5 + .../src/wallet/identity/network/profile.rs | 2 + packages/rs-sdk/src/platform.rs | 3 +- .../dashpay/contact_request_queries.rs | 1 + .../documents/composite_document_query_sdk.rs | 44 -- .../platform/documents/document_query_sdk.rs | 3 +- .../src/platform/documents/fetch_bindings.rs | 2 +- packages/rs-sdk/src/platform/documents/mod.rs | 1 - .../rs-sdk/src/platform/dpns_usernames/mod.rs | 2 + .../src/platform/dpns_usernames/queries.rs | 2 + .../tests/fetch/document_query_v0_v1.rs | 72 +++ packages/wasm-sdk/src/dpns.rs | 1 + 22 files changed, 569 insertions(+), 297 deletions(-) delete mode 100644 packages/rs-sdk/src/platform/documents/composite_document_query_sdk.rs diff --git a/packages/dash-platform-queries/src/documents/average_proof_helpers.rs b/packages/dash-platform-queries/src/documents/average_proof_helpers.rs index d0e99c5acf2..af74fa3f49d 100644 --- a/packages/dash-platform-queries/src/documents/average_proof_helpers.rs +++ b/packages/dash-platform-queries/src/documents/average_proof_helpers.rs @@ -103,6 +103,7 @@ pub(super) fn verify_average_query( platform_version: &PlatformVersion, provider: &dyn ContextProvider, ) -> Result<(Option>, ResponseMetadata, Proof), drive_proof_verifier::Error> { + request.ensure_no_sub_queries()?; let proof = response .proof() .or(Err(drive_proof_verifier::Error::NoProofInResult))?; diff --git a/packages/dash-platform-queries/src/documents/chained_document_query.rs b/packages/dash-platform-queries/src/documents/chained_document_query.rs index 0e1e8d54d65..e517adb4ab9 100644 --- a/packages/dash-platform-queries/src/documents/chained_document_query.rs +++ b/packages/dash-platform-queries/src/documents/chained_document_query.rs @@ -74,6 +74,9 @@ impl TryFromPlatformVersioned for GetDocumentsRequest { outer_document_type_name, } = value; + inner + .ensure_no_sub_queries() + .map_err(|e| Error::Config(e.to_string()))?; if inner.limit == 0 { return Err(Error::Config( "a chained document query requires an explicit non-zero inner limit: it \ @@ -125,6 +128,10 @@ impl<'a> TryFrom<&'a ChainedDocumentQuery> for DriveDocumentQuery<'a> { type Error = Error; fn try_from(request: &'a ChainedDocumentQuery) -> Result { + request + .inner + .ensure_no_sub_queries() + .map_err(|e| Error::Config(e.to_string()))?; let inner: DriveDocumentQuery<'a> = (&request.inner).try_into()?; let outer_document_type = request .inner @@ -289,31 +296,54 @@ mod tests { assert_eq!(drive_query.limit, Some(10)); } - fn assert_plain_conversions_refuse(query: &DriveDocumentQuery) { + #[test] + fn should_reject_sub_queries_inside_a_chained_inner_query() { + use crate::documents::composite_document_query::CompositeSubQuery; + + let mut query = posts_i_liked(10); + query.inner.sub_queries.push( + CompositeSubQuery::documents(query.inner.data_contract.clone(), "post") + .expect("post doctype exists") + .bound_to_page("postId", "$id"), + ); + let refused = + GetDocumentsRequest::try_from_platform_versioned(query.clone(), platform_version()); + assert!(matches!(refused, Err(Error::Config(message)) if message.contains("sub-queries"))); + let refused = DriveDocumentQuery::try_from(&query); + assert!(matches!(refused, Err(Error::Config(message)) if message.contains("sub-queries"))); + } + + fn assert_conversions_preserve_sub_queries(query: &DriveDocumentQuery) { for result in [ DocumentQuery::try_from(query), DocumentQuery::try_from(query.clone()), DocumentQuery::new_with_drive_query(query), ] { - assert!( - matches!(&result, Err(Error::Config(message)) if message.contains("sub-queries")), - "a plain conversion must refuse the composition, got {result:?}" - ); + let sdk_query = result.expect("conversion preserves sub-queries"); + let restored: DriveDocumentQuery = (&sdk_query).try_into().expect("converts back"); + assert_eq!(&restored, query); + let request = + GetDocumentsRequest::try_from_platform_versioned(sdk_query, platform_version()) + .expect("the composition encodes"); + let Some(RequestVersion::V1(v1)) = request.version else { + panic!("expected V1"); + }; + assert_eq!(v1.sub_queries.len(), query.sub_queries.len()); } } #[test] - fn should_refuse_dropping_a_drive_join_during_plain_query_conversion() { + fn should_preserve_a_drive_join_during_query_conversion() { let query = posts_i_liked(10); let drive_query: DriveDocumentQuery = (&query).try_into().expect("drive query"); drive_query .validate_chained(platform_version()) .expect("valid chained shape"); - assert_plain_conversions_refuse(&drive_query); + assert_conversions_preserve_sub_queries(&drive_query); } #[test] - fn should_refuse_dropping_a_composite_count_during_plain_query_conversion() { + fn should_preserve_a_composite_count_during_query_conversion() { let query = posts_i_liked(10); let page: DriveDocumentQuery = (&query.inner).try_into().expect("drive page"); let count = DriveSubQuery { @@ -333,7 +363,7 @@ mod tests { composite .validate_composite(platform_version()) .expect("valid count composition"); - assert_plain_conversions_refuse(&composite); + assert_conversions_preserve_sub_queries(&composite); } #[test] diff --git a/packages/dash-platform-queries/src/documents/composite_document_query.rs b/packages/dash-platform-queries/src/documents/composite_document_query.rs index 884b1f2ad01..01f24bd7cab 100644 --- a/packages/dash-platform-queries/src/documents/composite_document_query.rs +++ b/packages/dash-platform-queries/src/documents/composite_document_query.rs @@ -1,7 +1,8 @@ //! Composite document queries — the client half of "a page plus the //! sub-queries derived from it", answered as ONE merged proof. //! -//! The page is an ordinary [`DocumentQuery`] with an explicit limit. +//! Attach sub-queries directly to [`DocumentQuery`] with an explicit +//! page limit, then fetch the result as [`CompositeDocuments`]. //! Each [`CompositeSubQuery`] is a by-id join, an indexed lookup, a //! grouped count, or an independent sibling, whose `IN` clause the //! server derives from the proven page (or an earlier documents @@ -19,20 +20,19 @@ use crate::error::Error; use dapi_grpc::platform::v0::get_documents_request::get_documents_request_v1::{ sub_query, SubQuery as ProtoSubQuery, }; -use dapi_grpc::platform::v0::get_documents_request::Version as RequestVersion; -use dapi_grpc::platform::v0::{GetDocumentsRequest, GetDocumentsResponse, Proof, ResponseMetadata}; +use dapi_grpc::platform::v0::{GetDocumentsResponse, Proof, ResponseMetadata}; use dapi_grpc::platform::VersionedGrpcResponse; use dash_context_provider::ContextProvider; use dpp::dashcore::Network; use dpp::data_contract::accessors::v0::DataContractV0Getters; use dpp::data_contract::DataContract; -use dpp::version::{PlatformVersion, TryFromPlatformVersioned}; +use dpp::version::PlatformVersion; use dpp::ProtocolError; use drive::config::DEFAULT_QUERY_LIMIT; use drive::error::query::QuerySyntaxError; use drive::query::{ BindingSource, DriveDocumentQuery, DriveSubQuery, OrderClause, SelectProjection, - SubQueryBinding, SubQueryKind, WhereClause, + SubQueryBinding, SubQueryKind, WhereClause, MAX_SUB_QUERIES, }; use drive_proof_verifier::{ verify_composite_documents_tenderdash_proof, CompositeDocuments, FromProof, @@ -46,7 +46,7 @@ pub enum CompositeBindingSource { /// The page. Page, /// An earlier documents sub-query, by its position in - /// [`CompositeDocumentQuery::sub_queries`]. + /// [`DocumentQuery::sub_queries`]. SubQuery(usize), } @@ -201,51 +201,90 @@ impl CompositeSubQuery { } } -/// A composite document query: the page plus its sub-queries, in -/// binding order (a sub-query may only bind the page or an earlier -/// documents sub-query). -/// -/// The page MUST carry an explicit non-zero limit (it bounds every -/// derived clause; there is no server-default sentinel on this -/// surface) and supports where/order_by only: no cursor, offset, -/// projection, grouping or time-range selection — paginate with a -/// range clause on the page's ordering property. -#[derive(Debug, Clone, PartialEq, dash_platform_macros::Mockable)] -#[cfg_attr(feature = "mocks", derive(serde::Serialize, serde::Deserialize))] -pub struct CompositeDocumentQuery { - /// The page. - pub page: DocumentQuery, - /// The sub-queries, in request (and binding) order. - pub sub_queries: Vec, -} - -impl CompositeDocumentQuery { - /// A composite query around `page`, with no sub-queries yet. - pub fn new(page: DocumentQuery) -> Self { +impl From<&DriveSubQuery<'_>> for CompositeSubQuery { + fn from(sub: &DriveSubQuery<'_>) -> Self { + use dpp::data_contract::document_type::accessors::DocumentTypeV0Getters; Self { - page, - sub_queries: Vec::new(), + data_contract: Arc::new(sub.contract.clone()), + document_type_name: sub.document_type.name().to_string(), + kind: match sub.kind { + SubQueryKind::Documents => CompositeSubQueryKind::Documents, + SubQueryKind::Count => CompositeSubQueryKind::Count, + }, + where_clauses: sub.where_clauses.clone(), + order_by_clauses: sub.order_by.clone(), + limit: sub.limit.map(u32::from), + binding: sub.binding.as_ref().map(|binding| CompositeBinding { + source: match binding.source { + BindingSource::Page => CompositeBindingSource::Page, + BindingSource::SubQuery(index) => CompositeBindingSource::SubQuery(index), + }, + source_property: binding.source_property.clone(), + field: binding.field.clone(), + }), } } +} - /// Append a sub-query; its position is what later bindings name - /// through [`CompositeBindingSource::SubQuery`]. +impl DocumentQuery { + /// Append a sub-query derived from this page or an earlier sub-query. + /// Fetch the result as [`CompositeDocuments`]. pub fn with_sub_query(mut self, sub_query: CompositeSubQuery) -> Self { self.sub_queries.push(sub_query); self } + + /// Replace the sub-queries. An empty list makes this an ordinary query. + pub fn with_sub_queries(mut self, sub_queries: Vec) -> Self { + self.sub_queries = sub_queries; + self + } + + /// Check the composite-only shape before encoding or building Drive queries. + pub(super) fn check_composite_shape(&self) -> Result<(), Error> { + if self.sub_queries.is_empty() || self.sub_queries.len() > MAX_SUB_QUERIES { + return Err(Error::Config(format!( + "a composite document query requires between 1 and {MAX_SUB_QUERIES} sub-queries" + ))); + } + check_page_shape(self)?; + for (index, sub) in self.sub_queries.iter().enumerate() { + if let Some(limit) = sub.limit { + if limit == 0 || limit > u32::from(DEFAULT_QUERY_LIMIT) { + return Err(Error::Drive(drive::error::Error::Query( + QuerySyntaxError::InvalidLimit(format!( + "sub-query {index}: limit must be in [1, {DEFAULT_QUERY_LIMIT}], got {limit}" + )), + ))); + } + } + if let Some(CompositeBinding { + source: CompositeBindingSource::SubQuery(source), + .. + }) = &sub.binding + { + if *source >= index + || self.sub_queries[*source].kind != CompositeSubQueryKind::Documents + { + return Err(Error::Config(format!( + "sub-query {index}: a binding must name an earlier documents sub-query" + ))); + } + } + } + Ok(()) + } } /// The page-side shape rules shared by the wire encoder and the drive /// conversion: an explicit limit, a documents projection, and nothing /// the composite surface cannot express. fn check_page_shape(page: &DocumentQuery) -> Result<(), Error> { - if page.limit == 0 { - return Err(Error::Config( - "a composite document query requires an explicit non-zero page limit: it \ + if page.limit == 0 || page.limit > u32::from(DEFAULT_QUERY_LIMIT) { + return Err(Error::Config(format!( + "a composite document query requires an explicit page limit between 1 and {DEFAULT_QUERY_LIMIT}: it \ bounds every derived sub-query clause, so there is no server-default sentinel" - .to_string(), - )); + ))); } if page.select != SelectProjection::documents() { return Err(Error::Config( @@ -268,147 +307,95 @@ fn check_page_shape(page: &DocumentQuery) -> Result<(), Error> { Ok(()) } -impl TryFromPlatformVersioned for GetDocumentsRequest { - type Error = Error; - - fn try_from_platform_versioned( - value: CompositeDocumentQuery, - platform_version: &PlatformVersion, - ) -> Result { - let CompositeDocumentQuery { page, sub_queries } = value; - check_page_shape(&page)?; - - let proto_sub_queries = sub_queries - .into_iter() - .map(|sub_query| { - let CompositeSubQuery { - data_contract, - document_type_name, - kind, - where_clauses, - order_by_clauses, - limit, - binding, - } = sub_query; - let kind = match kind { - CompositeSubQueryKind::Documents => sub_query::Kind::Documents, - CompositeSubQueryKind::Count => sub_query::Kind::Count, - }; - Ok(ProtoSubQuery { - // Always explicit: the server treats an empty id as - // "the page's contract", but naming it costs 32 - // bytes and removes a shape the verifier would - // otherwise have to mirror. - data_contract_id: data_contract.id().to_vec(), - document_type: document_type_name, - where_clauses: where_clauses - .into_iter() - .map(where_clause_to_proto) - .collect::, _>>()?, - order_by: order_by_clauses - .into_iter() - .map(order_clause_to_proto) - .collect(), - limit, - kind: kind as i32, - bind: binding.map(|binding| sub_query::Binding { - source: match binding.source { - CompositeBindingSource::Page => 0, - CompositeBindingSource::SubQuery(index) => index as u32 + 1, - }, - source_property: binding.source_property, - field: binding.field, - }), - }) +/// Encode sub-queries after [`DocumentQuery::check_composite_shape`] has +/// bounded their count, binding indices, and limits. +pub(super) fn sub_queries_to_proto( + sub_queries: Vec, +) -> Result, Error> { + sub_queries + .into_iter() + .map(|sub_query| { + let CompositeSubQuery { + data_contract, + document_type_name, + kind, + where_clauses, + order_by_clauses, + limit, + binding, + } = sub_query; + let kind = match kind { + CompositeSubQueryKind::Documents => sub_query::Kind::Documents, + CompositeSubQueryKind::Count => sub_query::Kind::Count, + }; + Ok(ProtoSubQuery { + // Always explicit: the server treats an empty id as + // "the page's contract", but naming it costs 32 + // bytes and removes a shape the verifier would + // otherwise have to mirror. + data_contract_id: data_contract.id().to_vec(), + document_type: document_type_name, + where_clauses: where_clauses + .into_iter() + .map(where_clause_to_proto) + .collect::, _>>()?, + order_by: order_by_clauses + .into_iter() + .map(order_clause_to_proto) + .collect(), + limit, + kind: kind as i32, + bind: binding.map(|binding| sub_query::Binding { + source: match binding.source { + CompositeBindingSource::Page => 0, + CompositeBindingSource::SubQuery(index) => index as u32 + 1, + }, + source_property: binding.source_property, + field: binding.field, + }), }) - .collect::, Error>>()?; - - // The composite surface rides the typed V1 wire: encode the - // page through the standard versioned encoder, then attach the - // sub-queries. A network still on the V0 (CBOR) wire cannot - // express the field, so refuse rather than silently sending a - // plain documents query. - let mut request = GetDocumentsRequest::try_from_platform_versioned(page, platform_version)?; - match request.version.as_mut() { - Some(RequestVersion::V1(v1)) => { - v1.sub_queries = proto_sub_queries; - } - _ => { - return Err(Error::Config( - "composite document queries require the V1 documents wire (Platform \ - v3.1+); this network's protocol version encodes V0" - .to_string(), - )); - } - } - Ok(request) - } + }) + .collect::, Error>>() } -impl<'a> TryFrom<&'a CompositeDocumentQuery> for DriveDocumentQuery<'a> { - type Error = Error; - - fn try_from(request: &'a CompositeDocumentQuery) -> Result { - check_page_shape(&request.page)?; - let page: DriveDocumentQuery<'a> = (&request.page).try_into()?; - - let sub_queries = request - .sub_queries - .iter() - .enumerate() - .map(|(index, sub_query)| { - let contract: &'a DataContract = &sub_query.data_contract; - let document_type = contract - .document_type_for_name(&sub_query.document_type_name) - .map_err(|e| Error::Protocol(ProtocolError::DataContractError(e)))?; - // Mirror the server's limit contract: `[1, - // max_query_limit]`, with anything else refused rather - // than clamped, so a proof can only ever verify against - // a query an honest server would have run. - let limit = match sub_query.limit { - None => None, - Some(limit) if limit >= 1 && limit <= u32::from(DEFAULT_QUERY_LIMIT) => { - Some(limit as u16) - } - Some(limit) => { - return Err(Error::Drive(drive::error::Error::Query( - QuerySyntaxError::InvalidLimit(format!( - "sub-query {}: limit must be in [1, {}], got {}", - index, DEFAULT_QUERY_LIMIT, limit - )), - ))); - } - }; - Ok(DriveSubQuery { - contract, - document_type, - kind: match sub_query.kind { - CompositeSubQueryKind::Documents => SubQueryKind::Documents, - CompositeSubQueryKind::Count => SubQueryKind::Count, +/// Borrow sub-queries after [`DocumentQuery::check_composite_shape`] has +/// validated limits before narrowing them to Drive's `u16`. +pub(super) fn drive_sub_queries<'a>( + request: &'a DocumentQuery, +) -> Result>, Error> { + request + .sub_queries + .iter() + .map(|sub_query| { + let contract: &'a DataContract = &sub_query.data_contract; + let document_type = contract + .document_type_for_name(&sub_query.document_type_name) + .map_err(|e| Error::Protocol(ProtocolError::DataContractError(e)))?; + Ok(DriveSubQuery { + contract, + document_type, + kind: match sub_query.kind { + CompositeSubQueryKind::Documents => SubQueryKind::Documents, + CompositeSubQueryKind::Count => SubQueryKind::Count, + }, + where_clauses: sub_query.where_clauses.clone(), + order_by: sub_query.order_by_clauses.clone(), + limit: sub_query.limit.map(|limit| limit as u16), + binding: sub_query.binding.as_ref().map(|binding| SubQueryBinding { + source: match binding.source { + CompositeBindingSource::Page => BindingSource::Page, + CompositeBindingSource::SubQuery(index) => BindingSource::SubQuery(index), }, - where_clauses: sub_query.where_clauses.clone(), - order_by: sub_query.order_by_clauses.clone(), - limit, - binding: sub_query.binding.as_ref().map(|binding| SubQueryBinding { - source: match binding.source { - CompositeBindingSource::Page => BindingSource::Page, - CompositeBindingSource::SubQuery(index) => { - BindingSource::SubQuery(index) - } - }, - source_property: binding.source_property.clone(), - field: binding.field.clone(), - }), - }) + source_property: binding.source_property.clone(), + field: binding.field.clone(), + }), }) - .collect::, Error>>()?; - - Ok(page.with_sub_queries(sub_queries)) - } + }) + .collect::, Error>>() } -impl FromProof for CompositeDocuments { - type Request = CompositeDocumentQuery; +impl FromProof for CompositeDocuments { + type Request = DocumentQuery; type Response = GetDocumentsResponse; fn maybe_from_proof_with_metadata<'a, I: Into, O: Into>( @@ -422,6 +409,11 @@ impl FromProof for CompositeDocuments { Self: 'a, { let request: Self::Request = request.into(); + request + .check_composite_shape() + .map_err(|e| drive_proof_verifier::Error::RequestError { + error: e.to_string(), + })?; let response: Self::Response = response.into(); let query: DriveDocumentQuery = (&request).try_into().map_err(|e: Error| { @@ -466,8 +458,11 @@ mod tests { //! Drive exists. use super::*; + use dapi_grpc::platform::v0::get_documents_request::Version as RequestVersion; + use dapi_grpc::platform::v0::GetDocumentsRequest; use dpp::platform_value::Value; use dpp::tests::json_document::json_document_to_contract; + use dpp::version::TryFromPlatformVersioned; use drive::query::WhereOperator; const FEED_CONTRACT_PATH: &str = @@ -488,7 +483,7 @@ mod tests { /// The feed card composition: `dash` posts, their like counts, the /// posts they quote, and their authors' dashpay profiles. - fn feed_page(limit: u32) -> CompositeDocumentQuery { + fn feed_page(limit: u32) -> DocumentQuery { let feed = contract(FEED_CONTRACT_PATH); let dashpay = contract(DASHPAY_CONTRACT_PATH); let page = DocumentQuery::new(feed.clone(), "post") @@ -499,22 +494,21 @@ mod tests { value: Value::Text("dash".to_string()), }) .with_limit(limit); - CompositeDocumentQuery::new(page) - .with_sub_query( - CompositeSubQuery::count(feed.clone(), "like") - .expect("like doctype exists") - .bound_to_page("$id", "postId"), - ) - .with_sub_query( - CompositeSubQuery::documents(feed, "post") - .expect("post doctype exists") - .bound_to_page("quotedPostId", "$id"), - ) - .with_sub_query( - CompositeSubQuery::documents(dashpay, "profile") - .expect("profile doctype exists") - .bound_to_page("$ownerId", "$ownerId"), - ) + page.with_sub_query( + CompositeSubQuery::count(feed.clone(), "like") + .expect("like doctype exists") + .bound_to_page("$id", "postId"), + ) + .with_sub_query( + CompositeSubQuery::documents(feed, "post") + .expect("post doctype exists") + .bound_to_page("quotedPostId", "$id"), + ) + .with_sub_query( + CompositeSubQuery::documents(dashpay, "profile") + .expect("profile doctype exists") + .bound_to_page("$ownerId", "$ownerId"), + ) } #[test] @@ -572,19 +566,138 @@ mod tests { } #[test] - fn requires_a_page_limit() { - let refused = - GetDocumentsRequest::try_from_platform_versioned(feed_page(0), platform_version()); - assert!( - matches!(refused, Err(Error::Config(_))), - "a zero page limit must be refused, got {refused:?}" + fn should_preserve_the_full_composition_through_drive_conversion() { + let feed = contract(FEED_CONTRACT_PATH); + let query = feed_page(10).with_sub_query( + CompositeSubQuery::documents(feed, "repost") + .expect("repost doctype exists") + .bound_to(CompositeBindingSource::SubQuery(1), "$id", "postId") + .with_where(WhereClause { + field: "hashtag".to_string(), + operator: WhereOperator::Equal, + value: Value::Text("dash".to_string()), + }) + .with_order_by(OrderClause { + field: "postId".to_string(), + ascending: true, + }) + .with_limit(7), ); + let drive_query: DriveDocumentQuery = (&query).try_into().expect("converts"); + for restored in [ + DocumentQuery::try_from(&drive_query), + DocumentQuery::try_from(drive_query.clone()), + DocumentQuery::new_with_drive_query(&drive_query), + ] { + assert_eq!(restored.expect("preserves the composition"), query); + } + } + + #[test] + fn should_reject_compositions_on_v0_without_affecting_ordinary_queries() { + let mut v0 = platform_version().clone(); + v0.drive_abci.query.document_query.default_current_version = 0; + let query = feed_page(10); + let refused = GetDocumentsRequest::try_from_platform_versioned(query.clone(), &v0); + assert!(matches!(refused, Err(Error::Config(message)) if message.contains("V1"))); + + let ordinary = query.with_sub_queries(vec![]); + let request = GetDocumentsRequest::try_from_platform_versioned(ordinary.clone(), &v0) + .expect("ordinary queries still encode as V0"); + assert!(matches!(request.version, Some(RequestVersion::V0(_)))); + let request = + GetDocumentsRequest::try_from_platform_versioned(ordinary, platform_version()) + .expect("ordinary queries still encode as V1"); + let Some(RequestVersion::V1(v1)) = request.version else { + panic!("expected V1"); + }; + assert!(v1.sub_queries.is_empty()); + } + + #[test] + fn should_enforce_sub_query_limits_before_encoding_or_conversion() { + let query = feed_page(10); + let sub = query.sub_queries[0].clone(); + let maximum = query + .clone() + .with_sub_queries(vec![sub.clone(); MAX_SUB_QUERIES]); + GetDocumentsRequest::try_from_platform_versioned(maximum.clone(), platform_version()) + .expect("the maximum sub-query count encodes"); + DriveDocumentQuery::try_from(&maximum).expect("the maximum sub-query count converts"); + + let excessive = query.with_sub_queries(vec![sub; MAX_SUB_QUERIES + 1]); + assert!(GetDocumentsRequest::try_from_platform_versioned( + excessive.clone(), + platform_version() + ) + .is_err()); + assert!(DriveDocumentQuery::try_from(&excessive).is_err()); + + for limit in [0, 101, u32::MAX] { + let mut query = feed_page(10); + query.sub_queries[2].limit = Some(limit); + assert!(GetDocumentsRequest::try_from_platform_versioned( + query.clone(), + platform_version() + ) + .is_err()); + assert!(DriveDocumentQuery::try_from(&query).is_err()); + } + } + + #[test] + fn should_reject_invalid_binding_sources_before_encoding_or_conversion() { + // Source 0 is a count, source 2 is the sub-query itself, and a + // maximal index must not wrap when mapped to the wire's u32. + for source in [0, 2, 3, usize::MAX] { + let mut query = feed_page(10); + query.sub_queries[2].binding.as_mut().expect("bound").source = + CompositeBindingSource::SubQuery(source); + assert!(GetDocumentsRequest::try_from_platform_versioned( + query.clone(), + platform_version() + ) + .is_err()); + assert!(DriveDocumentQuery::try_from(&query).is_err()); + } + } + + #[cfg(feature = "mocks")] + #[test] + fn should_preserve_mock_compositions_and_read_older_ordinary_queries() { + let query = feed_page(10); + let encoded = serde_json::to_value(&query).expect("serializes"); + let restored: DocumentQuery = + serde_json::from_value(encoded.clone()).expect("deserializes"); + assert_eq!(restored, query); + + let mut legacy = encoded; + legacy + .as_object_mut() + .expect("query object") + .remove("sub_queries"); + let restored: DocumentQuery = serde_json::from_value(legacy).expect("reads older vectors"); + assert_eq!(restored, query.with_sub_queries(vec![])); + } + + #[test] + fn should_reject_invalid_page_limits() { + for limit in [0, 101, u32::MAX] { + let query = feed_page(limit); + let refused = + GetDocumentsRequest::try_from_platform_versioned(query.clone(), platform_version()); + assert!( + matches!(refused, Err(Error::Config(_))), + "an invalid page limit must be refused, got {refused:?}" + ); + assert!(DriveDocumentQuery::try_from(&query).is_err()); + } } #[test] fn refuses_unsupported_page_features() { let mut query = feed_page(10); - query.page.offset = Some(4); + query.offset = Some(4); let refused = GetDocumentsRequest::try_from_platform_versioned(query, platform_version()); assert!( matches!(refused, Err(Error::Config(_))), diff --git a/packages/dash-platform-queries/src/documents/count_proof_helpers.rs b/packages/dash-platform-queries/src/documents/count_proof_helpers.rs index cf060ecff31..edb68127635 100644 --- a/packages/dash-platform-queries/src/documents/count_proof_helpers.rs +++ b/packages/dash-platform-queries/src/documents/count_proof_helpers.rs @@ -145,6 +145,7 @@ pub(super) fn verify_count_query( platform_version: &PlatformVersion, provider: &dyn ContextProvider, ) -> Result<(Option>, ResponseMetadata, Proof), drive_proof_verifier::Error> { + request.ensure_no_sub_queries()?; let proof = response .proof() .or(Err(drive_proof_verifier::Error::NoProofInResult))?; diff --git a/packages/dash-platform-queries/src/documents/document_query.rs b/packages/dash-platform-queries/src/documents/document_query.rs index d02fa2cd498..d76c4c0843c 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::composite_document_query::{drive_sub_queries, sub_queries_to_proto, CompositeSubQuery}; use crate::error::Error; use dapi_grpc::platform::v0::get_documents_request::Version::{V0, V1}; use dapi_grpc::platform::v0::{ @@ -68,6 +69,10 @@ pub struct TimeRangeClause { /// required to correctly verify proofs returned by the Dash Platform. /// /// Conversions are implemented between this type, [GetDocumentsRequest] and [DriveDocumentQuery] using [TryFrom] trait. +/// +/// Add related document or count queries with [`Self::with_sub_query`]. +/// Fetch these compositions as [`drive_proof_verifier::CompositeDocuments`] +/// to receive both the page and its verified sub-results. #[derive(Debug, Clone, PartialEq, dash_platform_macros::Mockable)] #[cfg_attr(feature = "mocks", derive(serde::Serialize, serde::Deserialize))] pub struct DocumentQuery { @@ -168,6 +173,11 @@ pub struct DocumentQuery { pub offset: Option, /// first object to start with pub start: Option, + /// Related document and count queries derived from this page. Empty for + /// ordinary document or aggregate queries. Fetch nonempty compositions + /// as [`drive_proof_verifier::CompositeDocuments`]. + #[cfg_attr(feature = "mocks", serde(default))] + pub sub_queries: Vec, } /// Which end of a ranking a @@ -212,13 +222,23 @@ impl DocumentQuery { limit: 0, offset: None, start: None, + sub_queries: vec![], }) } + /// Ordinary document and aggregate proof results cannot represent sub-queries. + pub(super) fn ensure_no_sub_queries(&self) -> Result<(), drive_proof_verifier::Error> { + if !self.sub_queries.is_empty() { + return Err(drive_proof_verifier::Error::RequestError { + error: "this result type cannot return sub-queries; fetch the query as CompositeDocuments".to_string(), + }); + } + Ok(()) + } + /// Create new document query based on a [DriveDocumentQuery]. /// - /// Fails when the drive query carries sub-queries, which this plain - /// query cannot preserve — use the chained or composite surface. + /// Preserves sub-queries, including their contracts and bindings. /// /// Fails when the drive query carries time-range resolution provenance /// (`resolved_time_ranges`): the resolved bucket equality cannot be @@ -535,6 +555,7 @@ impl FromProof for drive_proof_verifier::types::Documents { Self: Sized + 'a, { let mut request: Self::Request = request.into(); + request.ensure_no_sub_queries()?; let response: Self::Response = response.into(); // A time-range (`IN_TIME_RANGE`) selection is resolved to a concrete @@ -674,6 +695,9 @@ impl TryFromPlatformVersioned for GetDocumentsRequest { value: DocumentQuery, platform_version: &PlatformVersion, ) -> Result { + if !value.sub_queries.is_empty() { + value.check_composite_shape()?; + } let DocumentQuery { select, data_contract, @@ -686,6 +710,7 @@ impl TryFromPlatformVersioned for GetDocumentsRequest { limit, offset, start, + sub_queries, } = value; let feature_version = platform_version @@ -703,6 +728,12 @@ impl TryFromPlatformVersioned for GetDocumentsRequest { match feature_version { 0 => { + if !sub_queries.is_empty() { + return Err(Error::Config( + "composite document queries require the V1 documents wire (Platform v3.1+)" + .to_string(), + )); + } if !time_range_clauses.is_empty() { return Err(Error::Config( "time range (IN_TIME_RANGE) queries require protocol version 14+; the \ @@ -757,6 +788,7 @@ impl TryFromPlatformVersioned for GetDocumentsRequest { select, group_by, having, + sub_queries_to_proto(sub_queries)?, ) } n => Err(Error::Config(format!( @@ -781,6 +813,7 @@ fn encode_v1( select: SelectProjection, group_by: Vec, having: Vec, + sub_queries: Vec, ) -> Result { let mut where_clauses = where_clauses .into_iter() @@ -875,7 +908,7 @@ fn encode_v1( // a second copy of that rule in the SDK. offset, chained: None, - sub_queries: Vec::new(), + sub_queries, })), }) } @@ -977,8 +1010,7 @@ fn encode_v0( impl<'a> TryFrom<&'a DriveDocumentQuery<'a>> for DocumentQuery { type Error = crate::error::Error; - /// Refuses sub-queries: a plain `DocumentQuery` cannot carry their - /// selections through SDK request construction and proof verification. + /// Preserves sub-queries through SDK request construction and proof verification. /// /// Fallible by necessity: a drive query carrying `resolved_time_ranges` /// holds bucket-start equalities whose meaning lives in the provenance, @@ -989,14 +1021,6 @@ impl<'a> TryFrom<&'a DriveDocumentQuery<'a>> for DocumentQuery { /// contract then rejects the request, while a contract with a competing /// plain index returns a different — but validly proven — result. fn try_from(value: &'a DriveDocumentQuery<'a>) -> Result { - if !value.sub_queries.is_empty() { - return Err(Error::Config( - "a drive query carrying sub-queries cannot be converted to a plain \ - DocumentQuery: its sub-queries would be discarded. Use the chained or \ - composite query surface instead" - .to_string(), - )); - } if !value.resolved_time_ranges.is_empty() { return Err(Error::Config( "a drive query carrying time-range resolution provenance cannot be \ @@ -1038,6 +1062,11 @@ impl<'a> TryFrom<&'a DriveDocumentQuery<'a>> for DocumentQuery { limit, offset, start, + sub_queries: value + .sub_queries + .iter() + .map(CompositeSubQuery::from) + .collect(), }) } } @@ -1046,7 +1075,7 @@ impl<'a> TryFrom> for DocumentQuery { type Error = crate::error::Error; /// By-value twin of the by-reference conversion above — same - /// sub-query and provenance rejections, same rationale. + /// sub-query preservation and provenance rejection, same rationale. fn try_from(value: DriveDocumentQuery<'a>) -> Result { DocumentQuery::try_from(&value) } @@ -1056,6 +1085,9 @@ impl<'a> TryFrom<&'a DocumentQuery> for DriveDocumentQuery<'a> { type Error = crate::error::Error; fn try_from(request: &'a DocumentQuery) -> Result { + if !request.sub_queries.is_empty() { + request.check_composite_shape()?; + } // A pending (unresolved) time-range selection MUST be resolved into a // concrete bucket-equality clause before a drive query can be built — // see `resolve_time_range_clauses_with_metadata_time`. Silently @@ -1170,9 +1202,7 @@ impl<'a> TryFrom<&'a DocumentQuery> for DriveDocumentQuery<'a> { // selections assign the fields they resolved onto the returned // query; everything else is a raw query. resolved_time_ranges: vec![], - // Composite sub-queries have no wire format yet: a query - // parsed from a request is always a plain page. - sub_queries: vec![], + sub_queries: drive_sub_queries(request)?, }; Ok(query) diff --git a/packages/dash-platform-queries/src/documents/having_proof_helpers.rs b/packages/dash-platform-queries/src/documents/having_proof_helpers.rs index 39742a6ebdd..ef6661513b2 100644 --- a/packages/dash-platform-queries/src/documents/having_proof_helpers.rs +++ b/packages/dash-platform-queries/src/documents/having_proof_helpers.rs @@ -96,6 +96,7 @@ pub(super) fn verify_having_query( platform_version: &PlatformVersion, provider: &dyn ContextProvider, ) -> Result<(Option>, ResponseMetadata, Proof), drive_proof_verifier::Error> { + request.ensure_no_sub_queries()?; let proof = response .proof() .or(Err(drive_proof_verifier::Error::NoProofInResult))?; diff --git a/packages/dash-platform-queries/src/documents/ranked_proof_helpers.rs b/packages/dash-platform-queries/src/documents/ranked_proof_helpers.rs index f1373a694e2..502432db609 100644 --- a/packages/dash-platform-queries/src/documents/ranked_proof_helpers.rs +++ b/packages/dash-platform-queries/src/documents/ranked_proof_helpers.rs @@ -124,6 +124,7 @@ pub(super) fn verify_ranked_query( platform_version: &PlatformVersion, provider: &dyn ContextProvider, ) -> Result<(Option, ResponseMetadata, Proof), drive_proof_verifier::Error> { + request.ensure_no_sub_queries()?; let proof = response .proof() .or(Err(drive_proof_verifier::Error::NoProofInResult))?; diff --git a/packages/dash-platform-queries/src/documents/sum_proof_helpers.rs b/packages/dash-platform-queries/src/documents/sum_proof_helpers.rs index a91d8d83b93..764f5fafcac 100644 --- a/packages/dash-platform-queries/src/documents/sum_proof_helpers.rs +++ b/packages/dash-platform-queries/src/documents/sum_proof_helpers.rs @@ -96,6 +96,7 @@ pub(super) fn verify_sum_query( platform_version: &PlatformVersion, provider: &dyn ContextProvider, ) -> Result<(Option>, ResponseMetadata, Proof), drive_proof_verifier::Error> { + request.ensure_no_sub_queries()?; let proof = response .proof() .or(Err(drive_proof_verifier::Error::NoProofInResult))?; diff --git a/packages/rs-drive-abci/src/query/document_query/v1/tests.rs b/packages/rs-drive-abci/src/query/document_query/v1/tests.rs index ecb95b07147..e6aa26b0fce 100644 --- a/packages/rs-drive-abci/src/query/document_query/v1/tests.rs +++ b/packages/rs-drive-abci/src/query/document_query/v1/tests.rs @@ -6354,7 +6354,7 @@ mod composite_trust_boundary { //! [`super::chained_trust_boundary`]: rs-drive's e2e suite covers //! the merk-level composition, and THIS suite runs the actual SDK //! entry points — the dash-platform-queries wire encoding and the - //! `FromProof` composition, including the + //! `FromProof` composition, including the //! tenderdash binding of the merged proof's root — against a //! server-generated proof. It lives here because generating proofs //! needs drive's server feature, which the client crates must not @@ -6368,9 +6368,7 @@ mod composite_trust_boundary { get_documents_response_v1, GetDocumentsResponseV1, Version as ResponseVersion, }; use dapi_grpc::platform::v0::{GetDocumentsRequest, GetDocumentsResponse, ResponseMetadata}; - use dash_platform_queries::documents::composite_document_query::{ - CompositeDocumentQuery, CompositeSubQuery, - }; + use dash_platform_queries::documents::composite_document_query::CompositeSubQuery; use dash_platform_queries::documents::document_query::DocumentQuery; use dpp::dashcore::Network; use dpp::data_contract::accessors::v0::DataContractV0Getters; @@ -6493,7 +6491,7 @@ mod composite_trust_boundary { /// The rich client-side query — the exact object an SDK caller /// hands to `CompositeDocuments::fetch`: the `dash` page, its like /// counts, the posts it quotes, and its authors' profiles. - fn client_query(feed: Arc, dashpay: Arc) -> CompositeDocumentQuery { + fn client_query(feed: Arc, dashpay: Arc) -> DocumentQuery { let page = DocumentQuery::new(feed.clone(), "post") .expect("post doctype exists") .with_where(WhereClause { @@ -6502,29 +6500,28 @@ mod composite_trust_boundary { value: Value::Text("dash".to_string()), }) .with_limit(10); - CompositeDocumentQuery::new(page) - .with_sub_query( - CompositeSubQuery::count(feed.clone(), "like") - .expect("like doctype exists") - .bound_to_page("$id", "postId"), - ) - .with_sub_query( - CompositeSubQuery::documents(feed, "post") - .expect("post doctype exists") - .bound_to_page("quotedPostId", "$id"), - ) - .with_sub_query( - CompositeSubQuery::documents(dashpay, "profile") - .expect("profile doctype exists") - .bound_to_page("$ownerId", "$ownerId"), - ) + page.with_sub_query( + CompositeSubQuery::count(feed.clone(), "like") + .expect("like doctype exists") + .bound_to_page("$id", "postId"), + ) + .with_sub_query( + CompositeSubQuery::documents(feed, "post") + .expect("post doctype exists") + .bound_to_page("quotedPostId", "$id"), + ) + .with_sub_query( + CompositeSubQuery::documents(dashpay, "profile") + .expect("profile doctype exists") + .bound_to_page("$ownerId", "$ownerId"), + ) } /// Server-side proof of the same shape, built from the rich query /// through the SDK's own conversion. fn prove( platform: &crate::platform_types::platform::Platform, - query: &CompositeDocumentQuery, + query: &DocumentQuery, ) -> (Vec, [u8; 32]) { let composite: DriveDocumentQuery = query.try_into().expect("the rich query converts"); let (proof, _page_documents) = platform @@ -6605,7 +6602,7 @@ mod composite_trust_boundary { ) .expect("the unified Drive query verifies through the generic proof path"); let (from_borrowed, _, _) = - >::maybe_from_proof_with_metadata( + >::maybe_from_proof_with_metadata( &query, response.clone(), Network::Testnet, @@ -6615,7 +6612,7 @@ mod composite_trust_boundary { .expect("the borrowed rich query verifies through the generic proof path"); let (verified, _mtd, _proof) = - >::maybe_from_proof_with_metadata( + >::maybe_from_proof_with_metadata( query, response, Network::Testnet, @@ -6674,6 +6671,61 @@ mod composite_trust_boundary { ); } + #[test] + fn should_reject_sub_queries_for_results_that_cannot_return_them() { + use drive::query::SelectProjection; + use drive_proof_verifier::types::Documents; + use drive_proof_verifier::{ + DocumentAverage, DocumentCount, DocumentHavingEntries, DocumentRankedEntries, + DocumentSplitAverages, DocumentSplitCounts, DocumentSplitSums, DocumentSum, + }; + + fn assert_rejected(query: DocumentQuery) + where + T: FromProof + + std::fmt::Debug, + { + let provider = TestQuorumProvider { pubkey: [0; 48] }; + let result = T::maybe_from_proof_with_metadata( + query, + GetDocumentsResponse::default(), + Network::Testnet, + platform_version(), + &provider, + ); + assert!( + matches!(&result, Err(drive_proof_verifier::Error::RequestError { error }) + if error.contains("CompositeDocuments")), + "{} must reject sub-queries before reading a proof: {result:?}", + std::any::type_name::(), + ); + } + + let feed = json_document_to_contract(FEED_CONTRACT_PATH, false, platform_version()) + .expect("feed contract"); + let dashpay = json_document_to_contract(DASHPAY_CONTRACT_PATH, false, platform_version()) + .expect("dashpay contract"); + let query = client_query(Arc::new(feed), Arc::new(dashpay)); + assert_rejected::(query.clone()); + assert_rejected::(query.clone()); + assert_rejected::(query.clone().with_select(SelectProjection::count_star())); + assert_rejected::( + query.clone().with_select(SelectProjection::count_star()), + ); + assert_rejected::(query.clone().with_select(SelectProjection::sum("amount"))); + assert_rejected::( + query.clone().with_select(SelectProjection::sum("amount")), + ); + assert_rejected::( + query.clone().with_select(SelectProjection::avg("amount")), + ); + assert_rejected::( + query.clone().with_select(SelectProjection::avg("amount")), + ); + assert_rejected::(query.clone()); + assert_rejected::(query); + } + /// A wrong quorum key fails the tenderdash binding — omitting or /// miswiring `verify_tenderdash_proof` turns this red. #[test] @@ -6703,7 +6755,7 @@ mod composite_trust_boundary { }; let refused = - >::maybe_from_proof_with_metadata( + >::maybe_from_proof_with_metadata( query, response_with(proof, mtd), Network::Testnet, @@ -6733,7 +6785,7 @@ mod composite_trust_boundary { tampered.height += 1; let refused = - >::maybe_from_proof_with_metadata( + >::maybe_from_proof_with_metadata( query, response_with(proof, tampered), Network::Testnet, @@ -6756,7 +6808,8 @@ mod composite_trust_boundary { let feed = Arc::new(feed); let dashpay = Arc::new(dashpay); let rich = client_query(feed.clone(), dashpay.clone()); - let page: DriveDocumentQuery = (&rich.page).try_into().expect("the page converts"); + let page_query = rich.clone().with_sub_queries(vec![]); + let page: DriveDocumentQuery = (&page_query).try_into().expect("the page converts"); let (grovedb_proof, _cost) = page .execute_with_proof(&platform.platform.drive, None, None, platform_version()) .expect("page proof generates"); @@ -6775,7 +6828,7 @@ mod composite_trust_boundary { }; let refused = - >::maybe_from_proof_with_metadata( + >::maybe_from_proof_with_metadata( client_query(feed, dashpay), response_with(proof, mtd), Network::Testnet, diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/contact_info.rs b/packages/rs-platform-wallet/src/wallet/identity/network/contact_info.rs index 126a0803f3e..20fd1192f1b 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/contact_info.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/contact_info.rs @@ -115,6 +115,7 @@ impl DashPayView<'_, B> { value: platform_value!(identity_id), }], time_range_clauses: vec![], + sub_queries: vec![], group_by: vec![], having: vec![], // Load-bearing, not cosmetic: drive answers a bare diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/dpns_marketplace.rs b/packages/rs-platform-wallet/src/wallet/identity/network/dpns_marketplace.rs index 05f66240859..603aff74b8c 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/dpns_marketplace.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/dpns_marketplace.rs @@ -521,6 +521,7 @@ fn domain_by_normalized_label_query( }, ], time_range_clauses: vec![], + sub_queries: vec![], group_by: vec![], having: vec![], order_by_clauses: vec![], @@ -563,6 +564,7 @@ fn history_by_source_document_query( }, ], time_range_clauses: vec![], + sub_queries: vec![], group_by: vec![], having: vec![], order_by_clauses: vec![OrderClause { @@ -665,6 +667,7 @@ impl IdentityWallet { document_type_name: DPNS_DOCUMENT_TYPE.to_string(), where_clauses, time_range_clauses: vec![], + sub_queries: vec![], group_by: vec![], having: vec![], order_by_clauses: vec![OrderClause { @@ -770,6 +773,7 @@ impl IdentityWallet { value: Value::Identifier(identity_id.to_buffer()), }], time_range_clauses: vec![], + sub_queries: vec![], group_by: vec![], having: vec![], order_by_clauses: vec![], @@ -3702,6 +3706,7 @@ mod tests { value: Value::Identifier(identity_id.to_buffer()), }], time_range_clauses: vec![], + sub_queries: vec![], group_by: vec![], having: vec![], order_by_clauses: vec![], diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/profile.rs b/packages/rs-platform-wallet/src/wallet/identity/network/profile.rs index 47f35c8a020..623a67cd2c8 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/profile.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/profile.rs @@ -747,6 +747,7 @@ fn single_profile_query( value: platform_value!(identity_id), }], time_range_clauses: vec![], + sub_queries: vec![], group_by: vec![], having: vec![], order_by_clauses: vec![], @@ -781,6 +782,7 @@ fn contact_profiles_chunk_query( value: in_values, }], time_range_clauses: vec![], + sub_queries: vec![], group_by: vec![], having: vec![], order_by_clauses: vec![OrderClause { diff --git a/packages/rs-sdk/src/platform.rs b/packages/rs-sdk/src/platform.rs index 2be44a55b7b..26aed12ccb0 100644 --- a/packages/rs-sdk/src/platform.rs +++ b/packages/rs-sdk/src/platform.rs @@ -32,8 +32,7 @@ pub use dash_context_provider::ContextProvider; pub use dash_context_provider::MockContextProvider; pub use documents::chained_document_query::ChainedDocumentQuery; pub use documents::composite_document_query::{ - CompositeBinding, CompositeBindingSource, CompositeDocumentQuery, CompositeSubQuery, - CompositeSubQueryKind, + CompositeBinding, CompositeBindingSource, CompositeSubQuery, CompositeSubQueryKind, }; pub use documents::document_history_query::DocumentHistoryQuery; pub use documents::document_query::DocumentQuery; diff --git a/packages/rs-sdk/src/platform/dashpay/contact_request_queries.rs b/packages/rs-sdk/src/platform/dashpay/contact_request_queries.rs index 841e66676e3..5e83e51dcba 100644 --- a/packages/rs-sdk/src/platform/dashpay/contact_request_queries.rs +++ b/packages/rs-sdk/src/platform/dashpay/contact_request_queries.rs @@ -106,6 +106,7 @@ impl Sdk { document_type_name: "contactRequest".to_string(), where_clauses: where_clauses.clone(), time_range_clauses: vec![], + sub_queries: vec![], group_by: vec![], having: vec![], // Load-bearing: a bare secondary-index equality with no diff --git a/packages/rs-sdk/src/platform/documents/composite_document_query_sdk.rs b/packages/rs-sdk/src/platform/documents/composite_document_query_sdk.rs deleted file mode 100644 index 43dadf4c156..00000000000 --- a/packages/rs-sdk/src/platform/documents/composite_document_query_sdk.rs +++ /dev/null @@ -1,44 +0,0 @@ -//! Sdk-bound half of the composite document query surface: the rich → -//! wire encoding. The transport-free query type itself -//! ([`CompositeDocumentQuery`]) lives in `dash-platform-queries`. - -use dapi_grpc::platform::v0 as platform_proto; -use dapi_grpc::platform::v0::GetDocumentsRequest; -use dash_platform_queries::documents::composite_document_query::CompositeDocumentQuery; -use dpp::version::TryFromPlatformVersioned; - -use crate::Error; - -/// Encode a [`CompositeDocumentQuery`] onto the wire. -/// -/// The [`Fetch`](crate::platform::Fetch) trampoline for -/// [`drive_proof_verifier::CompositeDocuments`] splits `Query = -/// CompositeDocumentQuery` (rich, what `FromProof` binds to) from -/// `Request = GetDocumentsRequest` (wire); this impl is the rich→wire -/// step. -impl crate::platform::Query for CompositeDocumentQuery { - fn query( - &self, - settings: &crate::platform::QuerySettings<'_>, - ) -> Result { - GetDocumentsRequest::try_from_platform_versioned(self.clone(), settings.protocol_version) - .map_err(Error::from) - } -} - -// `CompositeDocumentQuery` does not implement `TransportRequest` (the -// wire form is `GetDocumentsRequest`), so the blanket `Query for T` -// does not apply — provide the identity impl explicitly, same as -// `DocumentQuery`'s, so the fetch trampoline can use it both as the -// user-supplied `Q` and as the rich `Self::Query`. -impl crate::platform::Query for CompositeDocumentQuery { - fn query( - &self, - settings: &crate::platform::QuerySettings<'_>, - ) -> Result { - if !settings.prove { - tracing::warn!(request= ?self, "sending query without proof, ensure data is trusted"); - } - Ok(self.clone()) - } -} diff --git a/packages/rs-sdk/src/platform/documents/document_query_sdk.rs b/packages/rs-sdk/src/platform/documents/document_query_sdk.rs index f80dc19b6f4..4eb62837ea1 100644 --- a/packages/rs-sdk/src/platform/documents/document_query_sdk.rs +++ b/packages/rs-sdk/src/platform/documents/document_query_sdk.rs @@ -52,7 +52,8 @@ impl DocumentQuerySdk for DocumentQuery { /// currently-known [`dpp::version::PlatformVersion`] for V0 vs V1 dispatch. /// /// The [`Fetch`] / [`FetchMany`](crate::platform::FetchMany) trampolines for -/// [`dpp::document::Document`] (and the document aggregate views) split +/// [`dpp::document::Document`], the document aggregate views, and +/// [`drive_proof_verifier::CompositeDocuments`] split /// `Fetch::Query = DocumentQuery` (rich, what `FromProof` binds to) from /// `Fetch::Request = GetDocumentsRequest` (wire); this impl is the /// rich→wire step the trampoline invokes via diff --git a/packages/rs-sdk/src/platform/documents/fetch_bindings.rs b/packages/rs-sdk/src/platform/documents/fetch_bindings.rs index 54871c1b487..41cdd049c08 100644 --- a/packages/rs-sdk/src/platform/documents/fetch_bindings.rs +++ b/packages/rs-sdk/src/platform/documents/fetch_bindings.rs @@ -58,6 +58,6 @@ impl Fetch for drive_proof_verifier::ChainedDocuments { } impl Fetch for drive_proof_verifier::CompositeDocuments { - type Query = dash_platform_queries::documents::composite_document_query::CompositeDocumentQuery; + type Query = DocumentQuery; type Request = dapi_grpc::platform::v0::GetDocumentsRequest; } diff --git a/packages/rs-sdk/src/platform/documents/mod.rs b/packages/rs-sdk/src/platform/documents/mod.rs index 16d104b367e..e34a2d0be6f 100644 --- a/packages/rs-sdk/src/platform/documents/mod.rs +++ b/packages/rs-sdk/src/platform/documents/mod.rs @@ -12,7 +12,6 @@ pub use dash_platform_queries::documents::{ }; pub mod chained_document_query_sdk; -pub mod composite_document_query_sdk; pub mod document_query_sdk; mod fetch_bindings; pub mod transitions; diff --git a/packages/rs-sdk/src/platform/dpns_usernames/mod.rs b/packages/rs-sdk/src/platform/dpns_usernames/mod.rs index 0955ea24454..f7831f29c4b 100644 --- a/packages/rs-sdk/src/platform/dpns_usernames/mod.rs +++ b/packages/rs-sdk/src/platform/dpns_usernames/mod.rs @@ -342,6 +342,7 @@ impl Sdk { }, ], time_range_clauses: vec![], + sub_queries: vec![], group_by: vec![], having: vec![], order_by_clauses: vec![], @@ -402,6 +403,7 @@ impl Sdk { }, ], time_range_clauses: vec![], + sub_queries: vec![], group_by: vec![], having: vec![], order_by_clauses: vec![], diff --git a/packages/rs-sdk/src/platform/dpns_usernames/queries.rs b/packages/rs-sdk/src/platform/dpns_usernames/queries.rs index 87f36b6ad03..81b085caacb 100644 --- a/packages/rs-sdk/src/platform/dpns_usernames/queries.rs +++ b/packages/rs-sdk/src/platform/dpns_usernames/queries.rs @@ -56,6 +56,7 @@ impl Sdk { value: Value::Identifier(identity_id.to_buffer()), }], time_range_clauses: vec![], + sub_queries: vec![], group_by: vec![], having: vec![], order_by_clauses: vec![], // Remove ordering by $createdAt as it might not be indexed @@ -144,6 +145,7 @@ impl Sdk { }, ], time_range_clauses: vec![], + sub_queries: vec![], group_by: vec![], having: vec![], order_by_clauses: vec![OrderClause { diff --git a/packages/rs-sdk/tests/fetch/document_query_v0_v1.rs b/packages/rs-sdk/tests/fetch/document_query_v0_v1.rs index a8c4480aeee..145c83214e2 100644 --- a/packages/rs-sdk/tests/fetch/document_query_v0_v1.rs +++ b/packages/rs-sdk/tests/fetch/document_query_v0_v1.rs @@ -66,6 +66,78 @@ fn build_basic_document_query() -> DocumentQuery { .with_limit(7) } +/// Exercise the shared DocumentQuery adapter, Fetch binding, and mock +/// serialization together. Proof verification is tested against real +/// server-generated proofs in drive-abci's composite trust-boundary suite. +#[tokio::test] +async fn should_fetch_composite_documents_with_the_unified_query() { + use super::common::bootstrap_mock_sdk_to_latest; + use dash_sdk::platform::{CompositeDocuments, CompositeSubQuery, Fetch}; + use dpp::data_contract::accessors::v0::DataContractV0Getters; + use dpp::data_contract::document_type::random_document::CreateRandomDocument; + use dpp::document::DocumentV0Getters; + use dpp::tests::json_document::json_document_to_contract; + use drive_proof_verifier::{CompositeSubQueryResult, SplitCountEntry}; + + let mut sdk = SdkBuilder::new_mock().build().expect("mock SDK"); + bootstrap_mock_sdk_to_latest(&mut sdk).await; + let feed = Arc::new( + json_document_to_contract( + "../rs-drive/tests/supporting_files/contract/yappr-feed/yappr-feed-contract.json", + false, + sdk.version(), + ) + .expect("feed contract"), + ); + let post_type = feed.document_type_for_name("post").expect("post doctype"); + let mut post = post_type + .random_document(Some(1), sdk.version()) + .expect("page document"); + let mut quoted = post_type + .random_document(Some(2), sdk.version()) + .expect("quoted document"); + // Mock documents share the ordinary document CBOR format, which + // decodes binary properties as Bytes without a contract schema. + post.properties_mut().insert( + "quotedPostId".to_string(), + Value::Bytes(quoted.id().to_vec()), + ); + quoted.properties_mut().remove("quotedPostId"); + let query = DocumentQuery::new(feed.clone(), "post") + .expect("page query") + .with_limit(10) + .with_sub_query( + CompositeSubQuery::count(feed.clone(), "like") + .expect("count query") + .bound_to_page("$id", "postId"), + ) + .with_sub_query( + CompositeSubQuery::documents(feed, "post") + .expect("quoted query") + .bound_to_page("quotedPostId", "$id"), + ); + let expected = CompositeDocuments { + sub_results: vec![ + CompositeSubQueryResult::Counts(vec![SplitCountEntry { + in_key: None, + key: post.id().to_vec(), + count: Some(2), + }]), + CompositeSubQueryResult::Documents(vec![quoted]), + ], + page_documents: vec![post], + }; + sdk.mock() + .expect_fetch::(query.clone(), Some(expected.clone())) + .await + .expect("register composition"); + let retrieved = CompositeDocuments::fetch(&sdk, query) + .await + .expect("fetch composition") + .expect("proven page"); + assert_eq!(retrieved, expected); +} + #[test] fn v1_wire_shape_with_latest_platform_version() { let q = build_basic_document_query(); diff --git a/packages/wasm-sdk/src/dpns.rs b/packages/wasm-sdk/src/dpns.rs index c2f0677b4f7..3c57617b20d 100644 --- a/packages/wasm-sdk/src/dpns.rs +++ b/packages/wasm-sdk/src/dpns.rs @@ -278,6 +278,7 @@ impl WasmSdk { value: Value::Identifier(identity_id.to_buffer()), }], time_range_clauses: vec![], + sub_queries: vec![], group_by: vec![], having: vec![], order_by_clauses: vec![], From edaea15da8968e5dc46b2306ca5c5e99e9c021c0 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Tue, 8 Sep 2026 07:18:22 +0700 Subject: [PATCH 3/3] fix(ci): refresh local discovery after DAPI is ready The local seed starts its quorum discovery service before the DAPI nodes. Its initial version checks can all fail and remain cached, causing the browser suite to abort while initializing the trusted SDK with no eligible masternode addresses. After yarn start has confirmed DAPI readiness, restart only the local seed quorum discovery container and wait for the same enabled/success eligibility condition used by the SDK. Bound the wait and fail startup if discovery cannot recover, before any dependent tests run. Validated YAML and Bash syntax, ShellCheck, and five isolated startup checks. --- .github/actions/local-network/action.yaml | 30 +++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/.github/actions/local-network/action.yaml b/.github/actions/local-network/action.yaml index 68eb9849ee8..864741a3abf 100644 --- a/.github/actions/local-network/action.yaml +++ b/.github/actions/local-network/action.yaml @@ -102,3 +102,33 @@ runs: run: yarn start env: DEBUG: 1 + + - name: Refresh local masternode discovery + shell: bash + run: | + set -euo pipefail + + # The seed's discovery service starts before the DAPI nodes and can + # cache failed version checks for ten minutes. `yarn start` waits for + # DAPI readiness; restart discovery now so it checks the ready nodes. + quorum_discovery_container=$(docker ps -q \ + --filter label=com.docker.compose.service=quorum_list \ + --filter label=org.dashmate.config.name=local_seed) + if [[ -z "$quorum_discovery_container" || "$quorum_discovery_container" == *$'\n'* ]]; then + echo "::error::Expected exactly one local seed quorum discovery container" + exit 1 + fi + docker restart "$quorum_discovery_container" + + # Match the SDK's eligibility filter, not just HTTP readiness: a + # successful response can still contain only failed version checks. + quorum_discovery_deadline=$((SECONDS + 120)) + until curl --fail --silent --show-error --connect-timeout 2 --max-time 5 \ + http://127.0.0.1:22444/masternodes \ + | jq -e '.success == true and any(.data[]?; .status == "ENABLED" and .versionCheck == "success")' > /dev/null; do + if (( SECONDS >= quorum_discovery_deadline )); then + echo "::error::Local masternode discovery did not return an eligible node within 120 seconds" + exit 1 + fi + sleep 2 + done