diff --git a/packages/rs-drive/src/drive/contract/insert/insert_contract/v0/tests/chained_query_e2e_tests.rs b/packages/rs-drive/src/drive/contract/insert/insert_contract/v0/tests/chained_query_e2e_tests.rs new file mode 100644 index 00000000000..b1450c07fdb --- /dev/null +++ b/packages/rs-drive/src/drive/contract/insert/insert_contract/v0/tests/chained_query_e2e_tests.rs @@ -0,0 +1,454 @@ +//! End-to-end coverage for **chained document queries** (provable +//! semi-join): `SELECT * FROM post WHERE $id IN (SELECT postId FROM like +//! WHERE $ownerId = )` against the `yappr-likes` fixture — the inner +//! byLiker terminal route's proven postIds become the outer post query's +//! primary keys, and both proofs must verify to ONE root hash. +//! +//! Pinned here: no-proof/proof parity (the verifier's composed result +//! equals the server's materialized result), the empty-inner shape (no +//! outer proof), pagination through the inner terminal cursor, the +//! validation rejections, and the verifier's root-equality check (two +//! proofs straddling a state change are refused). + +use super::index_only_e2e_tests::{build_like, insert_like, platform_version, setup_likes}; +use crate::error::Error; +use crate::query::drive_chained_document_query::DriveChainedDocumentQuery; +use crate::query::{DriveDocumentQuery, OrderClause, WhereClause, WhereOperator}; +use crate::util::object_size_info::DocumentInfo::DocumentRefInfo; +use crate::util::object_size_info::{DocumentAndContractInfo, OwnedDocumentInfo}; +use dpp::block::block_info::BlockInfo; +use dpp::data_contract::accessors::v0::DataContractV0Getters; +use dpp::data_contract::document_type::random_document::CreateRandomDocument; +use dpp::document::{DocumentV0Getters, DocumentV0Setters}; +use dpp::platform_value::{Identifier, Value}; +use dpp::prelude::DataContract; + +const POST_A: [u8; 32] = [0xA1; 32]; +const POST_B: [u8; 32] = [0xB2; 32]; +const POST_C: [u8; 32] = [0xC3; 32]; +const OWNER_1: [u8; 32] = [0x11; 32]; +const OWNER_2: [u8; 32] = [0x22; 32]; +const OWNER_3: [u8; 32] = [0x33; 32]; + +/// Inserts a `post` document (regular, non-indexOnly type) with an +/// explicit id so likes can reference it. +fn insert_post( + drive: &crate::drive::Drive, + contract: &DataContract, + id: [u8; 32], + hashtag: &str, + message: &str, + seed: u64, +) { + let pv = platform_version(); + let document_type = contract + .document_type_for_name("post") + .expect("post doctype exists"); + let mut doc = document_type + .random_document(Some(seed), pv) + .expect("random post"); + let mut props = std::collections::BTreeMap::new(); + props.insert("hashtag".to_string(), Value::Text(hashtag.to_string())); + props.insert("message".to_string(), Value::Text(message.to_string())); + doc.set_properties(props); + doc.set_id(Identifier::from(id)); + doc.set_owner_id(Identifier::from(OWNER_1)); + drive + .add_document_for_contract( + DocumentAndContractInfo { + owned_document_info: OwnedDocumentInfo { + document_info: DocumentRefInfo((&doc, None)), + owner_id: None, + }, + contract, + document_type, + }, + false, + BlockInfo::default(), + true, + None, + pv, + None, + ) + .expect("insert post"); +} + +/// The inner byLiker query: `$ownerId == owner`, with an optional +/// pagination cursor `postId > after` (ordered by postId). +fn my_likes_query<'a>( + contract: &'a DataContract, + owner: [u8; 32], + after: Option<[u8; 32]>, + limit: Option, +) -> DriveDocumentQuery<'a> { + let document_type = contract + .document_type_for_name("like") + .expect("like doctype exists"); + let mut clauses = vec![WhereClause { + field: "$ownerId".to_string(), + operator: WhereOperator::Equal, + value: Value::Identifier(owner), + }]; + let mut order_by: indexmap::IndexMap = Default::default(); + if let Some(after) = after { + clauses.push(WhereClause { + field: "postId".to_string(), + operator: WhereOperator::GreaterThan, + value: Value::Identifier(after), + }); + order_by.insert( + "postId".to_string(), + OrderClause { + field: "postId".to_string(), + ascending: true, + }, + ); + } + DriveDocumentQuery { + contract, + document_type, + internal_clauses: crate::query::InternalClauses::extract_from_clauses( + clauses, + platform_version(), + ) + .expect("clauses extract"), + offset: None, + limit, + order_by, + start_at: None, + start_at_included: false, + block_time_ms: None, + resolved_time_ranges: vec![], + } +} + +fn chained_posts_i_liked<'a>( + contract: &'a DataContract, + owner: [u8; 32], + after: Option<[u8; 32]>, + limit: Option, +) -> DriveChainedDocumentQuery<'a> { + DriveChainedDocumentQuery { + inner: my_likes_query(contract, owner, after, limit), + join_property: "postId".to_string(), + outer_document_type: contract + .document_type_for_name("post") + .expect("post doctype exists"), + } +} + +/// The full round trip: the server's materialized result and the +/// verifier's composed result must agree half for half, and both proofs +/// must verify to one root hash. +#[test] +fn should_return_liked_posts_with_proof_parity() { + let (drive, contract) = setup_likes(); + let pv = platform_version(); + + insert_post(&drive, &contract, POST_A, "dash", "post a", 10); + insert_post(&drive, &contract, POST_B, "dash", "post b", 11); + insert_post(&drive, &contract, POST_C, "btc", "post c", 12); + for (post, hashtag, owner, seed) in [ + (POST_A, "dash", OWNER_1, 1u64), + (POST_B, "dash", OWNER_1, 2), + (POST_C, "btc", OWNER_2, 3), + ] { + let like = build_like(&contract, hashtag, post, owner, seed); + insert_like(&drive, &contract, &like, true).expect("insert like"); + } + + let chained = chained_posts_i_liked(&contract, OWNER_1, None, Some(10)); + + // No-proof execution. + let outcome = drive + .query_chained_documents(&chained, None, None, pv) + .expect("chained query executes"); + assert_eq!( + outcome.result.inner_documents.len(), + 2, + "OWNER_1 has 2 likes" + ); + let outer_ids: Vec<[u8; 32]> = outcome + .result + .outer_documents + .iter() + .map(|d| d.id().to_buffer()) + .collect(); + assert_eq!( + outer_ids, + vec![POST_A, POST_B], + "the liked posts come back in inner (postId) order" + ); + assert_eq!( + outcome.result.outer_documents[0] + .properties() + .get("message") + .expect("post body present") + .to_str() + .expect("text"), + "post a", + "outer documents are the full post bodies" + ); + + // Proof round trip: ONE merged proof, verified against the query + // re-derived from the server's join-value hint. The with-proof path + // materializes only the inner projections — the outer half rides + // the proof. + let (proof, proved_inner) = drive + .query_chained_documents_with_proof(&chained, pv) + .expect("chained proof generates"); + let hint = chained + .join_values(&proved_inner) + .expect("join values extract"); + let (_root_hash, verified) = chained + .verify_chained_documents_proof(proof.as_slice(), &hint, pv) + .expect("chained proof verifies"); + assert_eq!( + verified + .outer_documents + .iter() + .map(|d| d.id()) + .collect::>(), + outcome + .result + .outer_documents + .iter() + .map(|d| d.id()) + .collect::>(), + "verifier and server agree on the outer half" + ); + assert_eq!( + verified + .inner_documents + .iter() + .map(|d| d.id()) + .collect::>(), + proved_inner.iter().map(|d| d.id()).collect::>(), + "verifier and server agree on the inner half" + ); +} + +/// An empty inner page proves alone (the merged query degenerates to +/// the inner component), and a hint claiming otherwise is refused: the +/// derived outer branch demands documents the proof cannot cover. +#[test] +fn should_prove_an_empty_inner_page_alone() { + let (drive, contract) = setup_likes(); + let pv = platform_version(); + insert_post(&drive, &contract, POST_A, "dash", "post a", 10); + + let chained = chained_posts_i_liked(&contract, OWNER_3, None, Some(10)); + let (proof, proved_inner) = drive + .query_chained_documents_with_proof(&chained, pv) + .expect("chained proof generates"); + assert!(proved_inner.is_empty()); + + let (_root, verified) = chained + .verify_chained_documents_proof(proof.as_slice(), &[], pv) + .expect("empty chained proof verifies"); + assert!(verified.outer_documents.is_empty()); + + // A fabricated hint over an empty page: the merged query gains an + // outer branch this proof never covered. + let fake_hint = vec![dpp::identifier::Identifier::from(POST_A)]; + let refused = chained.verify_chained_documents_proof(proof.as_slice(), &fake_hint, pv); + assert!( + refused.is_err(), + "a non-empty hint over an empty proven page must be refused, got {refused:?}" + ); +} + +/// Pagination lives on the INNER query alone: each page re-derives its +/// own outer half from that page's proven join values. +#[test] +fn should_paginate_through_the_inner_cursor() { + let (drive, contract) = setup_likes(); + let pv = platform_version(); + insert_post(&drive, &contract, POST_A, "dash", "post a", 10); + insert_post(&drive, &contract, POST_B, "dash", "post b", 11); + for (post, seed) in [(POST_A, 1u64), (POST_B, 2)] { + let like = build_like(&contract, "dash", post, OWNER_1, seed); + insert_like(&drive, &contract, &like, true).expect("insert like"); + } + + let page_1 = chained_posts_i_liked(&contract, OWNER_1, None, Some(1)); + let outcome_1 = drive + .query_chained_documents(&page_1, None, None, pv) + .expect("page 1 executes"); + assert_eq!(outcome_1.result.outer_documents.len(), 1); + assert_eq!(outcome_1.result.outer_documents[0].id().to_buffer(), POST_A); + + // The cursor is the page's last join value, read off the inner + // projections — exactly what a client would do. + let cursor: [u8; 32] = outcome_1.result.inner_documents[0] + .properties() + .get("postId") + .expect("join value present") + .to_identifier() + .expect("identifier") + .to_buffer(); + + let page_2 = chained_posts_i_liked(&contract, OWNER_1, Some(cursor), Some(1)); + let (proof, proved_inner_2) = drive + .query_chained_documents_with_proof(&page_2, pv) + .expect("page 2 proof generates"); + let hint = page_2 + .join_values(&proved_inner_2) + .expect("join values extract"); + let (_root, verified) = page_2 + .verify_chained_documents_proof(proof.as_slice(), &hint, pv) + .expect("page 2 verifies"); + assert_eq!(verified.outer_documents.len(), 1); + assert_eq!(verified.outer_documents[0].id().to_buffer(), POST_B); +} + +/// The validation rejections: shapes that could not compose soundly are +/// refused identically on the server and the verifier (both call +/// `validate`). +#[test] +fn should_reject_invalid_chained_shapes() { + let (drive, contract) = setup_likes(); + let pv = platform_version(); + + // Missing inner limit — the bound on the outer fan-out. + let no_limit = chained_posts_i_liked(&contract, OWNER_1, None, None); + assert!( + matches!( + drive.query_chained_documents(&no_limit, None, None, pv), + Err(Error::Query(_)) + ), + "an inner limit is required" + ); + + // Join property without a refersTo declaration. + let mut bad_join = chained_posts_i_liked(&contract, OWNER_1, None, Some(10)); + bad_join.join_property = "hashtag".to_string(); + assert!( + matches!( + drive.query_chained_documents(&bad_join, None, None, pv), + Err(Error::Query(_)) + ), + "the join property must carry refersTo: permanentDocument" + ); + + // Outer type that is not the refersTo target (and is itself + // indexOnly, which is refused in its own right). + let mut bad_outer = chained_posts_i_liked(&contract, OWNER_1, None, Some(10)); + bad_outer.outer_document_type = contract + .document_type_for_name("tip") + .expect("tip doctype exists"); + assert!( + matches!( + drive.query_chained_documents(&bad_outer, None, None, pv), + Err(Error::Query(_)) + ), + "the outer type must be the refersTo target" + ); + + // Inner limit above the outer `$id IN` clause's 100-value cap. + let over_cap = chained_posts_i_liked(&contract, OWNER_1, None, Some(101)); + assert!( + matches!( + drive.query_chained_documents(&over_cap, None, None, pv), + Err(Error::Query(_)) + ), + "an inner limit above MAX_CHAINED_JOIN_VALUES must be refused" + ); + + // An oversized (necessarily lying) verifier-side hint is refused + // before the outer derivation runs. + let capped = chained_posts_i_liked(&contract, OWNER_1, None, Some(10)); + let oversized_hint: Vec = (0..101u8) + .map(|i| dpp::identifier::Identifier::from([i; 32])) + .collect(); + assert!( + matches!( + capped.verify_chained_documents_proof(&[], &oversized_hint, pv), + Err(Error::Query(_)) + ), + "an oversized join-value hint must be refused" + ); +} + +/// A like whose referenced post is missing is corrupted state at the +/// drive level (consensus validates references on write): the chained +/// execution refuses to return a partial join. +#[test] +fn should_refuse_a_dangling_reference() { + let (drive, contract) = setup_likes(); + let pv = platform_version(); + // A like referencing POST_A — which was never inserted. + let like = build_like(&contract, "dash", POST_A, OWNER_1, 1); + insert_like(&drive, &contract, &like, true).expect("insert like"); + + let chained = chained_posts_i_liked(&contract, OWNER_1, None, Some(10)); + let refused = drive.query_chained_documents(&chained, None, None, pv); + assert!( + matches!(refused, Err(Error::Proof(_))), + "a dangling reference must refuse the join, got {refused:?}" + ); +} + +/// The hint is untrusted: any lie — a dropped id, an extra id, a +/// substituted id — produces a merged query the proof cannot satisfy, +/// and verification fails rather than returning a steered join. +#[test] +fn should_reject_tampered_hints() { + use dpp::identifier::Identifier; + + let (drive, contract) = setup_likes(); + let pv = platform_version(); + insert_post(&drive, &contract, POST_A, "dash", "post a", 10); + insert_post(&drive, &contract, POST_B, "dash", "post b", 11); + insert_post(&drive, &contract, POST_C, "btc", "post c", 12); + for (post, hashtag, seed) in [(POST_A, "dash", 1u64), (POST_B, "dash", 2)] { + let like = build_like(&contract, hashtag, post, OWNER_1, seed); + insert_like(&drive, &contract, &like, true).expect("insert like"); + } + + let chained = chained_posts_i_liked(&contract, OWNER_1, None, Some(10)); + let (proof, proved_inner) = drive + .query_chained_documents_with_proof(&chained, pv) + .expect("chained proof generates"); + let honest_hint = chained + .join_values(&proved_inner) + .expect("join values extract"); + assert_eq!(honest_hint.len(), 2); + + // Dropped id: the merged query's outer branch misses a proven + // reference. + let dropped: Vec = honest_hint[..1].to_vec(); + assert!( + chained + .verify_chained_documents_proof(proof.as_slice(), &dropped, pv) + .is_err(), + "a hint missing a proven join value must be refused" + ); + + // Extra id: the merged query demands a document no proven join + // value references (POST_C exists on chain, making this the + // interesting injection case). + let mut extra = honest_hint.clone(); + extra.push(Identifier::from(POST_C)); + assert!( + chained + .verify_chained_documents_proof(proof.as_slice(), &extra, pv) + .is_err(), + "a hint with an injected id must be refused" + ); + + // Substituted id. + let mut substituted = honest_hint.clone(); + substituted[0] = Identifier::from(POST_C); + assert!( + chained + .verify_chained_documents_proof(proof.as_slice(), &substituted, pv) + .is_err(), + "a hint with a substituted id must be refused" + ); + + // And the honest hint still verifies after all that. + chained + .verify_chained_documents_proof(proof.as_slice(), &honest_hint, pv) + .expect("the honest hint verifies"); +} diff --git a/packages/rs-drive/src/drive/contract/insert/insert_contract/v0/tests/index_only_e2e_tests.rs b/packages/rs-drive/src/drive/contract/insert/insert_contract/v0/tests/index_only_e2e_tests.rs index a392ee4ba52..960fdd4347c 100644 --- a/packages/rs-drive/src/drive/contract/insert/insert_contract/v0/tests/index_only_e2e_tests.rs +++ b/packages/rs-drive/src/drive/contract/insert/insert_contract/v0/tests/index_only_e2e_tests.rs @@ -52,7 +52,7 @@ pub(super) fn platform_version() -> &'static PlatformVersion { PlatformVersion::latest() } -fn setup_likes() -> (Drive, DataContract) { +pub(super) fn setup_likes() -> (Drive, DataContract) { let drive = setup_drive_with_initial_state_structure(None); let pv = platform_version(); let contract = json_document_to_contract( diff --git a/packages/rs-drive/src/drive/contract/insert/insert_contract/v0/tests/mod.rs b/packages/rs-drive/src/drive/contract/insert/insert_contract/v0/tests/mod.rs index 63184ed3448..e6ecb106050 100644 --- a/packages/rs-drive/src/drive/contract/insert/insert_contract/v0/tests/mod.rs +++ b/packages/rs-drive/src/drive/contract/insert/insert_contract/v0/tests/mod.rs @@ -26,6 +26,7 @@ //! this file and is declared with `#[path]` so it can reuse that //! suite's fixture and assertion helpers. +mod chained_query_e2e_tests; mod countable_e2e_tests; mod index_only_e2e_tests; mod noncounted_sibling_e2e_tests; diff --git a/packages/rs-drive/src/drive/document/query/mod.rs b/packages/rs-drive/src/drive/document/query/mod.rs index 5c90d67c59c..72b5dcab3e5 100644 --- a/packages/rs-drive/src/drive/document/query/mod.rs +++ b/packages/rs-drive/src/drive/document/query/mod.rs @@ -4,6 +4,7 @@ //! mod fetch_document_history_query; +mod query_chained_documents; /// query of the vote state pub mod query_contested_documents_vote_state; mod query_documents; @@ -12,6 +13,7 @@ mod query_documents_with_flags; /// query of the contested documents in their storage pub mod query_contested_documents_storage; +pub use query_chained_documents::*; pub use query_documents::*; pub use query_documents_with_flags::*; diff --git a/packages/rs-drive/src/drive/document/query/query_chained_documents/mod.rs b/packages/rs-drive/src/drive/document/query/query_chained_documents/mod.rs new file mode 100644 index 00000000000..919b758cea6 --- /dev/null +++ b/packages/rs-drive/src/drive/document/query/query_chained_documents/mod.rs @@ -0,0 +1,73 @@ +mod v0; + +use crate::drive::Drive; +use crate::error::drive::DriveError; +use crate::error::Error; +use crate::query::drive_chained_document_query::DriveChainedDocumentQuery; +use dpp::block::epoch::Epoch; +use dpp::document::Document; +use dpp::version::PlatformVersion; +use grovedb::TransactionArg; + +pub use v0::QueryChainedDocumentsOutcomeV0; + +impl Drive { + /// Executes a chained document query (provable semi-join) without + /// proofs and returns the materialized halves plus the processing + /// cost (when an epoch is given). + pub fn query_chained_documents( + &self, + query: &DriveChainedDocumentQuery, + epoch: Option<&Epoch>, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result { + match platform_version + .drive + .methods + .document + .query + .query_chained_documents + { + 0 => self.query_chained_documents_v0(query, epoch, transaction, platform_version), + version => Err(Error::Drive(DriveError::UnknownVersionMismatch { + method: "query_chained_documents".to_string(), + known_versions: vec![0], + received: version, + })), + } + } + + /// Executes a chained document query AND generates its single + /// merged proof (the limited inner page and the derived outer + /// by-ids fetch merged by `prove_query_many` — one proof, one root + /// by construction). Grovedb proves committed state only, so the + /// materialize/prove sequence is bracketed by root-hash reads and + /// retried when a block commit interleaves — see + /// [`DriveChainedDocumentQuery::execute_with_proof_internal`]. + /// Shares the `query_chained_documents` version slot with the + /// no-proof path (one surface, one version). + /// Returns the merged proof plus the materialized INNER + /// projections (join values / hint / cursor derive from them); the + /// outer half is covered by the proof and not materialized. + pub fn query_chained_documents_with_proof( + &self, + query: &DriveChainedDocumentQuery, + platform_version: &PlatformVersion, + ) -> Result<(Vec, Vec), Error> { + match platform_version + .drive + .methods + .document + .query + .query_chained_documents + { + 0 => self.query_chained_documents_with_proof_v0(query, platform_version), + version => Err(Error::Drive(DriveError::UnknownVersionMismatch { + method: "query_chained_documents_with_proof".to_string(), + known_versions: vec![0], + received: version, + })), + } + } +} diff --git a/packages/rs-drive/src/drive/document/query/query_chained_documents/v0/mod.rs b/packages/rs-drive/src/drive/document/query/query_chained_documents/v0/mod.rs new file mode 100644 index 00000000000..e624aa31ac1 --- /dev/null +++ b/packages/rs-drive/src/drive/document/query/query_chained_documents/v0/mod.rs @@ -0,0 +1,62 @@ +use crate::drive::Drive; +use crate::error::Error; +use crate::fees::op::LowLevelDriveOperation; +use crate::query::drive_chained_document_query::{ + ChainedDocumentsResult, DriveChainedDocumentQuery, +}; +use dpp::block::epoch::Epoch; +use dpp::version::PlatformVersion; +use grovedb::TransactionArg; + +/// The outcome of a chained document query: the materialized halves and +/// the processing cost. +#[derive(Debug, Default)] +pub struct QueryChainedDocumentsOutcomeV0 { + /// The materialized inner projections and outer documents. + pub result: ChainedDocumentsResult, + /// The processing cost, when an epoch was given. + pub cost: u64, +} + +impl Drive { + #[inline(always)] + pub(super) fn query_chained_documents_v0( + &self, + query: &DriveChainedDocumentQuery, + epoch: Option<&Epoch>, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result { + let mut drive_operations: Vec = vec![]; + let result = query.execute_no_proof_internal( + self, + transaction, + &mut drive_operations, + platform_version, + )?; + let cost = if let Some(epoch) = epoch { + Drive::calculate_fee( + None, + Some(drive_operations), + epoch, + self.config.epochs_per_era, + platform_version, + None, + )? + .processing_fee + } else { + 0 + }; + Ok(QueryChainedDocumentsOutcomeV0 { result, cost }) + } + + #[inline(always)] + pub(super) fn query_chained_documents_with_proof_v0( + &self, + query: &DriveChainedDocumentQuery, + platform_version: &PlatformVersion, + ) -> Result<(Vec, Vec), Error> { + let mut drive_operations: Vec = vec![]; + query.execute_with_proof_internal(self, &mut drive_operations, platform_version) + } +} diff --git a/packages/rs-drive/src/query/drive_chained_document_query/mod.rs b/packages/rs-drive/src/query/drive_chained_document_query/mod.rs new file mode 100644 index 00000000000..305601b9f14 --- /dev/null +++ b/packages/rs-drive/src/query/drive_chained_document_query/mod.rs @@ -0,0 +1,507 @@ +//! Chained document queries: a provable semi-join. +//! +//! `SELECT * FROM post WHERE $id IN (SELECT postId FROM like WHERE +//! $ownerId = )` — the INNER query runs against an indexOnly document +//! type and projects a `refersTo: permanentDocument` property (the JOIN +//! property); its proven values are reinjected as the OUTER query's +//! primary keys. Both halves are proven as ONE merged grovedb proof — +//! `prove_query_many` merges the limited inner query with the derived +//! outer by-ids query (grovedb merge slot 2 lifts the inner limit into +//! a per-instance branch limit), so a single root binds the whole +//! composition by construction; the surrounding tenderdash layer then +//! binds that root to the quorum-signed app hash (see +//! `rs-drive-proof-verifier`). +//! +//! Soundness never rests on the server's join: the verifier re-derives +//! the outer query from the INNER proof's results ([`Self::join_values`] +//! → [`Self::derive_outer_query`], the same functions the server +//! executes), so a server cannot substitute, omit, or inject outer +//! documents. Because the join property's `refersTo` targets a +//! `permanentDocument` type (non-deletable, enforced at write time), +//! every proven join value MUST resolve to a document — a missing outer +//! document is an invalid proof, not an absence. +//! +//! Guardrails (v1): the inner query must resolve to an indexOnly index +//! that carries the join property (as terminal or prefix property, so +//! every synthesized projection provably carries its value); the join +//! edge must be a same-contract `refersTo: permanentDocument` whose +//! target is the outer type; the inner limit is required (it is what +//! bounds the outer fan-out); the outer half takes no clauses, no +//! limit and no cursor — it is purely the derived by-ids fetch, and +//! pagination lives on the inner query alone. + +use crate::error::drive::DriveError; +use crate::error::query::QuerySyntaxError; +use crate::error::Error; +use crate::query::{DriveDocumentQuery, InternalClauses, WhereClause, WhereOperator}; +use dpp::data_contract::accessors::v0::DataContractV0Getters; +use dpp::data_contract::document_type::accessors::{DocumentTypeV0Getters, DocumentTypeV2Getters}; +use dpp::data_contract::document_type::{ + DocumentPropertyReferenceTarget, DocumentPropertyType, DocumentTypeRef, +}; +use dpp::document::{Document, DocumentV0Getters}; +use dpp::identifier::Identifier; +use dpp::platform_value::Value; +use dpp::version::PlatformVersion; + +/// The most join values one chained query can carry — the derived +/// outer query is a single `$id IN [...]` clause, and `in` clauses +/// admit at most 100 values (`WhereClause::in_values`). `validate` +/// caps the inner limit here so every reachable page fits, and +/// [`DriveChainedDocumentQuery::proof_path_queries`] enforces it on +/// the (untrusted, verifier-supplied) join-value list itself. +pub const MAX_CHAINED_JOIN_VALUES: usize = 100; + +/// A chained document query: an inner indexOnly query whose proven join +/// values become the outer query's primary keys. +/// +/// Construction contract: `outer_document_type` MUST be a document type +/// of `inner.contract` — build it via +/// [`DataContract::document_type_for_name`] on the same contract the +/// inner query was built from. [`Self::validate`] enforces everything +/// derivable from the types themselves. +/// +/// [`DataContract::document_type_for_name`]: +/// dpp::data_contract::accessors::v0::DataContractV0Getters::document_type_for_name +#[derive(Debug, Clone)] +pub struct DriveChainedDocumentQuery<'a> { + /// The inner query. Must target an indexOnly document type and + /// resolve to an index carrying [`Self::join_property`]. + pub inner: DriveDocumentQuery<'a>, + /// The inner property whose values feed the outer query's `$id`s. + /// Must carry a same-contract `refersTo: permanentDocument` + /// declaration targeting [`Self::outer_document_type`]. + pub join_property: String, + /// The outer document type — the `refersTo` target. + pub outer_document_type: DocumentTypeRef<'a>, +} + +/// The materialized result of a chained query, in inner-proof order. +#[derive(Debug, Default)] +pub struct ChainedDocumentsResult { + /// The inner projections (synthesized indexOnly documents), exactly + /// as the inner query alone would return them — the caller reads its + /// pagination cursor (the last join value) from here. + pub inner_documents: Vec, + /// The referenced outer documents, ordered by FIRST APPEARANCE of + /// their id in `inner_documents` (deduplicated). + pub outer_documents: Vec, +} + +impl<'a> DriveChainedDocumentQuery<'a> { + /// Validates the chained shape. Called by the server before + /// executing and by the verifier before verifying, so an invalid + /// spec fails identically on both sides. + pub fn validate(&self, platform_version: &PlatformVersion) -> Result<(), Error> { + let unsupported = |message: String| Error::Query(QuerySyntaxError::Unsupported(message)); + + if !self.inner.document_type.index_only() { + return Err(unsupported( + "chained document queries require an indexOnly inner document type: only \ + indexOnly projections prove their values positionally" + .to_string(), + )); + } + if self.outer_document_type.index_only() { + return Err(unsupported( + "the outer document type of a chained query cannot be indexOnly: outer \ + documents are fetched by id from primary storage, which indexOnly types \ + do not have" + .to_string(), + )); + } + match self.inner.limit { + None => { + return Err(unsupported( + "chained document queries require an explicit limit on the inner query: \ + the inner page size is what bounds the derived outer query" + .to_string(), + )); + } + Some(limit) if limit as usize > MAX_CHAINED_JOIN_VALUES => { + return Err(unsupported(format!( + "a chained inner limit of {} exceeds {}: the derived outer query is a \ + single `$id IN` clause, which admits at most that many values", + limit, MAX_CHAINED_JOIN_VALUES, + ))); + } + Some(_) => {} + } + if self.inner.offset.is_some() { + return Err(unsupported( + "chained document queries do not support an inner offset; paginate with a \ + range clause on the join property" + .to_string(), + )); + } + + // The join property must be a same-contract permanentDocument + // reference targeting the outer type. `refersTo` writes are + // existence-validated and permanentDocument targets can never be + // deleted, so every proven join value MUST resolve — which is + // what lets the verifier treat a missing outer document as an + // invalid proof instead of needing absence proofs. + let Some(join_document_property) = self + .inner + .document_type + .flattened_properties() + .get(self.join_property.as_str()) + else { + return Err(unsupported(format!( + "chained query join property \"{}\" does not name a property of inner \ + document type \"{}\"", + self.join_property, + self.inner.document_type.name(), + ))); + }; + match &join_document_property.property_type { + DocumentPropertyType::IdentifierWithReference( + DocumentPropertyReferenceTarget::PermanentDocument { + contract_id, + document_type_name, + .. + }, + ) => { + if let Some(referenced_contract_id) = contract_id { + if *referenced_contract_id != self.inner.contract.id() { + return Err(unsupported( + "chained document queries support same-contract joins only: \ + the join property's refersTo names another contract" + .to_string(), + )); + } + } + if document_type_name != self.outer_document_type.name() { + return Err(unsupported(format!( + "chained query outer document type \"{}\" does not match the join \ + property's refersTo target \"{}\"", + self.outer_document_type.name(), + document_type_name, + ))); + } + } + _ => { + return Err(unsupported(format!( + "chained query join property \"{}\" must carry a `refersTo: \ + permanentDocument` declaration: only a permanent-document reference \ + guarantees every proven join value resolves to an outer document", + self.join_property, + ))); + } + } + + // The resolved index must carry the join property, so every + // synthesized inner projection provably carries its value. + let index = self.inner.index_only_query_index(platform_version)?; + let index_carries_join_property = index.terminal.as_deref() + == Some(self.join_property.as_str()) + || index + .properties + .iter() + .any(|property| property.name == self.join_property); + if !index_carries_join_property { + return Err(unsupported(format!( + "the inner query resolves to index \"{}\", which does not carry the join \ + property \"{}\"; constrain the query so an index carrying it serves it", + index.name, self.join_property, + ))); + } + + Ok(()) + } + + /// Extracts the join values from the inner documents in their proof + /// order, deduplicated to first appearance. ONE extraction both the + /// server and the verifier run — the single-builder rule that keeps + /// the derived outer query identical on both sides. + pub fn join_values(&self, inner_documents: &[Document]) -> Result, Error> { + use dpp::platform_value::btreemap_extensions::BTreeValueMapPathHelper; + + let mut seen: std::collections::BTreeSet = std::collections::BTreeSet::new(); + let mut join_values = Vec::with_capacity(inner_documents.len()); + for document in inner_documents { + // Path-aware read: `validate` admits any property + // `flattened_properties()` names — dotted (nested) keys + // included — and the synthesis builder stores those nested + // (`insert_at_path`), so a flat `.get` would miss them. + let value = document + .properties() + .get_optional_at_path(self.join_property.as_str()) + .ok() + .flatten() + .ok_or(Error::Drive(DriveError::CorruptedCodeExecution( + "an inner projection is missing the join property: validate() \ + guarantees the resolved index carries it", + )))?; + let identifier = value.to_identifier().map_err(|_| { + Error::Drive(DriveError::CorruptedCodeExecution( + "a chained join property must decode as an identifier: the parser \ + only admits identifier-typed refersTo properties", + )) + })?; + if seen.insert(identifier) { + join_values.push(identifier); + } + } + Ok(join_values) + } + + /// The derived outer query: a pure by-ids fetch of the join values + /// from the outer type's primary storage. No clauses, no limit, no + /// cursor — completeness is set-equality against `join_values`, + /// checked by the verifier. + pub fn derive_outer_query(&self, join_values: &[Identifier]) -> DriveDocumentQuery<'a> { + // Canonical value order: byte-ascending. Grove sorts query keys + // internally either way; sorting here keeps the built query — + // and therefore the proof — byte-identical between the server + // and a verifier that extracted the ids in any order. + let mut ids: Vec = join_values.to_vec(); + ids.sort(); + DriveDocumentQuery { + contract: self.inner.contract, + document_type: self.outer_document_type, + internal_clauses: InternalClauses { + primary_key_in_clause: Some(WhereClause { + field: dpp::document::property_names::ID.to_string(), + operator: WhereOperator::In, + value: Value::Array( + ids.into_iter() + .map(|id| Value::Identifier(id.to_buffer())) + .collect(), + ), + }), + primary_key_equal_clause: None, + in_clauses: Vec::new(), + range_clause: None, + equal_clauses: Default::default(), + }, + offset: None, + limit: None, + order_by: Default::default(), + start_at: None, + start_at_included: false, + block_time_ms: None, + resolved_time_ranges: Vec::new(), + } + } + + /// Reorders the outer documents (returned in key order by the by-ids + /// query) into first-appearance join order, and enforces EXACT set + /// equality between the proven outer ids and the derived join + /// values — both directions. Shared by the server (where a mismatch + /// is corrupted state: permanentDocument references cannot dangle) + /// and the verifier (where it is an invalid proof). + pub fn assemble_outer_documents( + &self, + join_values: &[Identifier], + outer_documents: Vec, + ) -> Result, Error> { + use std::collections::BTreeMap; + let mut by_id: BTreeMap = BTreeMap::new(); + for document in outer_documents { + let id = document.id(); + if by_id.insert(id, document).is_some() { + return Err(Error::Proof( + crate::error::proof::ProofError::CorruptedProof(format!( + "chained outer results carry document {} twice", + id + )), + )); + } + } + let mut ordered = Vec::with_capacity(join_values.len()); + for join_value in join_values { + let document = by_id.remove(join_value).ok_or_else(|| { + Error::Proof(crate::error::proof::ProofError::CorruptedProof(format!( + "chained outer results are missing referenced document {}: a \ + permanentDocument reference cannot dangle, so the outer half does \ + not prove the derived query", + join_value + ))) + })?; + ordered.push(document); + } + if let Some((extra_id, _)) = by_id.into_iter().next() { + return Err(Error::Proof( + crate::error::proof::ProofError::CorruptedProof(format!( + "chained outer results carry document {} that no proven join value \ + references", + extra_id + )), + )); + } + Ok(ordered) + } + + /// The component path queries the chained proof covers: the inner + /// query's own path query, plus — for a non-empty join — the outer + /// by-ids path query derived from `join_values`. ONE builder both + /// the prover (`prove_query_many` merges these) and the verifier + /// (`PathQuery::merge` on the same inputs at the same grove + /// version) call, so the merged query is byte-identical on both + /// sides. Grovedb's merge lifts the inner query's global + /// `SizedQuery::limit` into its branch's per-instance + /// `Query::limit`, which is exact here: the branch instance + /// executes once. + pub fn proof_path_queries( + &self, + join_values: &[Identifier], + platform_version: &PlatformVersion, + ) -> Result, Error> { + // `join_values` may be an UNTRUSTED verifier-side hint; cap it + // before deriving, so an oversized list fails here with a clear + // message instead of deep in the `in`-clause lowering. An + // honest list cannot exceed this: it is deduplicated from an + // inner page whose limit `validate` bounds to the same cap. + if join_values.len() > MAX_CHAINED_JOIN_VALUES { + return Err(Error::Query(QuerySyntaxError::Unsupported(format!( + "{} chained join values exceed the {} an outer `$id IN` clause admits", + join_values.len(), + MAX_CHAINED_JOIN_VALUES, + )))); + } + let inner = self.inner.construct_path_query(None, platform_version)?; + if join_values.is_empty() { + return Ok(vec![inner]); + } + let outer = self + .derive_outer_query(join_values) + .construct_path_query(None, platform_version)?; + Ok(vec![inner, outer]) + } +} + +#[cfg(feature = "server")] +impl DriveChainedDocumentQuery<'_> { + /// Executes the chained query without proofs. + pub(crate) fn execute_no_proof_internal( + &self, + drive: &crate::drive::Drive, + transaction: grovedb::TransactionArg, + drive_operations: &mut Vec, + platform_version: &PlatformVersion, + ) -> Result { + use dpp::document::serialization_traits::DocumentPlatformConversionMethodsV0; + + self.validate(platform_version)?; + + let (inner_documents, _skipped) = + self.inner.execute_index_only_documents_no_proof_internal( + drive, + transaction, + drive_operations, + platform_version, + )?; + let join_values = self.join_values(&inner_documents)?; + if join_values.is_empty() { + return Ok(ChainedDocumentsResult { + inner_documents, + outer_documents: Vec::new(), + }); + } + + let outer_query = self.derive_outer_query(&join_values); + let (serialized_outer, _outer_skipped) = outer_query + .execute_raw_results_no_proof_internal( + drive, + transaction, + drive_operations, + platform_version, + )?; + let outer_documents = serialized_outer + .into_iter() + .map(|serialized| { + Document::from_bytes( + serialized.as_slice(), + self.outer_document_type, + platform_version, + ) + .map_err(|e| Error::Protocol(Box::new(e))) + }) + .collect::, Error>>()?; + let outer_documents = self.assemble_outer_documents(&join_values, outer_documents)?; + + Ok(ChainedDocumentsResult { + inner_documents, + outer_documents, + }) + } + + /// Executes the chained query AND generates its single merged + /// proof. + /// + /// The inner page and the derived outer by-ids fetch are proven as + /// ONE grovedb proof: [`Self::proof_path_queries`] builds the + /// component path queries and `prove_query_many` merges them + /// (grovedb merge slot 2 LIFTS the inner query's global limit into + /// its merged branch's per-instance `Query::limit` — semantically + /// exact, since the branch executes once). One proof means one root + /// by construction. + /// + /// The materialize pass (which produces the join values the outer + /// component derives from) and the prove pass still both read + /// committed state — grovedb proves committed state only — so the + /// sequence is BRACKETED by root-hash reads and retried if a block + /// commit interleaved; otherwise the proof's inner branch could + /// disagree with the outer branch derived from the stale + /// materialization, and every verifier would reject the + /// composition. + /// + /// Returns the proof and the materialized INNER projections (the + /// join values, and with them the caller's response hint and + /// pagination cursor, derive from these). The outer documents are + /// deliberately NOT materialized here — the proof pass covers them, + /// so reading their bodies a second time would double the state + /// reads for data the proved response never carries inline. + pub(crate) fn execute_with_proof_internal( + &self, + drive: &crate::drive::Drive, + drive_operations: &mut Vec, + platform_version: &PlatformVersion, + ) -> Result<(Vec, Vec), Error> { + self.validate(platform_version)?; + + // Block commits are seconds apart while an attempt is + // milliseconds, so a bracket collision is rare and two in a row + // vanishingly so; three attempts is generosity, not need. + const MAX_ATTEMPTS: usize = 3; + for _ in 0..MAX_ATTEMPTS { + let root_before = drive + .grove + .root_hash(None, &platform_version.drive.grove_version) + .unwrap()?; + + // Materialize the INNER half only — the join values the + // outer component derives from live in its projections. + let (inner_documents, _skipped) = + self.inner.execute_index_only_documents_no_proof_internal( + drive, + None, + drive_operations, + platform_version, + )?; + let join_values = self.join_values(&inner_documents)?; + + let path_queries = self.proof_path_queries(&join_values, platform_version)?; + let path_query_refs: Vec<&grovedb::PathQuery> = path_queries.iter().collect(); + let proof = drive + .grove + .prove_query_many(path_query_refs, None, &platform_version.drive.grove_version) + .unwrap()?; + + let root_after = drive + .grove + .root_hash(None, &platform_version.drive.grove_version) + .unwrap()?; + if root_before != root_after { + continue; + } + + return Ok((proof, inner_documents)); + } + Err(Error::Drive(DriveError::NotSupported( + "chained proof generation raced a block commit on every attempt; \ + transient — retry the request", + ))) + } +} diff --git a/packages/rs-drive/src/query/mod.rs b/packages/rs-drive/src/query/mod.rs index 7420f0553bf..ac3e975e08d 100644 --- a/packages/rs-drive/src/query/mod.rs +++ b/packages/rs-drive/src/query/mod.rs @@ -287,6 +287,12 @@ pub mod drive_document_ranked_query; #[cfg(any(feature = "server", feature = "verify"))] pub(crate) mod index_only_synthesis; +/// Chained document queries — a provable semi-join: an inner indexOnly +/// query whose proven `refersTo` values become the outer query's +/// primary keys, proven against one state root. See the module docs. +#[cfg(any(feature = "server", feature = "verify"))] +pub mod drive_chained_document_query; + /// Joint count-and-sum no-prove executor surface — backs the AVG /// no-prove path's unified single-walk dispatch. See its module /// docstring for the perf / atomicity contract. Server-only because diff --git a/packages/rs-drive/src/verify/chained_document/mod.rs b/packages/rs-drive/src/verify/chained_document/mod.rs new file mode 100644 index 00000000000..68ce536a97e --- /dev/null +++ b/packages/rs-drive/src/verify/chained_document/mod.rs @@ -0,0 +1,5 @@ +//! Chained document query proof verification — the verifier half of the +//! provable semi-join in +//! [`drive_chained_document_query`](crate::query::drive_chained_document_query). + +mod verify_chained_documents_proof; diff --git a/packages/rs-drive/src/verify/chained_document/verify_chained_documents_proof/mod.rs b/packages/rs-drive/src/verify/chained_document/verify_chained_documents_proof/mod.rs new file mode 100644 index 00000000000..93bac9b50b6 --- /dev/null +++ b/packages/rs-drive/src/verify/chained_document/verify_chained_documents_proof/mod.rs @@ -0,0 +1,107 @@ +mod v0; + +use crate::error::drive::DriveError; +use crate::error::Error; +use crate::query::drive_chained_document_query::{ + ChainedDocumentsResult, DriveChainedDocumentQuery, +}; +use crate::verify::RootHash; +use dpp::version::PlatformVersion; + +impl DriveChainedDocumentQuery<'_> { + /// Verifies a chained query's single merged proof and returns + /// `(root_hash, result)`. + /// + /// The verifier trusts nothing about the join. `join_values_hint` + /// is the server's CLAIMED join-value list — untrusted bootstrap + /// data used only to reconstruct the merged query (the outer by-ids + /// component is derived from it, exactly as the prover derived it + /// from its materialization). The proof is then verified against + /// that reconstruction in ONE pass — grovedb enforces the inner + /// page's lifted per-instance limit and range completeness — and + /// the PROVEN inner join values are extracted and required to match + /// the proven outer documents exactly. A hint that lies in any + /// direction (extra, missing, or substituted ids) produces a merged + /// query the proof cannot satisfy consistently, and verification + /// fails: a missing referenced document is an invalid proof + /// (`refersTo: permanentDocument` targets cannot dangle), and so is + /// an extra one. + /// + /// One proof means one root by construction; the caller combines + /// the returned root hash with the surrounding tenderdash + /// signature — see `rs-drive-proof-verifier` for the canonical + /// composition. + pub fn verify_chained_documents_proof( + &self, + proof: &[u8], + join_values_hint: &[dpp::identifier::Identifier], + platform_version: &PlatformVersion, + ) -> Result<(RootHash, ChainedDocumentsResult), Error> { + match platform_version + .drive + .methods + .verify + .chained_document + .verify_chained_documents_proof + { + 0 => self.verify_chained_documents_proof_v0(proof, join_values_hint, platform_version), + version => Err(Error::Drive(DriveError::UnknownVersionMismatch { + method: "DriveChainedDocumentQuery::verify_chained_documents_proof".to_string(), + known_versions: vec![0], + received: version, + })), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::error::drive::DriveError; + use crate::query::DriveDocumentQuery; + use dpp::data_contract::accessors::v0::DataContractV0Getters; + use dpp::data_contracts::SystemDataContract; + use dpp::system_data_contracts::load_system_data_contract; + + #[test] + fn test_verify_chained_documents_proof_unknown_version() { + let platform_version = dpp::version::PlatformVersion::latest(); + let contract = load_system_data_contract(SystemDataContract::DPNS, platform_version) + .expect("expected to load DPNS contract"); + let document_type = contract + .document_type_for_name("domain") + .expect("expected domain document type"); + + let mut platform_version = platform_version.clone(); + platform_version + .drive + .methods + .verify + .chained_document + .verify_chained_documents_proof = 255; + + let query = DriveChainedDocumentQuery { + inner: DriveDocumentQuery { + contract: &contract, + document_type, + internal_clauses: Default::default(), + offset: None, + limit: Some(1), + order_by: Default::default(), + start_at: None, + start_at_included: false, + block_time_ms: None, + resolved_time_ranges: vec![], + }, + join_property: "records".to_string(), + outer_document_type: document_type, + }; + + let result = query.verify_chained_documents_proof(&[], &[], &platform_version); + assert!(matches!( + result, + Err(Error::Drive(DriveError::UnknownVersionMismatch { method, .. })) + if method == "DriveChainedDocumentQuery::verify_chained_documents_proof" + )); + } +} diff --git a/packages/rs-drive/src/verify/chained_document/verify_chained_documents_proof/v0/mod.rs b/packages/rs-drive/src/verify/chained_document/verify_chained_documents_proof/v0/mod.rs new file mode 100644 index 00000000000..20e1c7d42db --- /dev/null +++ b/packages/rs-drive/src/verify/chained_document/verify_chained_documents_proof/v0/mod.rs @@ -0,0 +1,111 @@ +use crate::error::proof::ProofError; +use crate::error::Error; +use crate::query::drive_chained_document_query::{ + ChainedDocumentsResult, DriveChainedDocumentQuery, +}; +use crate::query::index_only_synthesis::synthesize_index_only_document; +use crate::verify::RootHash; +use dpp::data_contract::accessors::v0::DataContractV0Getters; +use dpp::data_contract::document_type::accessors::DocumentTypeV0Getters; +use dpp::document::serialization_traits::DocumentPlatformConversionMethodsV0; +use dpp::document::Document; +use dpp::identifier::Identifier; +use dpp::version::PlatformVersion; +use grovedb::{GroveDb, PathQuery}; + +impl DriveChainedDocumentQuery<'_> { + /// v0 of the chained proof verification — see the versioned wrapper + /// for the trust model. + #[inline(always)] + pub(super) fn verify_chained_documents_proof_v0( + &self, + proof: &[u8], + join_values_hint: &[Identifier], + platform_version: &PlatformVersion, + ) -> Result<(RootHash, ChainedDocumentsResult), Error> { + self.validate(platform_version)?; + let grove_version = &platform_version.drive.grove_version; + + // The hint is UNTRUSTED bootstrap data (the server's claimed + // join values). The merged query is derived from it and the + // proof is verified against that derivation — if the hint + // disagrees with the proof's actual inner content, the exact-set + // assembly below fails, so soundness never rests on the hint. + let path_queries = self.proof_path_queries(join_values_hint, platform_version)?; + let path_query_refs: Vec<&PathQuery> = path_queries.iter().collect(); + let merged_query = if path_query_refs.len() > 1 { + PathQuery::merge(path_query_refs, grove_version)? + } else { + path_queries[0].clone() + }; + + let (root_hash, proved_path_key_values) = + GroveDb::verify_query(proof, &merged_query, grove_version)?; + + // Split the proved trios between the halves by their doctype + // path segment: `[DataContractDocuments, contract_id, 1, + // , …]`. + let inner_type_name = self.inner.document_type.name().as_bytes(); + let outer_type_name = self.outer_document_type.name().as_bytes(); + let index = self.inner.index_only_query_index(platform_version)?; + let mut inner_documents: Vec = Vec::new(); + let mut outer_documents: Vec = Vec::new(); + for (path, key, element) in proved_path_key_values { + let Some(element) = element else { + continue; + }; + match path.get(3).map(|segment| segment.as_slice()) { + Some(segment) if segment == inner_type_name => { + inner_documents.push(synthesize_index_only_document( + self.inner.contract.id(), + self.inner.document_type, + index, + &path, + &key, + )?); + } + Some(segment) if segment == outer_type_name => { + let grovedb::Element::Item(serialized, _) = element else { + return Err(Error::Proof(ProofError::CorruptedProof( + "chained proof's outer half proved a non-item element where a \ + stored document was expected" + .to_string(), + ))); + }; + outer_documents.push( + Document::from_bytes( + serialized.as_slice(), + self.outer_document_type, + platform_version, + ) + .map_err(|e| Error::Protocol(Box::new(e)))?, + ); + } + _ => { + return Err(Error::Proof(ProofError::CorruptedProof( + "chained proof proved an entry outside both document types' \ + subtrees" + .to_string(), + ))); + } + } + } + + // The PROVEN join values are authoritative. If the hint lied — + // extra ids, missing ids, different ids — the outer half the + // merged query covered cannot match them, and the exact-set + // assembly refuses in whichever direction the lie went. An + // empty proven page with a non-empty hint dies here too: the + // merged query demanded outer entries the proof cannot carry. + let join_values = self.join_values(&inner_documents)?; + let outer_documents = self.assemble_outer_documents(&join_values, outer_documents)?; + + Ok(( + root_hash, + ChainedDocumentsResult { + inner_documents, + outer_documents, + }, + )) + } +} diff --git a/packages/rs-drive/src/verify/mod.rs b/packages/rs-drive/src/verify/mod.rs index 3875f2b8507..a6c0593912b 100644 --- a/packages/rs-drive/src/verify/mod.rs +++ b/packages/rs-drive/src/verify/mod.rs @@ -1,5 +1,8 @@ #![allow(clippy::result_large_err)] // Errors intentionally carry rich context in verify paths // TODO: Revisit after shrinking top-level Error by boxing heavy variants +/// Chained document query (provable semi-join) verification methods on +/// proofs — two grovedb proofs verified as one composed statement. +pub mod chained_document; ///DataContract verification methods on proofs pub mod contract; /// Document verification methods on proofs diff --git a/packages/rs-platform-version/src/version/drive_versions/drive_document_method_versions/mod.rs b/packages/rs-platform-version/src/version/drive_versions/drive_document_method_versions/mod.rs index 4a0aca2b9f5..c5ceef2e1ec 100644 --- a/packages/rs-platform-version/src/version/drive_versions/drive_document_method_versions/mod.rs +++ b/packages/rs-platform-version/src/version/drive_versions/drive_document_method_versions/mod.rs @@ -20,6 +20,9 @@ pub struct DriveDocumentMethodVersions { #[derive(Clone, Debug, Default)] pub struct DriveDocumentQueryMethodVersions { pub query_documents: FeatureVersion, + /// Chained document queries (provable semi-join): the version slot + /// shared by the no-proof and the two-proof execution paths. + pub query_chained_documents: FeatureVersion, pub query_contested_documents: FeatureVersion, pub query_contested_documents_vote_state: FeatureVersion, pub query_documents_with_flags: FeatureVersion, diff --git a/packages/rs-platform-version/src/version/drive_versions/drive_document_method_versions/v1.rs b/packages/rs-platform-version/src/version/drive_versions/drive_document_method_versions/v1.rs index 3a1f14b7068..66aaa63c641 100644 --- a/packages/rs-platform-version/src/version/drive_versions/drive_document_method_versions/v1.rs +++ b/packages/rs-platform-version/src/version/drive_versions/drive_document_method_versions/v1.rs @@ -9,6 +9,7 @@ pub const DRIVE_DOCUMENT_METHOD_VERSIONS_V1: DriveDocumentMethodVersions = DriveDocumentMethodVersions { query: DriveDocumentQueryMethodVersions { query_documents: 0, + query_chained_documents: 0, query_contested_documents: 0, query_contested_documents_vote_state: 0, query_documents_with_flags: 0, diff --git a/packages/rs-platform-version/src/version/drive_versions/drive_document_method_versions/v2.rs b/packages/rs-platform-version/src/version/drive_versions/drive_document_method_versions/v2.rs index 132d06e0539..ce962c55a75 100644 --- a/packages/rs-platform-version/src/version/drive_versions/drive_document_method_versions/v2.rs +++ b/packages/rs-platform-version/src/version/drive_versions/drive_document_method_versions/v2.rs @@ -11,6 +11,7 @@ pub const DRIVE_DOCUMENT_METHOD_VERSIONS_V2: DriveDocumentMethodVersions = DriveDocumentMethodVersions { query: DriveDocumentQueryMethodVersions { query_documents: 0, + query_chained_documents: 0, query_contested_documents: 0, query_contested_documents_vote_state: 0, query_documents_with_flags: 0, diff --git a/packages/rs-platform-version/src/version/drive_versions/drive_document_method_versions/v3.rs b/packages/rs-platform-version/src/version/drive_versions/drive_document_method_versions/v3.rs index a1f92dc2459..f2635d5ff18 100644 --- a/packages/rs-platform-version/src/version/drive_versions/drive_document_method_versions/v3.rs +++ b/packages/rs-platform-version/src/version/drive_versions/drive_document_method_versions/v3.rs @@ -21,6 +21,7 @@ pub const DRIVE_DOCUMENT_METHOD_VERSIONS_V3: DriveDocumentMethodVersions = DriveDocumentMethodVersions { query: DriveDocumentQueryMethodVersions { query_documents: 0, + query_chained_documents: 0, query_contested_documents: 0, query_contested_documents_vote_state: 0, query_documents_with_flags: 0, diff --git a/packages/rs-platform-version/src/version/drive_versions/drive_document_method_versions/v4.rs b/packages/rs-platform-version/src/version/drive_versions/drive_document_method_versions/v4.rs index b5d6ed4eceb..14dd88c5839 100644 --- a/packages/rs-platform-version/src/version/drive_versions/drive_document_method_versions/v4.rs +++ b/packages/rs-platform-version/src/version/drive_versions/drive_document_method_versions/v4.rs @@ -68,6 +68,7 @@ pub const DRIVE_DOCUMENT_METHOD_VERSIONS_V4: DriveDocumentMethodVersions = DriveDocumentMethodVersions { query: DriveDocumentQueryMethodVersions { query_documents: 0, + query_chained_documents: 0, query_contested_documents: 0, query_contested_documents_vote_state: 0, query_documents_with_flags: 0, diff --git a/packages/rs-platform-version/src/version/drive_versions/drive_verify_method_versions/mod.rs b/packages/rs-platform-version/src/version/drive_versions/drive_verify_method_versions/mod.rs index 58280be26b8..bda81602f40 100644 --- a/packages/rs-platform-version/src/version/drive_versions/drive_verify_method_versions/mod.rs +++ b/packages/rs-platform-version/src/version/drive_versions/drive_verify_method_versions/mod.rs @@ -7,6 +7,7 @@ pub mod v2; pub struct DriveVerifyMethodVersions { pub contract: DriveVerifyContractMethodVersions, pub document: DriveVerifyDocumentMethodVersions, + pub chained_document: DriveVerifyChainedDocumentMethodVersions, pub document_count: DriveVerifyDocumentCountMethodVersions, pub document_sum: DriveVerifyDocumentSumMethodVersions, pub document_ranked: DriveVerifyDocumentRankedMethodVersions, @@ -46,6 +47,14 @@ pub struct DriveVerifyDocumentMethodVersions { pub verify_start_at_document_in_proof: FeatureVersion, } +/// Versions for the chained document query (provable semi-join) +/// prove-path verifier (grovedb-level — the tenderdash composition +/// layer lives in rs-drive-proof-verifier). +#[derive(Clone, Debug, Default)] +pub struct DriveVerifyChainedDocumentMethodVersions { + pub verify_chained_documents_proof: FeatureVersion, +} + /// Versions for the `GetDocumentsCount` prove-path verifiers /// (grovedb-level — the tenderdash composition layer lives in /// rs-drive-proof-verifier). All three methods are implemented on diff --git a/packages/rs-platform-version/src/version/drive_versions/drive_verify_method_versions/v1.rs b/packages/rs-platform-version/src/version/drive_versions/drive_verify_method_versions/v1.rs index b9412b58615..02c7fd7f1cb 100644 --- a/packages/rs-platform-version/src/version/drive_versions/drive_verify_method_versions/v1.rs +++ b/packages/rs-platform-version/src/version/drive_versions/drive_verify_method_versions/v1.rs @@ -1,8 +1,9 @@ use crate::version::drive_versions::drive_verify_method_versions::{ - DriveVerifyAddressFundsMethodVersions, DriveVerifyContractMethodVersions, - DriveVerifyDocumentCountMethodVersions, DriveVerifyDocumentMethodVersions, - DriveVerifyDocumentRankedMethodVersions, DriveVerifyDocumentSumMethodVersions, - DriveVerifyGroupMethodVersions, DriveVerifyIdentityMethodVersions, DriveVerifyMethodVersions, + DriveVerifyAddressFundsMethodVersions, DriveVerifyChainedDocumentMethodVersions, + DriveVerifyContractMethodVersions, DriveVerifyDocumentCountMethodVersions, + DriveVerifyDocumentMethodVersions, DriveVerifyDocumentRankedMethodVersions, + DriveVerifyDocumentSumMethodVersions, DriveVerifyGroupMethodVersions, + DriveVerifyIdentityMethodVersions, DriveVerifyMethodVersions, DriveVerifyShieldedMethodVersions, DriveVerifySingleDocumentMethodVersions, DriveVerifyStateTransitionMethodVersions, DriveVerifySystemMethodVersions, DriveVerifyTokenMethodVersions, DriveVerifyVoteMethodVersions, @@ -20,6 +21,9 @@ pub const DRIVE_VERIFY_METHOD_VERSIONS_V1: DriveVerifyMethodVersions = DriveVeri verify_document_history: 0, verify_start_at_document_in_proof: 0, }, + chained_document: DriveVerifyChainedDocumentMethodVersions { + verify_chained_documents_proof: 0, + }, document_count: DriveVerifyDocumentCountMethodVersions { verify_aggregate_count_proof: 0, verify_carrier_aggregate_count_proof: 0,