From 747ae926c5025501b8aca1e1e225e57c0df0a972 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 31 Aug 2026 17:12:58 +0200 Subject: [PATCH 1/6] =?UTF-8?q?feat(sdk):=20chained=20document=20queries?= =?UTF-8?q?=20=E2=80=94=20ChainedDocuments=20fetch=20with=20composed=20pro?= =?UTF-8?q?of=20verification?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Third PR of the chained-document-queries stack. The Rust SDK surface for the provable semi-join: - dash-platform-queries: ChainedDocumentQuery (rich, transport-free) — inner DocumentQuery + join edge, wire encoding onto GetChainedDocumentsRequest (inner clauses in the GetDocumentsRequestV0 CBOR encoding, required non-zero inner limit, always proves), conversion to DriveChainedDocumentQuery, and the concrete FromProof impl for ChainedDocuments. - rs-drive-proof-verifier: ChainedDocuments result type and the tenderdash-composition wrapper verify_chained_documents_proof — merk-level composition (verifier-derived outer query, root equality, exact set equality) in rs-drive, quorum-signature binding of the shared root here, one function so the composition can never be skipped; plus a generic FromProof for DriveChainedDocumentQuery- convertible requests. No unproven decoder with verification semantics on purpose: an unproven chained response can fabricate the join, which is exactly what the surface exists to prevent. - rs-sdk: Query wire-encoding impl, Fetch binding (ChainedDocuments::fetch), MockResponse (per-document CBOR halves, list order preserved — order IS the answer), mock expectation loading, platform re-exports. - Removes the @sdk-ignore on getChainedDocuments (grpc-coverage cache updated by the gate script). Tested offline against the yappr-likes fixture: wire-shape encoding (byte-exact CBOR clauses), required-limit and unsupported-inner-feature rejections, rich→drive conversion + shared shape validation (valid byLiker shape passes, non-refersTo join property fails). Proof verification is exercised end-to-end in rs-drive's chained_query_e2e_tests and rs-drive-abci's handler tests; SDK test vectors for a devnet round trip can be generated with scripts/generate_test_vectors.sh once a local network is up. Co-Authored-By: Claude Fable 5 --- .github/grpc-queries-cache.json | 3 + .../src/documents/chained_document_query.rs | 350 ++++++++++++++++++ .../src/documents/mod.rs | 1 + packages/rs-drive-proof-verifier/src/lib.rs | 3 + packages/rs-drive-proof-verifier/src/proof.rs | 4 + .../src/proof/chained_document.rs | 137 +++++++ packages/rs-sdk/src/mock/requests.rs | 41 ++ packages/rs-sdk/src/mock/sdk.rs | 3 + packages/rs-sdk/src/platform.rs | 2 + .../documents/chained_document_query_sdk.rs | 30 ++ .../src/platform/documents/fetch_bindings.rs | 5 + packages/rs-sdk/src/platform/documents/mod.rs | 7 +- 12 files changed, 583 insertions(+), 3 deletions(-) create mode 100644 packages/dash-platform-queries/src/documents/chained_document_query.rs create mode 100644 packages/rs-drive-proof-verifier/src/proof/chained_document.rs create mode 100644 packages/rs-sdk/src/platform/documents/chained_document_query_sdk.rs 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..8c5f8ec3361 --- /dev/null +++ b/packages/dash-platform-queries/src/documents/chained_document_query.rs @@ -0,0 +1,350 @@ +//! 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_chained_documents_request::GetChainedDocumentsRequestV0; +use dapi_grpc::platform::v0::get_chained_documents_response::Version as ResponseVersion; +use dapi_grpc::platform::v0::{ + GetChainedDocumentsRequest, GetChainedDocumentsResponse, 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::platform_value::Value; +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 GetChainedDocumentsRequest { + 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 wire carries the inner clauses in the same CBOR + // encoding as `GetDocumentsRequestV0.where` / `.order_by`. + let where_bytes = if inner.where_clauses.is_empty() { + Vec::new() + } else { + let where_value = + Value::Array(inner.where_clauses.into_iter().map(Value::from).collect()); + where_value.to_cbor_buffer().map_err(|e| { + Error::Protocol(ProtocolError::EncodingError(format!( + "failed to CBOR-encode chained inner where clauses: {e}" + ))) + })? + }; + let order_by_bytes = if inner.order_by_clauses.is_empty() { + Vec::new() + } else { + let order_value = Value::Array( + inner + .order_by_clauses + .into_iter() + .map(Value::from) + .collect(), + ); + order_value.to_cbor_buffer().map_err(|e| { + Error::Protocol(ProtocolError::EncodingError(format!( + "failed to CBOR-encode chained inner order_by clauses: {e}" + ))) + })? + }; + + Ok(GetChainedDocumentsRequest { + version: Some( + dapi_grpc::platform::v0::get_chained_documents_request::Version::V0( + GetChainedDocumentsRequestV0 { + data_contract_id: inner.data_contract.id().to_vec(), + inner_document_type: inner.document_type_name, + inner_where: where_bytes, + inner_order_by: order_by_bytes, + inner_limit: inner.limit, + join_property, + outer_document_type: outer_document_type_name, + // Chained fetch always proves — the whole point + // of the surface is the verifiable composition. + prove: true, + }, + ), + ), + }) + } +} + +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 = GetChainedDocumentsResponse; + + 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 INNER proof (and the + // signature fields); the outer grovedb proof rides beside the + // result oneof. + let proof = response + .proof() + .or(Err(drive_proof_verifier::Error::NoProofInResult))?; + let mtd = response + .metadata() + .or(Err(drive_proof_verifier::Error::EmptyResponseMetadata))?; + let outer_grovedb_proof = match &response.version { + Some(ResponseVersion::V0(v0)) => v0.outer_grovedb_proof.as_slice(), + None => return Err(drive_proof_verifier::Error::EmptyVersion), + }; + + let (_root_hash, chained) = verify_chained_documents_tenderdash_proof( + &query, + proof, + outer_grovedb_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 request→wire + //! encoding, 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 + //! `chained_document_query` handler tests, where a populated Drive + //! exists. + + use super::*; + use dapi_grpc::platform::v0::get_chained_documents_request::Version as RequestVersion; + use dpp::data_contract::DataContract; + 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_v0_wire_shape() { + let request = GetChainedDocumentsRequest::try_from_platform_versioned( + posts_i_liked(10), + platform_version(), + ) + .expect("encodes"); + let Some(RequestVersion::V0(v0)) = request.version else { + panic!("expected a V0 request"); + }; + assert_eq!(v0.inner_document_type, "like"); + assert_eq!(v0.join_property, "postId"); + assert_eq!(v0.outer_document_type, "post"); + assert_eq!(v0.inner_limit, 10); + assert!(v0.prove, "chained fetch always proves"); + assert!(v0.inner_order_by.is_empty()); + // The where bytes are the same CBOR the server decodes for + // GetDocumentsRequestV0.where: an array of clause arrays — + // byte-identical to encoding the clause list directly. + let expected = Value::Array(vec![Value::from(WhereClause { + field: "$ownerId".to_string(), + operator: WhereOperator::Equal, + value: Value::Identifier(OWNER), + })]) + .to_cbor_buffer() + .expect("encode expected clauses"); + assert_eq!(v0.inner_where, expected); + } + + #[test] + fn requires_an_inner_limit() { + let refused = GetChainedDocumentsRequest::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 = + GetChainedDocumentsRequest::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..850807a722e --- /dev/null +++ b/packages/rs-drive-proof-verifier/src/proof/chained_document.rs @@ -0,0 +1,137 @@ +//! Verified **chained document** (provable semi-join) results. +//! +//! A chained query is `SELECT * FROM WHERE $id IN (SELECT +//! FROM WHERE …)` answered as TWO grovedb +//! proofs bound to ONE state root: the inner indexOnly page, and the +//! outer by-ids fetch DERIVED from the proven inner values. The +//! verifier ([`DriveChainedDocumentQuery::verify_chained_documents_proof`]) +//! re-derives the outer query itself, requires equal root hashes and +//! exact id↔document set equality — 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 shared 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::get_chained_documents_response::Version as ResponseVersion; +use dapi_grpc::platform::v0::{GetChainedDocumentsResponse, 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 proof pair and bind the shared root hash to the +/// quorum signature. +/// +/// The merk-level composition (outer-query re-derivation, root +/// equality, exact set equality) lives in rs-drive's +/// [`DriveChainedDocumentQuery::verify_chained_documents_proof`]; this +/// wrapper adds the [`verify_tenderdash_proof`] binding — the root +/// hash both proofs commit 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. +/// +/// `outer_grovedb_proof` is the response's rider field: empty means +/// "no outer proof" (required for an empty inner page, refused +/// otherwise — the verifier enforces presence-iff-nonempty). +pub fn verify_chained_documents_proof( + query: &DriveChainedDocumentQuery, + proof: &Proof, + outer_grovedb_proof: &[u8], + mtd: &ResponseMetadata, + platform_version: &PlatformVersion, + provider: &dyn ContextProvider, +) -> Result<(RootHash, ChainedDocuments), Error> { + let outer_proof = (!outer_grovedb_proof.is_empty()).then_some(outer_grovedb_proof); + let (root_hash, result) = query + .verify_chained_documents_proof(&proof.grovedb_proof, outer_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 = GetChainedDocumentsResponse; + + 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 INNER proof (and the + // signature fields); the outer grovedb proof rides beside the + // result oneof. + let proof = response.proof().or(Err(Error::NoProofInResult))?; + let mtd = response.metadata().or(Err(Error::EmptyResponseMetadata))?; + let outer_grovedb_proof = match &response.version { + Some(ResponseVersion::V0(v0)) => v0.outer_grovedb_proof.as_slice(), + None => return Err(Error::EmptyVersion), + }; + + let (_root_hash, chained) = verify_chained_documents_proof( + &query, + proof, + outer_grovedb_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..b286938b949 100644 --- a/packages/rs-sdk/src/mock/requests.rs +++ b/packages/rs-sdk/src/mock/requests.rs @@ -818,3 +818,44 @@ impl MockResponse for drive_proof_verifier::DocumentHavingEntries { } } } + +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: (Vec>, Vec>) = ( + 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), _): ((Vec>, Vec>), _) = + 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/mock/sdk.rs b/packages/rs-sdk/src/mock/sdk.rs index 397a30c417b..e029041471e 100644 --- a/packages/rs-sdk/src/mock/sdk.rs +++ b/packages/rs-sdk/src/mock/sdk.rs @@ -147,6 +147,9 @@ impl MockDashPlatformSdk { "GetDocumentsRequest" => { load_expectation::(&mut dapi, filename)? } + "GetChainedDocumentsRequest" => { + load_expectation::(&mut dapi, filename)? + } "GetEpochsInfoRequest" => { load_expectation::(&mut dapi, filename)? } 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..58968fdb89e --- /dev/null +++ b/packages/rs-sdk/src/platform/documents/chained_document_query_sdk.rs @@ -0,0 +1,30 @@ +//! 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::GetChainedDocumentsRequest; +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 { + GetChainedDocumentsRequest::try_from_platform_versioned( + self.clone(), + settings.protocol_version, + ) + .map_err(Error::from) + } +} diff --git a/packages/rs-sdk/src/platform/documents/fetch_bindings.rs b/packages/rs-sdk/src/platform/documents/fetch_bindings.rs index 5d0acd3a698..735d11beb26 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::GetChainedDocumentsRequest; +} 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; From 59463952d653b6077ae0e64bc4aa45dbe4c10689 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 31 Aug 2026 17:14:32 +0200 Subject: [PATCH 2/6] fix(sdk): identity Query impl for ChainedDocumentQuery Same rationale as DocumentQuery's explicit identity impl: the rich query is not a TransportRequest, so the blanket does not apply, and the fetch trampoline needs Query for the user-supplied form. Co-Authored-By: Claude Fable 5 --- .../documents/chained_document_query_sdk.rs | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) 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 index 58968fdb89e..349efeddfa3 100644 --- a/packages/rs-sdk/src/platform/documents/chained_document_query_sdk.rs +++ b/packages/rs-sdk/src/platform/documents/chained_document_query_sdk.rs @@ -28,3 +28,20 @@ impl crate::platform::Query for Chai .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()) + } +} From 5fd11ef629162080ea5f7bd392caf4941fa77ccc Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Mon, 31 Aug 2026 22:36:19 +0200 Subject: [PATCH 3/6] feat(sdk)!: verify the single merged chained proof Rework for the merged-proof wire: the tenderdash-composition wrapper and both FromProof impls take the response's proven_join_values as the untrusted bootstrap hint (decoded as 32-byte identifiers, fail-closed) and run rs-drive's single-pass merged verification. The two-proof plumbing is gone. Co-Authored-By: Claude Fable 5 --- .../src/documents/chained_document_query.rs | 22 ++++-- .../src/proof/chained_document.rs | 76 ++++++++++--------- 2 files changed, 57 insertions(+), 41 deletions(-) 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 8c5f8ec3361..625be1ff106 100644 --- a/packages/dash-platform-queries/src/documents/chained_document_query.rs +++ b/packages/dash-platform-queries/src/documents/chained_document_query.rs @@ -191,24 +191,34 @@ impl FromProof for ChainedDocuments { } })?; - // The standard envelope carries the INNER proof (and the - // signature fields); the outer grovedb proof rides beside the - // result oneof. + // The standard envelope carries the single MERGED proof; the + // untrusted join-value hint rides beside the result oneof. let proof = response .proof() .or(Err(drive_proof_verifier::Error::NoProofInResult))?; let mtd = response .metadata() .or(Err(drive_proof_verifier::Error::EmptyResponseMetadata))?; - let outer_grovedb_proof = match &response.version { - Some(ResponseVersion::V0(v0)) => v0.outer_grovedb_proof.as_slice(), + let hint: Vec = match &response.version { + Some(ResponseVersion::V0(v0)) => v0 + .proven_join_values + .iter() + .map(|bytes| { + dpp::prelude::Identifier::from_bytes(bytes).map_err(|_| { + drive_proof_verifier::Error::ResponseDecodeError { + error: "proven_join_values entries must be 32-byte identifiers" + .to_string(), + } + }) + }) + .collect::>()?, None => return Err(drive_proof_verifier::Error::EmptyVersion), }; let (_root_hash, chained) = verify_chained_documents_tenderdash_proof( &query, proof, - outer_grovedb_proof, + &hint, mtd, platform_version, provider, diff --git a/packages/rs-drive-proof-verifier/src/proof/chained_document.rs b/packages/rs-drive-proof-verifier/src/proof/chained_document.rs index 850807a722e..b681be75f0a 100644 --- a/packages/rs-drive-proof-verifier/src/proof/chained_document.rs +++ b/packages/rs-drive-proof-verifier/src/proof/chained_document.rs @@ -1,15 +1,18 @@ //! Verified **chained document** (provable semi-join) results. //! //! A chained query is `SELECT * FROM WHERE $id IN (SELECT -//! FROM WHERE …)` answered as TWO grovedb -//! proofs bound to ONE state root: the inner indexOnly page, and the -//! outer by-ids fetch DERIVED from the proven inner values. The +//! 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`]) -//! re-derives the outer query itself, requires equal root hashes and -//! exact id↔document set equality — 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 shared root. +//! 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 @@ -43,31 +46,30 @@ pub struct ChainedDocuments { pub outer_documents: Vec, } -/// Verify a chained proof pair and bind the shared root hash to the -/// quorum signature. +/// Verify a chained query's single merged proof and bind its root hash +/// to the quorum signature. /// -/// The merk-level composition (outer-query re-derivation, root -/// equality, exact set equality) lives in rs-drive's +/// The merk-level composition (merged-query reconstruction from the +/// untrusted hint, single-pass 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 both proofs commit 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. +/// 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. /// -/// `outer_grovedb_proof` is the response's rider field: empty means -/// "no outer proof" (required for an empty inner page, refused -/// otherwise — the verifier enforces presence-iff-nonempty). +/// `join_values_hint` is the response's `proven_join_values` rider — +/// untrusted bootstrap data; a hint that lies fails verification. pub fn verify_chained_documents_proof( query: &DriveChainedDocumentQuery, proof: &Proof, - outer_grovedb_proof: &[u8], + join_values_hint: &[dpp::prelude::Identifier], mtd: &ResponseMetadata, platform_version: &PlatformVersion, provider: &dyn ContextProvider, ) -> Result<(RootHash, ChainedDocuments), Error> { - let outer_proof = (!outer_grovedb_proof.is_empty()).then_some(outer_grovedb_proof); let (root_hash, result) = query - .verify_chained_documents_proof(&proof.grovedb_proof, outer_proof, platform_version) + .verify_chained_documents_proof(&proof.grovedb_proof, join_values_hint, platform_version) .map_drive_error(proof, mtd)?; verify_tenderdash_proof(proof, mtd, &root_hash, provider)?; @@ -110,24 +112,28 @@ where error: e.to_string(), })?; - // The standard envelope carries the INNER proof (and the - // signature fields); the outer grovedb proof rides beside the - // result oneof. + // The standard envelope carries the single MERGED proof; the + // untrusted join-value hint rides beside the result oneof. let proof = response.proof().or(Err(Error::NoProofInResult))?; let mtd = response.metadata().or(Err(Error::EmptyResponseMetadata))?; - let outer_grovedb_proof = match &response.version { - Some(ResponseVersion::V0(v0)) => v0.outer_grovedb_proof.as_slice(), + let hint: Vec = match &response.version { + Some(ResponseVersion::V0(v0)) => v0 + .proven_join_values + .iter() + .map(|bytes| { + dpp::prelude::Identifier::from_bytes(bytes).map_err(|_| { + Error::ResponseDecodeError { + error: "proven_join_values entries must be 32-byte identifiers" + .to_string(), + } + }) + }) + .collect::>()?, None => return Err(Error::EmptyVersion), }; - let (_root_hash, chained) = verify_chained_documents_proof( - &query, - proof, - outer_grovedb_proof, - mtd, - platform_version, - provider, - )?; + let (_root_hash, chained) = + verify_chained_documents_proof(&query, proof, &hint, 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 From 4b7b389e5d04af210e922b01380e224f4f7daaa4 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Tue, 1 Sep 2026 00:20:12 +0200 Subject: [PATCH 4/6] fix(sdk): factor the chained mock wire tuple into a type alias Appeases clippy::type_complexity on the MockResponse round-trip shape. Co-Authored-By: Claude Fable 5 --- packages/rs-sdk/src/mock/requests.rs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/rs-sdk/src/mock/requests.rs b/packages/rs-sdk/src/mock/requests.rs index b286938b949..efb7fee74a7 100644 --- a/packages/rs-sdk/src/mock/requests.rs +++ b/packages/rs-sdk/src/mock/requests.rs @@ -819,6 +819,10 @@ 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, @@ -826,7 +830,7 @@ impl MockResponse for drive_proof_verifier::ChainedDocuments { /// destroy it. fn mock_serialize(&self, _sdk: &MockDashPlatformSdk) -> Vec { let bincode_config = standard(); - let halves: (Vec>, Vec>) = ( + let halves: MockChainedHalves = ( self.inner_documents .iter() .map(|d| d.to_cbor().expect("encode inner document")) @@ -844,7 +848,7 @@ impl MockResponse for drive_proof_verifier::ChainedDocuments { Self: Sized, { let bincode_config = standard(); - let ((inner, outer), _): ((Vec>, Vec>), _) = + let ((inner, outer), _): (MockChainedHalves, _) = bincode::decode_from_slice(buf, bincode_config).expect("decode ChainedDocuments"); let decode = |bufs: Vec>| { bufs.into_iter() From 441d762e0baed1c4cac6d5322a27b801c2d956e1 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Tue, 1 Sep 2026 01:21:16 +0200 Subject: [PATCH 5/6] feat(sdk)!: encode chained queries onto the getDocuments V1 wire MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follows the wire fold: ChainedDocumentQuery now encodes through the standard versioned GetDocumentsRequest encoder (typed V1 clauses — no CBOR anywhere on the surface) and attaches the ChainedJoin spec; a network still on the V0 wire is refused rather than silently sent a plain documents query. Both FromProof impls decode GetDocumentsResponse V1 (the merged proof in the standard envelope + the proven_join_values rider), rejecting V0 responses fail-closed. The Fetch binding's Request becomes GetDocumentsRequest and the dedicated mock expectation arm is gone. Offline tests updated: the wire-shape assertion now pins the typed V1 clauses and the riding join spec. Co-Authored-By: Claude Fable 5 --- .../src/documents/chained_document_query.rs | 162 +++++++----------- .../src/proof/chained_document.rs | 15 +- packages/rs-sdk/src/mock/sdk.rs | 3 - .../documents/chained_document_query_sdk.rs | 13 +- .../src/platform/documents/fetch_bindings.rs | 2 +- 5 files changed, 82 insertions(+), 113 deletions(-) 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 625be1ff106..1972a2820fb 100644 --- a/packages/dash-platform-queries/src/documents/chained_document_query.rs +++ b/packages/dash-platform-queries/src/documents/chained_document_query.rs @@ -11,16 +11,14 @@ use crate::documents::document_query::DocumentQuery; use crate::error::Error; -use dapi_grpc::platform::v0::get_chained_documents_request::GetChainedDocumentsRequestV0; -use dapi_grpc::platform::v0::get_chained_documents_response::Version as ResponseVersion; -use dapi_grpc::platform::v0::{ - GetChainedDocumentsRequest, GetChainedDocumentsResponse, Proof, ResponseMetadata, -}; +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::get_documents_response::Version as ResponseVersion; +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::platform_value::Value; use dpp::version::{PlatformVersion, TryFromPlatformVersioned}; use dpp::ProtocolError; use drive::query::drive_chained_document_query::DriveChainedDocumentQuery; @@ -65,12 +63,12 @@ impl ChainedDocumentQuery { } } -impl TryFromPlatformVersioned for GetChainedDocumentsRequest { +impl TryFromPlatformVersioned for GetDocumentsRequest { type Error = Error; fn try_from_platform_versioned( value: ChainedDocumentQuery, - _platform_version: &PlatformVersion, + platform_version: &PlatformVersion, ) -> Result { let ChainedDocumentQuery { inner, @@ -99,54 +97,29 @@ impl TryFromPlatformVersioned for GetChainedDocumentsReque )); } - // The chained wire carries the inner clauses in the same CBOR - // encoding as `GetDocumentsRequestV0.where` / `.order_by`. - let where_bytes = if inner.where_clauses.is_empty() { - Vec::new() - } else { - let where_value = - Value::Array(inner.where_clauses.into_iter().map(Value::from).collect()); - where_value.to_cbor_buffer().map_err(|e| { - Error::Protocol(ProtocolError::EncodingError(format!( - "failed to CBOR-encode chained inner where clauses: {e}" - ))) - })? - }; - let order_by_bytes = if inner.order_by_clauses.is_empty() { - Vec::new() - } else { - let order_value = Value::Array( - inner - .order_by_clauses - .into_iter() - .map(Value::from) - .collect(), - ); - order_value.to_cbor_buffer().map_err(|e| { - Error::Protocol(ProtocolError::EncodingError(format!( - "failed to CBOR-encode chained inner order_by clauses: {e}" - ))) - })? - }; - - Ok(GetChainedDocumentsRequest { - version: Some( - dapi_grpc::platform::v0::get_chained_documents_request::Version::V0( - GetChainedDocumentsRequestV0 { - data_contract_id: inner.data_contract.id().to_vec(), - inner_document_type: inner.document_type_name, - inner_where: where_bytes, - inner_order_by: order_by_bytes, - inner_limit: inner.limit, - join_property, - outer_document_type: outer_document_type_name, - // Chained fetch always proves — the whole point - // of the surface is the verifiable composition. - prove: true, - }, - ), - ), - }) + // 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) } } @@ -170,7 +143,7 @@ impl<'a> TryFrom<&'a ChainedDocumentQuery> for DriveChainedDocumentQuery<'a> { impl FromProof for ChainedDocuments { type Request = ChainedDocumentQuery; - type Response = GetChainedDocumentsResponse; + type Response = GetDocumentsResponse; fn maybe_from_proof_with_metadata<'a, I: Into, O: Into>( request: I, @@ -200,7 +173,7 @@ impl FromProof for ChainedDocuments { .metadata() .or(Err(drive_proof_verifier::Error::EmptyResponseMetadata))?; let hint: Vec = match &response.version { - Some(ResponseVersion::V0(v0)) => v0 + Some(ResponseVersion::V1(v1)) => v1 .proven_join_values .iter() .map(|bytes| { @@ -212,6 +185,13 @@ impl FromProof for ChainedDocuments { }) }) .collect::>()?, + Some(ResponseVersion::V0(_)) => { + return Err(drive_proof_verifier::Error::ResponseDecodeError { + error: "chained results are a V1-only response shape; got a V0 \ + getDocuments response" + .to_string(), + }) + } None => return Err(drive_proof_verifier::Error::EmptyVersion), }; @@ -233,17 +213,17 @@ impl FromProof for ChainedDocuments { #[cfg(test)] mod tests { - //! Offline tests for the chained client surface: the request→wire - //! encoding, 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 - //! `chained_document_query` handler tests, where a populated Drive - //! exists. + //! 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 dapi_grpc::platform::v0::get_chained_documents_request::Version as RequestVersion; 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; @@ -276,40 +256,29 @@ mod tests { } #[test] - fn encodes_the_v0_wire_shape() { - let request = GetChainedDocumentsRequest::try_from_platform_versioned( - posts_i_liked(10), - platform_version(), - ) - .expect("encodes"); - let Some(RequestVersion::V0(v0)) = request.version else { - panic!("expected a V0 request"); + 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!(v0.inner_document_type, "like"); - assert_eq!(v0.join_property, "postId"); - assert_eq!(v0.outer_document_type, "post"); - assert_eq!(v0.inner_limit, 10); - assert!(v0.prove, "chained fetch always proves"); - assert!(v0.inner_order_by.is_empty()); - // The where bytes are the same CBOR the server decodes for - // GetDocumentsRequestV0.where: an array of clause arrays — - // byte-identical to encoding the clause list directly. - let expected = Value::Array(vec![Value::from(WhereClause { - field: "$ownerId".to_string(), - operator: WhereOperator::Equal, - value: Value::Identifier(OWNER), - })]) - .to_cbor_buffer() - .expect("encode expected clauses"); - assert_eq!(v0.inner_where, expected); + 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 = GetChainedDocumentsRequest::try_from_platform_versioned( - posts_i_liked(0), - platform_version(), - ); + 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:?}" @@ -320,8 +289,7 @@ mod tests { fn refuses_unsupported_inner_features() { let mut query = posts_i_liked(10); query.inner.group_by = vec!["hashtag".to_string()]; - let refused = - GetChainedDocumentsRequest::try_from_platform_versioned(query, platform_version()); + 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:?}" diff --git a/packages/rs-drive-proof-verifier/src/proof/chained_document.rs b/packages/rs-drive-proof-verifier/src/proof/chained_document.rs index b681be75f0a..c5a8d42057b 100644 --- a/packages/rs-drive-proof-verifier/src/proof/chained_document.rs +++ b/packages/rs-drive-proof-verifier/src/proof/chained_document.rs @@ -24,8 +24,8 @@ use crate::error::MapGroveDbError; use crate::verify::verify_tenderdash_proof; use crate::{ContextProvider, Error, FromProof}; -use dapi_grpc::platform::v0::get_chained_documents_response::Version as ResponseVersion; -use dapi_grpc::platform::v0::{GetChainedDocumentsResponse, Proof, ResponseMetadata}; +use dapi_grpc::platform::v0::get_documents_response::Version as ResponseVersion; +use dapi_grpc::platform::v0::{GetDocumentsResponse, Proof, ResponseMetadata}; use dapi_grpc::platform::VersionedGrpcResponse; use dpp::dashcore::Network; use dpp::document::Document; @@ -89,7 +89,7 @@ where Q::Error: std::fmt::Display, { type Request = Q; - type Response = GetChainedDocumentsResponse; + type Response = GetDocumentsResponse; fn maybe_from_proof_with_metadata<'a, I: Into, O: Into>( request: I, @@ -117,7 +117,7 @@ where let proof = response.proof().or(Err(Error::NoProofInResult))?; let mtd = response.metadata().or(Err(Error::EmptyResponseMetadata))?; let hint: Vec = match &response.version { - Some(ResponseVersion::V0(v0)) => v0 + Some(ResponseVersion::V1(v1)) => v1 .proven_join_values .iter() .map(|bytes| { @@ -129,6 +129,13 @@ where }) }) .collect::>()?, + Some(ResponseVersion::V0(_)) => { + return Err(Error::ResponseDecodeError { + error: "chained results are a V1-only response shape; got a V0 \ + getDocuments response" + .to_string(), + }) + } None => return Err(Error::EmptyVersion), }; diff --git a/packages/rs-sdk/src/mock/sdk.rs b/packages/rs-sdk/src/mock/sdk.rs index e029041471e..397a30c417b 100644 --- a/packages/rs-sdk/src/mock/sdk.rs +++ b/packages/rs-sdk/src/mock/sdk.rs @@ -147,9 +147,6 @@ impl MockDashPlatformSdk { "GetDocumentsRequest" => { load_expectation::(&mut dapi, filename)? } - "GetChainedDocumentsRequest" => { - load_expectation::(&mut dapi, filename)? - } "GetEpochsInfoRequest" => { load_expectation::(&mut dapi, filename)? } 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 index 349efeddfa3..eebf6fa70f4 100644 --- a/packages/rs-sdk/src/platform/documents/chained_document_query_sdk.rs +++ b/packages/rs-sdk/src/platform/documents/chained_document_query_sdk.rs @@ -3,7 +3,7 @@ //! ([`ChainedDocumentQuery`]) lives in `dash-platform-queries`. use dapi_grpc::platform::v0 as platform_proto; -use dapi_grpc::platform::v0::GetChainedDocumentsRequest; +use dapi_grpc::platform::v0::GetDocumentsRequest; use dash_platform_queries::documents::chained_document_query::ChainedDocumentQuery; use dpp::version::TryFromPlatformVersioned; @@ -16,16 +16,13 @@ use crate::Error; /// ChainedDocumentQuery` (rich, what `FromProof` binds to) from /// `Request = GetChainedDocumentsRequest` (wire); this impl is the /// rich→wire step. -impl crate::platform::Query for ChainedDocumentQuery { +impl crate::platform::Query for ChainedDocumentQuery { fn query( &self, settings: &crate::platform::QuerySettings<'_>, - ) -> Result { - GetChainedDocumentsRequest::try_from_platform_versioned( - self.clone(), - settings.protocol_version, - ) - .map_err(Error::from) + ) -> Result { + GetDocumentsRequest::try_from_platform_versioned(self.clone(), settings.protocol_version) + .map_err(Error::from) } } diff --git a/packages/rs-sdk/src/platform/documents/fetch_bindings.rs b/packages/rs-sdk/src/platform/documents/fetch_bindings.rs index 735d11beb26..20c5f4f8060 100644 --- a/packages/rs-sdk/src/platform/documents/fetch_bindings.rs +++ b/packages/rs-sdk/src/platform/documents/fetch_bindings.rs @@ -54,5 +54,5 @@ impl Fetch for DocumentHavingEntries { impl Fetch for drive_proof_verifier::ChainedDocuments { type Query = dash_platform_queries::documents::chained_document_query::ChainedDocumentQuery; - type Request = dapi_grpc::platform::v0::GetChainedDocumentsRequest; + type Request = dapi_grpc::platform::v0::GetDocumentsRequest; } From 9400531c0638fbd266aac831a59dbc395f8bb3eb Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Tue, 1 Sep 2026 01:45:34 +0200 Subject: [PATCH 6/6] feat(sdk)!: verify chained proofs without the rider hint The proof is self-sufficient now: the FromProof impls hand the merged proof straight to rs-drive's bootstrap-then-verify composition, and the proven_join_values decode is gone with the field. Co-Authored-By: Claude Fable 5 --- .../src/documents/chained_document_query.rs | 29 ++----------- .../src/proof/chained_document.rs | 41 ++++--------------- 2 files changed, 11 insertions(+), 59 deletions(-) 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 1972a2820fb..ce46a4f5551 100644 --- a/packages/dash-platform-queries/src/documents/chained_document_query.rs +++ b/packages/dash-platform-queries/src/documents/chained_document_query.rs @@ -13,7 +13,6 @@ 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::get_documents_response::Version as ResponseVersion; use dapi_grpc::platform::v0::{GetDocumentsRequest, GetDocumentsResponse, Proof, ResponseMetadata}; use dapi_grpc::platform::VersionedGrpcResponse; use dash_context_provider::ContextProvider; @@ -164,41 +163,19 @@ impl FromProof for ChainedDocuments { } })?; - // The standard envelope carries the single MERGED proof; the - // untrusted join-value hint rides beside the result oneof. + // 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 hint: Vec = match &response.version { - Some(ResponseVersion::V1(v1)) => v1 - .proven_join_values - .iter() - .map(|bytes| { - dpp::prelude::Identifier::from_bytes(bytes).map_err(|_| { - drive_proof_verifier::Error::ResponseDecodeError { - error: "proven_join_values entries must be 32-byte identifiers" - .to_string(), - } - }) - }) - .collect::>()?, - Some(ResponseVersion::V0(_)) => { - return Err(drive_proof_verifier::Error::ResponseDecodeError { - error: "chained results are a V1-only response shape; got a V0 \ - getDocuments response" - .to_string(), - }) - } - None => return Err(drive_proof_verifier::Error::EmptyVersion), - }; let (_root_hash, chained) = verify_chained_documents_tenderdash_proof( &query, proof, - &hint, mtd, platform_version, provider, diff --git a/packages/rs-drive-proof-verifier/src/proof/chained_document.rs b/packages/rs-drive-proof-verifier/src/proof/chained_document.rs index c5a8d42057b..afe9feba12e 100644 --- a/packages/rs-drive-proof-verifier/src/proof/chained_document.rs +++ b/packages/rs-drive-proof-verifier/src/proof/chained_document.rs @@ -24,7 +24,6 @@ use crate::error::MapGroveDbError; use crate::verify::verify_tenderdash_proof; use crate::{ContextProvider, Error, FromProof}; -use dapi_grpc::platform::v0::get_documents_response::Version as ResponseVersion; use dapi_grpc::platform::v0::{GetDocumentsResponse, Proof, ResponseMetadata}; use dapi_grpc::platform::VersionedGrpcResponse; use dpp::dashcore::Network; @@ -49,27 +48,24 @@ pub struct ChainedDocuments { /// Verify a chained query's single merged proof and bind its root hash /// to the quorum signature. /// -/// The merk-level composition (merged-query reconstruction from the -/// untrusted hint, single-pass verification, exact set equality against -/// the PROVEN join values) lives in rs-drive's +/// 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. /// -/// `join_values_hint` is the response's `proven_join_values` rider — -/// untrusted bootstrap data; a hint that lies fails verification. pub fn verify_chained_documents_proof( query: &DriveChainedDocumentQuery, proof: &Proof, - join_values_hint: &[dpp::prelude::Identifier], mtd: &ResponseMetadata, platform_version: &PlatformVersion, provider: &dyn ContextProvider, ) -> Result<(RootHash, ChainedDocuments), Error> { let (root_hash, result) = query - .verify_chained_documents_proof(&proof.grovedb_proof, join_values_hint, platform_version) + .verify_chained_documents_proof(&proof.grovedb_proof, platform_version) .map_drive_error(proof, mtd)?; verify_tenderdash_proof(proof, mtd, &root_hash, provider)?; @@ -112,35 +108,14 @@ where error: e.to_string(), })?; - // The standard envelope carries the single MERGED proof; the - // untrusted join-value hint rides beside the result oneof. + // 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 hint: Vec = match &response.version { - Some(ResponseVersion::V1(v1)) => v1 - .proven_join_values - .iter() - .map(|bytes| { - dpp::prelude::Identifier::from_bytes(bytes).map_err(|_| { - Error::ResponseDecodeError { - error: "proven_join_values entries must be 32-byte identifiers" - .to_string(), - } - }) - }) - .collect::>()?, - Some(ResponseVersion::V0(_)) => { - return Err(Error::ResponseDecodeError { - error: "chained results are a V1-only response shape; got a V0 \ - getDocuments response" - .to_string(), - }) - } - None => return Err(Error::EmptyVersion), - }; let (_root_hash, chained) = - verify_chained_documents_proof(&query, proof, &hint, mtd, platform_version, provider)?; + 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