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 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 new file mode 100644 index 00000000000..01f24bd7cab --- /dev/null +++ b/packages/dash-platform-queries/src/documents/composite_document_query.rs @@ -0,0 +1,760 @@ +//! Composite document queries — the client half of "a page plus the +//! sub-queries derived from it", answered as ONE merged proof. +//! +//! 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 +//! 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::{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; +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, MAX_SUB_QUERIES, +}; +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 + /// [`DocumentQuery::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 + } +} + +impl From<&DriveSubQuery<'_>> for CompositeSubQuery { + fn from(sub: &DriveSubQuery<'_>) -> Self { + use dpp::data_contract::document_type::accessors::DocumentTypeV0Getters; + Self { + 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(), + }), + } + } +} + +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 || 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" + ))); + } + 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(()) +} + +/// 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>>() +} + +/// 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), + }, + source_property: binding.source_property.clone(), + field: binding.field.clone(), + }), + }) + }) + .collect::, Error>>() +} + +impl FromProof for CompositeDocuments { + type Request = DocumentQuery; + 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(); + 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| { + 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 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 = + "../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) -> DocumentQuery { + 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); + 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 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.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/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 45188fa642e..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) @@ -1190,7 +1220,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 +1232,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/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/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/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 44e189b95ba..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 @@ -6346,3 +6346,498 @@ 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::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) -> DocumentQuery { + 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); + 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: &DocumentQuery, + ) -> (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" + ); + } + + #[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] + 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_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"); + 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-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/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..26aed12ccb0 100644 --- a/packages/rs-sdk/src/platform.rs +++ b/packages/rs-sdk/src/platform.rs @@ -31,6 +31,9 @@ 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, 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 +45,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/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/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 20c5f4f8060..41cdd049c08 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 = 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 ebfb4dd8ebf..e34a2d0be6f 100644 --- a/packages/rs-sdk/src/platform/documents/mod.rs +++ b/packages/rs-sdk/src/platform/documents/mod.rs @@ -6,9 +6,9 @@ //! 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; 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![],