From 2e24461647e0f0d4c6eb1b8d909f8e638a4fe9d7 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Fri, 4 Sep 2026 20:45:07 +0200 Subject: [PATCH] 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, per-value 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 | 421 ++++++++++++ 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, 1349 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..3537297c6d6 --- /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::drive_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::drive_composite_document_query::{ + BindingSource, DriveCompositeDocumentQuery, DriveSubQuery, SubQueryBinding, SubQueryKind, +}; +use drive::query::{DriveDocumentQuery, OrderClause, SelectProjection, 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 per-value 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 DriveCompositeDocumentQuery<'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(DriveCompositeDocumentQuery { page, 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: DriveCompositeDocumentQuery = (&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: DriveCompositeDocumentQuery = + (&query).try_into().expect("converts to a drive query"); + drive_query + .validate(platform_version()) + .expect("the feed card shape validates"); + assert_eq!(drive_query.page.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: DriveCompositeDocumentQuery = + (&query).try_into().expect("conversion itself succeeds"); + assert!( + drive_query.validate(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 a8e88a37690..4da10ea4de2 100644 --- a/packages/dash-platform-queries/src/documents/document_query.rs +++ b/packages/dash-platform-queries/src/documents/document_query.rs @@ -1173,7 +1173,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, @@ -1185,7 +1185,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 a20462473a7..a4cc8bd4514 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,424 @@ 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::drive_composite_document_query::DriveCompositeDocumentQuery; + 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: DriveCompositeDocumentQuery = + 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 (verified, _mtd, _proof) = + >::maybe_from_proof_with_metadata( + query, + response_with(proof, mtd), + Network::Testnet, + platform_version(), + &provider, + ) + .expect("a correctly signed composite composition must verify"); + + 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..fc01ec4b0b6 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::drive_composite_document_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..c09be4c3998 --- /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 +//! ([`DriveCompositeDocumentQuery::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::drive_composite_document_query::{DriveCompositeDocumentQuery, 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 +/// [`DriveCompositeDocumentQuery::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: &DriveCompositeDocumentQuery, + 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: DriveCompositeDocumentQuery<'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;