diff --git a/.github/grpc-queries-cache.json b/.github/grpc-queries-cache.json index ccdfbd20c97..881b898725a 100644 --- a/.github/grpc-queries-cache.json +++ b/.github/grpc-queries-cache.json @@ -173,6 +173,9 @@ }, "getShieldedNotesCount": { "status": "implemented" + }, + "getChainedDocuments": { + "status": "implemented" } } } diff --git a/packages/dash-platform-queries/src/documents/chained_document_query.rs b/packages/dash-platform-queries/src/documents/chained_document_query.rs new file mode 100644 index 00000000000..ce46a4f5551 --- /dev/null +++ b/packages/dash-platform-queries/src/documents/chained_document_query.rs @@ -0,0 +1,305 @@ +//! Chained document queries — the client half of the provable semi-join: +//! `SELECT * FROM WHERE $id IN (SELECT FROM +//! WHERE …)`. +//! +//! The inner half is an ordinary [`DocumentQuery`] against an indexOnly +//! document type; the request carries no outer clauses at all — the +//! server derives the outer by-ids query from the inner results, and the +//! verifier re-derives it from the PROVEN inner results, so the join can +//! never be steered by the responding node. See +//! `drive::query::drive_chained_document_query` for the trust model. + +use crate::documents::document_query::DocumentQuery; +use crate::error::Error; +use dapi_grpc::platform::v0::get_documents_request::get_documents_request_v1::ChainedJoin; +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::version::{PlatformVersion, TryFromPlatformVersioned}; +use dpp::ProtocolError; +use drive::query::drive_chained_document_query::DriveChainedDocumentQuery; +use drive::query::DriveDocumentQuery; +use drive_proof_verifier::{ + verify_chained_documents_tenderdash_proof, ChainedDocuments, FromProof, +}; + +/// A chained document query: the inner [`DocumentQuery`] plus the join +/// edge. The outer half has no clauses by design — it is derived. +/// +/// The inner query MUST carry an explicit non-zero limit (it bounds the +/// derived outer query; there is no server-default sentinel on this +/// surface) and must resolve, server-side, to an indexOnly index +/// carrying `join_property`. +#[derive(Debug, Clone, PartialEq, dash_platform_macros::Mockable)] +#[cfg_attr(feature = "mocks", derive(serde::Serialize, serde::Deserialize))] +pub struct ChainedDocumentQuery { + /// The inner query (the subselect). + pub inner: DocumentQuery, + /// The inner property whose proven values become the outer `$id`s. + /// Must carry a same-contract `refersTo: permanentDocument` + /// declaration targeting `outer_document_type_name`. + pub join_property: String, + /// The outer (joined) document type — the `refersTo` target. + pub outer_document_type_name: String, +} + +impl ChainedDocumentQuery { + /// A chained query joining `inner`'s `join_property` values onto + /// documents of `outer_document_type_name`. + pub fn new( + inner: DocumentQuery, + join_property: impl Into, + outer_document_type_name: impl Into, + ) -> Self { + Self { + inner, + join_property: join_property.into(), + outer_document_type_name: outer_document_type_name.into(), + } + } +} + +impl TryFromPlatformVersioned for GetDocumentsRequest { + type Error = Error; + + fn try_from_platform_versioned( + value: ChainedDocumentQuery, + platform_version: &PlatformVersion, + ) -> Result { + let ChainedDocumentQuery { + inner, + join_property, + outer_document_type_name, + } = value; + + if inner.limit == 0 { + return Err(Error::Config( + "a chained document query requires an explicit non-zero inner limit: it \ + bounds the derived outer query, so there is no server-default sentinel" + .to_string(), + )); + } + if !inner.time_range_clauses.is_empty() + || inner.start.is_some() + || inner.offset.is_some() + || !inner.group_by.is_empty() + || !inner.having.is_empty() + { + return Err(Error::Config( + "a chained inner query supports where/order_by/limit only: no time-range \ + selections, cursors, offsets, group_by, or having (paginate with a range \ + clause on the join property)" + .to_string(), + )); + } + + // The chained surface rides the typed V1 wire: encode the + // inner query through the standard versioned encoder, then + // attach the join spec. 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(inner, platform_version)?; + match request.version.as_mut() { + Some(RequestVersion::V1(v1)) => { + v1.chained = Some(ChainedJoin { + join_property, + outer_document_type: outer_document_type_name, + }); + } + _ => { + return Err(Error::Config( + "chained 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 ChainedDocumentQuery> for DriveChainedDocumentQuery<'a> { + type Error = Error; + + fn try_from(request: &'a ChainedDocumentQuery) -> Result { + let inner: DriveDocumentQuery<'a> = (&request.inner).try_into()?; + let outer_document_type = request + .inner + .data_contract + .document_type_for_name(&request.outer_document_type_name) + .map_err(|e| Error::Protocol(ProtocolError::DataContractError(e)))?; + Ok(DriveChainedDocumentQuery { + inner, + join_property: request.join_property.clone(), + outer_document_type, + }) + } +} + +impl FromProof for ChainedDocuments { + type Request = ChainedDocumentQuery; + 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: DriveChainedDocumentQuery = (&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 join + // values from it via a subset pass. + 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, chained) = verify_chained_documents_tenderdash_proof( + &query, + proof, + mtd, + platform_version, + provider, + )?; + + // An empty inner page is a valid, proven "you have nothing + // here" — surface it as Some(empty) rather than None so callers + // can tell it apart from a missing object. + Ok((Some(chained), mtd.clone(), proof.clone())) + } +} + +#[cfg(test)] +mod tests { + //! Offline tests for the chained client surface: the V1 + //! request-wire encoding (typed clauses, no CBOR), the + //! unsupported-feature rejections, and the rich→drive conversion + + //! shared shape validation against the yappr-likes fixture. Proof + //! verification is exercised end-to-end in rs-drive's + //! `chained_query_e2e_tests` and rs-drive-abci's v1 chained + //! dispatch tests, where a populated Drive exists. + + use super::*; + use dpp::data_contract::DataContract; + use dpp::platform_value::Value; + use dpp::tests::json_document::json_document_to_contract; + use drive::query::{WhereClause, WhereOperator}; + use std::sync::Arc; + + const YAPPR_CONTRACT_PATH: &str = + "../rs-drive/tests/supporting_files/contract/yappr-likes/yappr-likes-contract.json"; + const OWNER: [u8; 32] = [0x11; 32]; + + fn platform_version() -> &'static PlatformVersion { + PlatformVersion::latest() + } + + fn yappr_contract() -> Arc { + Arc::new( + json_document_to_contract(YAPPR_CONTRACT_PATH, false, platform_version()) + .expect("expected to parse the yappr-likes contract"), + ) + } + + fn posts_i_liked(limit: u32) -> ChainedDocumentQuery { + let inner = DocumentQuery::new(yappr_contract(), "like") + .expect("like doctype exists") + .with_where(WhereClause { + field: "$ownerId".to_string(), + operator: WhereOperator::Equal, + value: Value::Identifier(OWNER), + }) + .with_limit(limit); + ChainedDocumentQuery::new(inner, "postId", "post") + } + + #[test] + fn encodes_the_v1_wire_shape() { + let request = + GetDocumentsRequest::try_from_platform_versioned(posts_i_liked(10), platform_version()) + .expect("encodes"); + let Some(RequestVersion::V1(v1)) = request.version else { + panic!("expected a V1 request"); + }; + assert_eq!(v1.document_type, "like"); + assert_eq!(v1.limit, Some(10)); + assert!(v1.prove, "chained fetch always proves"); + assert!(v1.order_by.is_empty()); + // Typed clauses on the wire — no CBOR anywhere on this surface. + assert_eq!(v1.where_clauses.len(), 1); + assert_eq!(v1.where_clauses[0].field, "$ownerId"); + let chained = v1.chained.expect("the join spec rides the request"); + assert_eq!(chained.join_property, "postId"); + assert_eq!(chained.outer_document_type, "post"); + } + + #[test] + fn requires_an_inner_limit() { + let refused = + GetDocumentsRequest::try_from_platform_versioned(posts_i_liked(0), platform_version()); + assert!( + matches!(refused, Err(Error::Config(_))), + "a zero inner limit must be refused, got {refused:?}" + ); + } + + #[test] + fn refuses_unsupported_inner_features() { + let mut query = posts_i_liked(10); + query.inner.group_by = vec!["hashtag".to_string()]; + let refused = GetDocumentsRequest::try_from_platform_versioned(query, platform_version()); + assert!( + matches!(refused, Err(Error::Config(_))), + "an inner group_by must be refused, got {refused:?}" + ); + } + + #[test] + fn converts_to_a_valid_drive_query() { + let query = posts_i_liked(10); + let drive_query: DriveChainedDocumentQuery = + (&query).try_into().expect("converts to a drive query"); + drive_query + .validate(platform_version()) + .expect("the byLiker shape validates"); + assert_eq!(drive_query.join_property, "postId"); + assert_eq!(drive_query.inner.limit, Some(10)); + } + + #[test] + fn conversion_surfaces_shape_errors() { + let query = ChainedDocumentQuery::new( + DocumentQuery::new(yappr_contract(), "like") + .expect("like doctype exists") + .with_limit(10), + "hashtag", + "post", + ); + let drive_query: DriveChainedDocumentQuery = + (&query).try_into().expect("conversion itself succeeds"); + let refused = drive_query.validate(platform_version()); + assert!( + refused.is_err(), + "a non-refersTo join property must fail validation" + ); + } +} diff --git a/packages/dash-platform-queries/src/documents/mod.rs b/packages/dash-platform-queries/src/documents/mod.rs index 65cde6af086..fcc5f578a02 100644 --- a/packages/dash-platform-queries/src/documents/mod.rs +++ b/packages/dash-platform-queries/src/documents/mod.rs @@ -1,4 +1,5 @@ pub(crate) mod average_proof_helpers; +pub mod chained_document_query; pub(crate) mod count_proof_helpers; pub mod document_average; pub mod document_count; diff --git a/packages/rs-drive-proof-verifier/src/lib.rs b/packages/rs-drive-proof-verifier/src/lib.rs index f91801de6e1..55c201b1cf9 100644 --- a/packages/rs-drive-proof-verifier/src/lib.rs +++ b/packages/rs-drive-proof-verifier/src/lib.rs @@ -9,6 +9,9 @@ mod proof; pub mod types; mod verify; pub use error::Error; +pub use proof::chained_document::{ + verify_chained_documents_proof as verify_chained_documents_tenderdash_proof, ChainedDocuments, +}; 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 ffcd66171da..39a7dd34497 100644 --- a/packages/rs-drive-proof-verifier/src/proof.rs +++ b/packages/rs-drive-proof-verifier/src/proof.rs @@ -1,3 +1,7 @@ +/// Verified chained-document (provable semi-join) result: two grovedb +/// 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 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/chained_document.rs b/packages/rs-drive-proof-verifier/src/proof/chained_document.rs new file mode 100644 index 00000000000..afe9feba12e --- /dev/null +++ b/packages/rs-drive-proof-verifier/src/proof/chained_document.rs @@ -0,0 +1,125 @@ +//! Verified **chained document** (provable semi-join) results. +//! +//! A chained query is `SELECT * FROM WHERE $id IN (SELECT +//! FROM WHERE …)` answered as ONE merged +//! grovedb proof: the limited inner indexOnly page and the outer +//! by-ids fetch derived from its values, merged by the server (grovedb +//! lifts the inner limit into a per-instance branch limit). The +//! verifier ([`DriveChainedDocumentQuery::verify_chained_documents_proof`]) +//! reconstructs the merged query from the response's UNTRUSTED +//! join-value hint, verifies in one pass, and requires the proven +//! outer documents to match the PROVEN inner join values exactly — a +//! missing referenced document is an invalid proof (`refersTo: +//! permanentDocument` targets cannot dangle) — and 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 chained response is free to fabricate +//! the join entirely (substitute, omit, inject), which is precisely +//! what the surface exists to prevent. [`ChainedDocuments`] 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_chained_document_query::DriveChainedDocumentQuery; +use drive::verify::RootHash; + +/// The verified result of a chained document query, both halves in +/// inner-proof order. +#[derive(Debug, Clone, PartialEq, Default)] +pub struct ChainedDocuments { + /// The inner projections (synthesized indexOnly documents), exactly + /// as the inner query alone would return them. The last one's join + /// property carries the pagination cursor. + pub inner_documents: Vec, + /// The joined outer documents, ordered by first appearance of their + /// id among the inner projections (deduplicated). + pub outer_documents: Vec, +} + +/// Verify a chained query's single merged proof and bind its root hash +/// to the quorum signature. +/// +/// The merk-level composition (bootstrap subset pass on the inner +/// query, merged-query re-derivation, authoritative full verification, +/// exact set equality against the PROVEN join values) lives in rs-drive's +/// [`DriveChainedDocumentQuery::verify_chained_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_chained_documents_proof( + query: &DriveChainedDocumentQuery, + proof: &Proof, + mtd: &ResponseMetadata, + platform_version: &PlatformVersion, + provider: &dyn ContextProvider, +) -> Result<(RootHash, ChainedDocuments), Error> { + let (root_hash, result) = query + .verify_chained_documents_proof(&proof.grovedb_proof, platform_version) + .map_drive_error(proof, mtd)?; + + verify_tenderdash_proof(proof, mtd, &root_hash, provider)?; + + Ok(( + root_hash, + ChainedDocuments { + inner_documents: result.inner_documents, + outer_documents: result.outer_documents, + }, + )) +} + +impl<'dq, Q> FromProof for ChainedDocuments +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: DriveChainedDocumentQuery<'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 join + // values from it via a subset pass. + let proof = response.proof().or(Err(Error::NoProofInResult))?; + let mtd = response.metadata().or(Err(Error::EmptyResponseMetadata))?; + + let (_root_hash, chained) = + verify_chained_documents_proof(&query, proof, mtd, platform_version, provider)?; + + // An empty inner page is a valid, proven "you have nothing + // here" — surface it as Some(empty) rather than None so callers + // can tell it apart from a missing object. + Ok((Some(chained), mtd.clone(), proof.clone())) + } +} diff --git a/packages/rs-sdk/src/mock/requests.rs b/packages/rs-sdk/src/mock/requests.rs index 8cd7c1c445d..efb7fee74a7 100644 --- a/packages/rs-sdk/src/mock/requests.rs +++ b/packages/rs-sdk/src/mock/requests.rs @@ -818,3 +818,48 @@ impl MockResponse for drive_proof_verifier::DocumentHavingEntries { } } } + +/// Wire shape for `ChainedDocuments` mock round-trip: both halves as +/// per-document CBOR lists. +type MockChainedHalves = (Vec>, Vec>); + +impl MockResponse for drive_proof_verifier::ChainedDocuments { + /// Both halves as per-document CBOR, bincode-framed as + /// `(inner, outer)` — list order IS the answer (inner-proof order, + /// outer by first appearance), so a map-shaped encoding would + /// destroy it. + fn mock_serialize(&self, _sdk: &MockDashPlatformSdk) -> Vec { + let bincode_config = standard(); + let halves: MockChainedHalves = ( + self.inner_documents + .iter() + .map(|d| d.to_cbor().expect("encode inner document")) + .collect(), + self.outer_documents + .iter() + .map(|d| d.to_cbor().expect("encode outer document")) + .collect(), + ); + bincode::encode_to_vec(halves, bincode_config).expect("encode ChainedDocuments") + } + + fn mock_deserialize(sdk: &MockDashPlatformSdk, buf: &[u8]) -> Self + where + Self: Sized, + { + let bincode_config = standard(); + let ((inner, outer), _): (MockChainedHalves, _) = + bincode::decode_from_slice(buf, bincode_config).expect("decode ChainedDocuments"); + let decode = |bufs: Vec>| { + bufs.into_iter() + .map(|b| { + Document::from_cbor(&b, None, None, sdk.version()).expect("decode document") + }) + .collect() + }; + drive_proof_verifier::ChainedDocuments { + inner_documents: decode(inner), + outer_documents: decode(outer), + } + } +} diff --git a/packages/rs-sdk/src/platform.rs b/packages/rs-sdk/src/platform.rs index d6a3213036e..25df127e249 100644 --- a/packages/rs-sdk/src/platform.rs +++ b/packages/rs-sdk/src/platform.rs @@ -30,6 +30,7 @@ pub use dapi_grpc::platform::v0 as proto; 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::document_history_query::DocumentHistoryQuery; pub use documents::document_query::DocumentQuery; /// Sdk-bound constructors for [`DocumentQuery`]. Must be in scope to call @@ -41,6 +42,7 @@ pub use dpp::{ prelude::{DataContract, Identifier, Identity, IdentityPublicKey, Revision}, }; pub use drive::query::DriveDocumentQuery; +pub use drive_proof_verifier::ChainedDocuments; pub use rs_dapi_client as dapi; pub use { fetch::Fetch, diff --git a/packages/rs-sdk/src/platform/documents/chained_document_query_sdk.rs b/packages/rs-sdk/src/platform/documents/chained_document_query_sdk.rs new file mode 100644 index 00000000000..eebf6fa70f4 --- /dev/null +++ b/packages/rs-sdk/src/platform/documents/chained_document_query_sdk.rs @@ -0,0 +1,44 @@ +//! Sdk-bound half of the chained document query surface: the rich → +//! wire encoding. The transport-free query type itself +//! ([`ChainedDocumentQuery`]) lives in `dash-platform-queries`. + +use dapi_grpc::platform::v0 as platform_proto; +use dapi_grpc::platform::v0::GetDocumentsRequest; +use dash_platform_queries::documents::chained_document_query::ChainedDocumentQuery; +use dpp::version::TryFromPlatformVersioned; + +use crate::Error; + +/// Encode a [`ChainedDocumentQuery`] onto the wire. +/// +/// The [`Fetch`](crate::platform::Fetch) trampoline for +/// [`drive_proof_verifier::ChainedDocuments`] splits `Query = +/// ChainedDocumentQuery` (rich, what `FromProof` binds to) from +/// `Request = GetChainedDocumentsRequest` (wire); this impl is the +/// rich→wire step. +impl crate::platform::Query for ChainedDocumentQuery { + fn query( + &self, + settings: &crate::platform::QuerySettings<'_>, + ) -> Result { + GetDocumentsRequest::try_from_platform_versioned(self.clone(), settings.protocol_version) + .map_err(Error::from) + } +} + +// `ChainedDocumentQuery` does not implement `TransportRequest` (the wire +// form is `GetChainedDocumentsRequest`), 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 ChainedDocumentQuery { + 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 5d0acd3a698..20c5f4f8060 100644 --- a/packages/rs-sdk/src/platform/documents/fetch_bindings.rs +++ b/packages/rs-sdk/src/platform/documents/fetch_bindings.rs @@ -51,3 +51,8 @@ impl Fetch for DocumentHavingEntries { type Query = DocumentQuery; type Request = GetDocumentsRequest; } + +impl Fetch for drive_proof_verifier::ChainedDocuments { + type Query = dash_platform_queries::documents::chained_document_query::ChainedDocumentQuery; + 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 67acd8df70a..ebfb4dd8ebf 100644 --- a/packages/rs-sdk/src/platform/documents/mod.rs +++ b/packages/rs-sdk/src/platform/documents/mod.rs @@ -6,11 +6,12 @@ //! bindings, the contract-fetching constructor, and transition builders. pub use dash_platform_queries::documents::{ - 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, 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 document_query_sdk; mod fetch_bindings; pub mod transitions;