diff --git a/packages/rs-drive/src/drive/contract/insert/insert_contract/v0/tests/composite_query_e2e_tests.rs b/packages/rs-drive/src/drive/contract/insert/insert_contract/v0/tests/composite_query_e2e_tests.rs new file mode 100644 index 00000000000..3c9d7f9bbfa --- /dev/null +++ b/packages/rs-drive/src/drive/contract/insert/insert_contract/v0/tests/composite_query_e2e_tests.rs @@ -0,0 +1,1405 @@ +//! End-to-end coverage for **composite document queries**: a page of +//! posts plus everything a feed card renders for it — like and repost +//! counts, the quoted posts, the reposts, the authors' profiles (in +//! another contract), the quoted authors' profiles (derived from a +//! sub-query rather than the page), and the viewer's own likes — as ONE +//! merged proof against the `yappr-feed` fixture. +//! +//! Pinned here: no-proof/proof parity (the verifier's composed result +//! equals the server's materialized result), the empty-page shape, the +//! validation rejections, the fail-closed behaviour on a page-only proof +//! (what a node ignoring the sub-queries would serve), the dangling +//! reference refusal, and by-id routing when the page and a join share +//! the primary tree. + +use crate::error::Error; +use crate::query::drive_composite_document_query::{ + BindingSource, DriveCompositeDocumentQuery, DriveSubQuery, SubQueryBinding, SubQueryKind, + SubQueryResult, MAX_SUB_QUERIES, +}; +use crate::query::{DriveDocumentQuery, InternalClauses, OrderClause, WhereClause, WhereOperator}; +use crate::util::object_size_info::DocumentInfo::DocumentRefInfo; +use crate::util::object_size_info::{DocumentAndContractInfo, OwnedDocumentInfo}; +use crate::util::storage_flags::StorageFlags; +use crate::util::test_helpers::setup::setup_drive_with_initial_state_structure; +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::{Document, DocumentV0Getters, DocumentV0Setters}; +use dpp::platform_value::{Identifier, Value}; +use dpp::prelude::DataContract; +use dpp::tests::json_document::json_document_to_contract; +use dpp::version::PlatformVersion; +use std::collections::BTreeMap; + +const FEED_CONTRACT: &str = "tests/supporting_files/contract/yappr-feed/yappr-feed-contract.json"; +const DASHPAY_CONTRACT: &str = "tests/supporting_files/contract/dashpay/dashpay-contract.json"; + +const POST_A: [u8; 32] = [0xA1; 32]; +const POST_B: [u8; 32] = [0xB2; 32]; +const POST_C: [u8; 32] = [0xC3; 32]; +const POST_D: [u8; 32] = [0xD4; 32]; +const MISSING_POST: [u8; 32] = [0xE5; 32]; +const OWNER_1: [u8; 32] = [0x11; 32]; +const OWNER_2: [u8; 32] = [0x22; 32]; +const OWNER_3: [u8; 32] = [0x33; 32]; + +fn platform_version() -> &'static PlatformVersion { + PlatformVersion::latest() +} + +/// A drive with the feed contract and the dashpay contract (whose +/// `profile` type, keyed by `$ownerId`, plays the cross-contract lookup). +fn setup() -> (crate::drive::Drive, DataContract, DataContract) { + let drive = setup_drive_with_initial_state_structure(None); + let pv = platform_version(); + let mut contracts = Vec::new(); + for path in [FEED_CONTRACT, DASHPAY_CONTRACT] { + let contract = + json_document_to_contract(path, false, pv).expect("expected to parse the contract"); + drive + .apply_contract( + &contract, + BlockInfo::default(), + true, + StorageFlags::optional_default_as_cow(), + None, + pv, + ) + .expect("expected to apply the contract"); + contracts.push(contract); + } + let dashpay = contracts.pop().expect("dashpay"); + let feed = contracts.pop().expect("feed"); + (drive, feed, dashpay) +} + +fn insert(drive: &crate::drive::Drive, contract: &DataContract, type_name: &str, doc: &Document) { + let document_type = contract + .document_type_for_name(type_name) + .expect("doctype exists"); + 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, + platform_version(), + None, + ) + .expect("insert document"); +} + +fn build(contract: &DataContract, type_name: &str, seed: u64) -> Document { + contract + .document_type_for_name(type_name) + .expect("doctype exists") + .random_document(Some(seed), platform_version()) + .expect("random document") +} + +fn insert_post( + drive: &crate::drive::Drive, + contract: &DataContract, + id: [u8; 32], + owner: [u8; 32], + hashtag: &str, + quoted: Option<[u8; 32]>, + seed: u64, +) { + let mut doc = build(contract, "post", seed); + let mut props = BTreeMap::new(); + props.insert("hashtag".to_string(), Value::Text(hashtag.to_string())); + props.insert("message".to_string(), Value::Text(format!("post {seed}"))); + if let Some(quoted) = quoted { + props.insert("quotedPostId".to_string(), Value::Identifier(quoted)); + } + doc.set_properties(props); + doc.set_id(Identifier::from(id)); + doc.set_owner_id(Identifier::from(owner)); + insert(drive, contract, "post", &doc); +} + +fn insert_like( + drive: &crate::drive::Drive, + contract: &DataContract, + owner: [u8; 32], + post: [u8; 32], + hashtag: &str, + seed: u64, +) { + let mut doc = build(contract, "like", seed); + let mut props = BTreeMap::new(); + props.insert("hashtag".to_string(), Value::Text(hashtag.to_string())); + props.insert("postId".to_string(), Value::Identifier(post)); + doc.set_properties(props); + doc.set_owner_id(Identifier::from(owner)); + insert(drive, contract, "like", &doc); +} + +fn insert_repost( + drive: &crate::drive::Drive, + contract: &DataContract, + owner: [u8; 32], + post: [u8; 32], + seed: u64, +) { + let mut doc = build(contract, "repost", seed); + let mut props = BTreeMap::new(); + props.insert("postId".to_string(), Value::Identifier(post)); + doc.set_properties(props); + doc.set_owner_id(Identifier::from(owner)); + insert(drive, contract, "repost", &doc); +} + +fn insert_profile( + drive: &crate::drive::Drive, + dashpay: &DataContract, + owner: [u8; 32], + display_name: &str, + seed: u64, +) { + let mut doc = build(dashpay, "profile", seed); + let mut props = BTreeMap::new(); + props.insert( + "displayName".to_string(), + Value::Text(display_name.to_string()), + ); + doc.set_properties(props); + doc.set_owner_id(Identifier::from(owner)); + insert(drive, dashpay, "profile", &doc); +} + +/// The feed fixture: three `dash` posts (the page), one `btc` post two +/// of them quote, likes, reposts and two profiles. +/// +/// | post | owner | tag | quotes | likes by | reposts by | +/// |------|-------|------|--------|---------------|----------------| +/// | A | 1 | dash | D | 1, 2 | 2 | +/// | B | 2 | dash | — | 1 | 1, 3 | +/// | C | 3 | dash | D | — | — | +/// | D | 3 | btc | — | 3 | — | +/// +/// Profiles exist for owners 1 and 3 only. +fn seed_feed(drive: &crate::drive::Drive, feed: &DataContract, dashpay: &DataContract) { + insert_post(drive, feed, POST_D, OWNER_3, "btc", None, 4); + insert_post(drive, feed, POST_A, OWNER_1, "dash", Some(POST_D), 1); + insert_post(drive, feed, POST_B, OWNER_2, "dash", None, 2); + insert_post(drive, feed, POST_C, OWNER_3, "dash", Some(POST_D), 3); + insert_like(drive, feed, OWNER_1, POST_A, "dash", 10); + insert_like(drive, feed, OWNER_2, POST_A, "dash", 11); + insert_like(drive, feed, OWNER_1, POST_B, "dash", 12); + insert_like(drive, feed, OWNER_3, POST_D, "btc", 13); + insert_repost(drive, feed, OWNER_2, POST_A, 20); + insert_repost(drive, feed, OWNER_1, POST_B, 21); + insert_repost(drive, feed, OWNER_3, POST_B, 22); + insert_profile(drive, dashpay, OWNER_1, "one", 30); + insert_profile(drive, dashpay, OWNER_3, "three", 31); +} + +fn page_by_hashtag<'a>( + contract: &'a DataContract, + hashtag: &str, + limit: Option, +) -> DriveDocumentQuery<'a> { + DriveDocumentQuery { + contract, + document_type: contract.document_type_for_name("post").expect("post"), + internal_clauses: InternalClauses::extract_from_clauses( + vec![WhereClause { + field: "hashtag".to_string(), + operator: WhereOperator::Equal, + value: Value::Text(hashtag.to_string()), + }], + platform_version(), + ) + .expect("clauses extract"), + offset: None, + limit, + order_by: Default::default(), + start_at: None, + start_at_included: false, + block_time_ms: None, + resolved_time_ranges: vec![], + } +} + +fn bound<'a>( + contract: &'a DataContract, + type_name: &str, + kind: SubQueryKind, + source: BindingSource, + source_property: &str, + field: &str, + limit: Option, +) -> DriveSubQuery<'a> { + DriveSubQuery { + contract, + document_type: contract.document_type_for_name(type_name).expect("doctype"), + kind, + where_clauses: vec![], + order_by: vec![], + limit, + binding: Some(SubQueryBinding { + source, + source_property: source_property.to_string(), + field: field.to_string(), + }), + } +} + +/// Sub-query positions in [`feed_query`]. +const LIKE_COUNTS: usize = 0; +const QUOTED_POSTS: usize = 1; +const REPOSTS: usize = 2; +const AUTHOR_PROFILES: usize = 3; +const QUOTED_AUTHOR_PROFILES: usize = 4; +const VIEWER_LIKES: usize = 5; + +/// The whole feed composition: like counts, the quoted posts, the +/// reposts themselves (their count is a client-side length; a count on +/// the same `byPost` index would read the value trees the documents +/// lookup descends past), the authors' profiles, the quoted authors' +/// profiles. `viewer` adds the "which of these did I like" lookup on +/// the indexOnly `like` type — proof-path only, since its `byLiker` +/// projection does not cover every property and so cannot be +/// materialized into a non-proof response. +fn feed_query<'a>( + feed: &'a DataContract, + dashpay: &'a DataContract, + viewer: Option<[u8; 32]>, +) -> DriveCompositeDocumentQuery<'a> { + let mut sub_queries = vec![ + bound( + feed, + "like", + SubQueryKind::Count, + BindingSource::Page, + "$id", + "postId", + None, + ), + bound( + feed, + "post", + SubQueryKind::Documents, + BindingSource::Page, + "quotedPostId", + "$id", + None, + ), + bound( + feed, + "repost", + SubQueryKind::Documents, + BindingSource::Page, + "$id", + "postId", + Some(50), + ), + // Profiles are unique per owner: value-bounded, so no limit. + bound( + dashpay, + "profile", + SubQueryKind::Documents, + BindingSource::Page, + "$ownerId", + "$ownerId", + None, + ), + bound( + dashpay, + "profile", + SubQueryKind::Documents, + BindingSource::SubQuery(QUOTED_POSTS), + "$ownerId", + "$ownerId", + None, + ), + ]; + if let Some(viewer) = viewer { + // `byLiker` is `[$ownerId] → postId`: with the owner fixed, the + // terminal postId is unique per value — value-bounded, no limit. + let mut marks = bound( + feed, + "like", + SubQueryKind::Documents, + BindingSource::Page, + "$id", + "postId", + None, + ); + marks.where_clauses = vec![WhereClause { + field: "$ownerId".to_string(), + operator: WhereOperator::Equal, + value: Value::Identifier(viewer), + }]; + sub_queries.push(marks); + } + DriveCompositeDocumentQuery { + page: page_by_hashtag(feed, "dash", Some(10)), + sub_queries, + } +} + +fn ids(documents: &[Document]) -> Vec<[u8; 32]> { + documents.iter().map(|d| d.id().to_buffer()).collect() +} + +fn counts(result: &SubQueryResult) -> BTreeMap<[u8; 32], u64> { + result + .counts() + .iter() + .map(|entry| { + let key: [u8; 32] = entry.key.as_slice().try_into().expect("identifier key"); + (key, entry.count.expect("present count")) + }) + .collect() +} + +fn post_ids_of(result: &SubQueryResult, property: &str) -> Vec<[u8; 32]> { + result + .documents() + .iter() + .map(|d| { + d.properties() + .get(property) + .expect("property present") + .to_identifier() + .expect("identifier") + .to_buffer() + }) + .collect() +} + +fn owner_ids(documents: &[Document]) -> Vec<[u8; 32]> { + documents.iter().map(|d| d.owner_id().to_buffer()).collect() +} + +#[test] +fn should_preserve_join_order_before_deriving_later_bindings() { + let (drive, feed, dashpay) = setup(); + insert_post(&drive, &feed, POST_C, OWNER_1, "btc", None, 3); + insert_post(&drive, &feed, POST_D, OWNER_3, "btc", None, 4); + insert_post(&drive, &feed, POST_A, OWNER_1, "dash", Some(POST_D), 1); + insert_post(&drive, &feed, POST_B, OWNER_2, "dash", Some(POST_C), 2); + insert_profile(&drive, &dashpay, OWNER_1, "one", 30); + insert_profile(&drive, &dashpay, OWNER_3, "three", 31); + let query = feed_query(&feed, &dashpay, None); + let pv = platform_version(); + let materialized = drive + .query_composite_documents(&query, None, None, pv) + .expect("materializes") + .result; + assert_eq!( + ids(materialized.sub_results[QUOTED_POSTS].documents()), + vec![POST_D, POST_C], + "the page references quoted posts in the opposite order to their ids" + ); + let (proof, _) = drive + .query_composite_documents_with_proof(&query, pv) + .expect("proves"); + let (_, verified) = query + .verify_composite_documents_proof(&proof, pv) + .expect("a feed with two quoted authors verifies"); + assert_eq!(verified.sub_results, materialized.sub_results); +} + +#[test] +fn should_route_counts_by_complete_positions_including_overlapping_queries() { + let (drive, feed, dashpay) = setup(); + seed_feed(&drive, &feed, &dashpay); + let pv = platform_version(); + let mut sub_queries: Vec<_> = [OWNER_1, OWNER_2, OWNER_3] + .into_iter() + .map(|owner| { + let mut count = bound( + &feed, + "repost", + SubQueryKind::Count, + BindingSource::Page, + "$id", + "postId", + None, + ); + count.where_clauses.push(WhereClause { + field: "$ownerId".into(), + operator: WhereOperator::Equal, + value: Value::Identifier(owner), + }); + count + }) + .collect(); + // This count has a deeper base path, but shares terminals with the + // first and third counts. Both shallower selections still own them. + let mut owners_of_b = bound( + &feed, + "repost", + SubQueryKind::Count, + BindingSource::Page, + "$ownerId", + "$ownerId", + None, + ); + owners_of_b.where_clauses.push(WhereClause { + field: "postId".into(), + operator: WhereOperator::Equal, + value: Value::Identifier(POST_B), + }); + sub_queries.push(owners_of_b); + let query = DriveCompositeDocumentQuery { + page: page_by_hashtag(&feed, "dash", Some(10)), + sub_queries, + }; + let materialized = drive + .query_composite_documents(&query, None, None, pv) + .expect("materializes") + .result; + let (proof, _) = drive + .query_composite_documents_with_proof(&query, pv) + .expect("proves"); + let (_, verified) = query + .verify_composite_documents_proof(&proof, pv) + .expect("verifies"); + for result in [&materialized, &verified] { + for (index, post) in [POST_B, POST_A, POST_B].into_iter().enumerate() { + assert_eq!(result.sub_results[index].counts().len(), 1); + assert_eq!( + counts(&result.sub_results[index]), + BTreeMap::from([(post, 1)]) + ); + } + assert_eq!(result.sub_results[3].counts().len(), 2); + assert_eq!( + counts(&result.sub_results[3]), + BTreeMap::from([(OWNER_1, 1), (OWNER_3, 1)]) + ); + } + assert_eq!(verified.sub_results, materialized.sub_results); +} + +#[test] +fn should_reject_conflicting_document_directions_even_when_the_page_is_empty() { + let (drive, feed, dashpay) = setup(); + seed_feed(&drive, &feed, &dashpay); + let pv = platform_version(); + let mut lookup = bound( + &feed, + "repost", + SubQueryKind::Documents, + BindingSource::Page, + "$id", + "postId", + Some(1), + ); + lookup.order_by.push(OrderClause { + field: "postId".into(), + ascending: false, + }); + let mut profiles = bound( + &dashpay, + "profile", + SubQueryKind::Documents, + BindingSource::Page, + "$ownerId", + "$ownerId", + None, + ); + profiles.order_by.push(OrderClause { + field: "$ownerId".into(), + ascending: false, + }); + let sibling = DriveSubQuery { + contract: &feed, + document_type: feed.document_type_for_name("post").expect("post"), + kind: SubQueryKind::Documents, + where_clauses: vec![], + order_by: vec![OrderClause { + field: "$id".into(), + ascending: false, + }], + limit: Some(1), + binding: None, + }; + for sub_query in [lookup, profiles, sibling] { + for hashtag in ["dash", "empty"] { + let query = DriveCompositeDocumentQuery { + page: page_by_hashtag(&feed, hashtag, Some(10)), + sub_queries: vec![sub_query.clone()], + }; + for result in [ + drive + .query_composite_documents(&query, None, None, pv) + .map(|_| ()), + drive + .query_composite_documents_with_proof(&query, pv) + .map(|_| ()), + query.verify_composite_documents_proof(&[], pv).map(|_| ()), + ] { + assert!(matches!(result, Err(Error::Query(_))), "{result:?}"); + assert!(result.unwrap_err().to_string().contains("outer ordering")); + } + } + } +} + +#[test] +fn should_reject_count_tree_descents_but_allow_disjoint_count_selections() { + let (drive, feed, dashpay) = setup(); + seed_feed(&drive, &feed, &dashpay); + insert_repost(&drive, &feed, OWNER_3, POST_D, 23); + let pv = platform_version(); + let total = bound( + &feed, + "repost", + SubQueryKind::Count, + BindingSource::Page, + "$id", + "postId", + None, + ); + let mut per_owner = total.clone(); + per_owner.where_clauses.push(WhereClause { + field: "$ownerId".into(), + operator: WhereOperator::Equal, + value: Value::Identifier(OWNER_1), + }); + let mut query = DriveCompositeDocumentQuery { + page: page_by_hashtag(&feed, "dash", Some(10)), + sub_queries: vec![total, per_owner], + }; + for result in [ + drive + .query_composite_documents(&query, None, None, pv) + .map(|_| ()), + drive + .query_composite_documents_with_proof(&query, pv) + .map(|_| ()), + ] { + assert!(matches!(result, Err(Error::Query(_))), "{result:?}"); + let error = result.unwrap_err().to_string(); + assert!(error.contains("another component descends"), "{error}"); + } + + // The total now selects D's count tree, while the per-owner query + // descends through A/B/C. Their actual selections do not overlap. + query.sub_queries[0] + .binding + .as_mut() + .expect("bound") + .source_property = "quotedPostId".into(); + let materialized = drive + .query_composite_documents(&query, None, None, pv) + .expect("disjoint counts materialize") + .result; + let (proof, _) = drive + .query_composite_documents_with_proof(&query, pv) + .expect("disjoint counts prove"); + let (_, verified) = query + .verify_composite_documents_proof(&proof, pv) + .expect("disjoint counts verify"); + assert_eq!( + counts(&verified.sub_results[0]), + BTreeMap::from([(POST_D, 1)]) + ); + assert_eq!( + counts(&verified.sub_results[1]), + BTreeMap::from([(POST_B, 1)]) + ); + assert_eq!(verified.sub_results, materialized.sub_results); +} + +#[test] +fn should_preserve_descending_documents_and_key_ordered_counts() { + let (drive, feed, dashpay) = setup(); + seed_feed(&drive, &feed, &dashpay); + let pv = platform_version(); + let mut page = page_by_hashtag(&feed, "dash", Some(3)); + page.internal_clauses = InternalClauses::extract_from_clauses( + vec![WhereClause { + field: "$id".into(), + operator: WhereOperator::In, + value: Value::Array(vec![ + Value::Identifier(POST_A), + Value::Identifier(POST_B), + Value::Identifier(POST_C), + ]), + }], + pv, + ) + .expect("by-id page"); + page.order_by.insert( + "$id".into(), + OrderClause { + field: "$id".into(), + ascending: false, + }, + ); + // Keep the sibling on another type's primary tree so its limited + // branch cannot overlap the page or the by-id join. + let sibling = DriveSubQuery { + contract: &feed, + document_type: feed.document_type_for_name("repost").expect("repost"), + kind: SubQueryKind::Documents, + where_clauses: vec![], + order_by: vec![OrderClause { + field: "$id".into(), + ascending: false, + }], + limit: Some(1), + binding: None, + }; + let mut sub_queries = vec![ + bound( + &feed, + "post", + SubQueryKind::Documents, + BindingSource::Page, + "quotedPostId", + "$id", + None, + ), + bound( + &feed, + "like", + SubQueryKind::Count, + BindingSource::Page, + "$id", + "postId", + None, + ), + sibling, + ]; + // A later count also verifies that a descending sibling is a valid + // binding source and its limit is applied before deriving values. + sub_queries.push(bound( + &feed, + "like", + SubQueryKind::Count, + BindingSource::SubQuery(2), + "postId", + "postId", + None, + )); + let query = DriveCompositeDocumentQuery { page, sub_queries }; + let materialized = drive + .query_composite_documents(&query, None, None, pv) + .expect("materializes") + .result; + assert_eq!( + ids(&materialized.page_documents), + vec![POST_C, POST_B, POST_A] + ); + assert_eq!(materialized.sub_results[2].documents().len(), 1); + let (proof, _) = drive + .query_composite_documents_with_proof(&query, pv) + .expect("proves"); + let (_, verified) = query + .verify_composite_documents_proof(&proof, pv) + .expect("verifies"); + assert_eq!(verified.page_documents, materialized.page_documents); + assert_eq!(verified.sub_results, materialized.sub_results); + // The count bound to the sibling derived exactly the one post the + // sibling's limit left it: A carries two likes, B one. + let sibling_posts = post_ids_of(&verified.sub_results[2], "postId"); + assert_eq!(sibling_posts.len(), 1); + let expected_likes = if sibling_posts[0] == POST_A { 2 } else { 1 }; + assert_eq!( + counts(&verified.sub_results[3]), + BTreeMap::from([(sibling_posts[0], expected_likes)]), + "the sibling-bound count covers the sibling's single derived post" + ); +} + +/// The full round trip: the server's materialized result and the +/// verifier's composed result agree component for component. +#[test] +fn should_answer_the_feed_composition_with_proof_parity() { + let (drive, feed, dashpay) = setup(); + seed_feed(&drive, &feed, &dashpay); + let pv = platform_version(); + let query = feed_query(&feed, &dashpay, None); + + let materialized = drive + .query_composite_documents(&query, None, None, pv) + .expect("no-proof composite executes") + .result; + let (proof, page) = drive + .query_composite_documents_with_proof(&query, pv) + .expect("composite proves"); + let (_root, verified) = query + .verify_composite_documents_proof(&proof, pv) + .expect("the merged proof verifies"); + + // The page: the three `dash` posts, in index order. + assert_eq!( + ids(&materialized.page_documents), + vec![POST_A, POST_B, POST_C] + ); + assert_eq!(ids(&page), vec![POST_A, POST_B, POST_C]); + assert_eq!(verified.page_documents, materialized.page_documents); + + for result in [&materialized, &verified] { + assert_eq!( + counts(&result.sub_results[LIKE_COUNTS]), + BTreeMap::from([(POST_A, 2), (POST_B, 1)]), + "C has no like tree and D is off the page" + ); + assert_eq!( + ids(result.sub_results[QUOTED_POSTS].documents()), + vec![POST_D], + "A and C both quote D: one derived id, one document" + ); + assert_eq!( + post_ids_of(&result.sub_results[REPOSTS], "postId"), + vec![POST_A, POST_B, POST_B] + ); + assert_eq!( + owner_ids(result.sub_results[AUTHOR_PROFILES].documents()), + vec![OWNER_1, OWNER_3], + "owner 2 has no profile: a proven absence, not an error" + ); + assert_eq!( + owner_ids(result.sub_results[QUOTED_AUTHOR_PROFILES].documents()), + vec![OWNER_3], + "derived from the quoted-posts sub-query, not the page" + ); + } + assert_eq!(verified.sub_results, materialized.sub_results); +} + +/// The viewer's own likes ride the same proof as an indexOnly lookup +/// pinned on `$ownerId`: the synthesized projections carry the post ids +/// the viewer liked among the page. +#[test] +fn should_prove_the_viewers_marks_as_an_index_only_lookup() { + let (drive, feed, dashpay) = setup(); + seed_feed(&drive, &feed, &dashpay); + let pv = platform_version(); + let query = feed_query(&feed, &dashpay, Some(OWNER_1)); + + let (proof, _) = drive + .query_composite_documents_with_proof(&query, pv) + .expect("composite proves"); + let (_root, verified) = query + .verify_composite_documents_proof(&proof, pv) + .expect("the merged proof verifies"); + + assert_eq!( + post_ids_of(&verified.sub_results[VIEWER_LIKES], "postId"), + vec![POST_A, POST_B] + ); + assert!(verified.sub_results[VIEWER_LIKES] + .documents() + .iter() + .all(|like| like.owner_id().to_buffer() == OWNER_1)); + + let query = feed_query(&feed, &dashpay, Some(OWNER_2)); + let (proof, _) = drive + .query_composite_documents_with_proof(&query, pv) + .expect("composite proves"); + let (_root, verified) = query + .verify_composite_documents_proof(&proof, pv) + .expect("verifies"); + assert_eq!( + post_ids_of(&verified.sub_results[VIEWER_LIKES], "postId"), + vec![POST_A] + ); +} + +/// An empty page derives nothing: every sub-query is empty and the proof +/// is the page's alone. +#[test] +fn should_prove_an_empty_page_alone() { + let (drive, feed, dashpay) = setup(); + seed_feed(&drive, &feed, &dashpay); + let pv = platform_version(); + let mut query = feed_query(&feed, &dashpay, Some(OWNER_1)); + query.page = page_by_hashtag(&feed, "nothing", Some(10)); + + let materialized = drive + .query_composite_documents(&query, None, None, pv) + .expect("executes") + .result; + assert!(materialized.page_documents.is_empty()); + assert!(materialized + .sub_results + .iter() + .all(|result| result.documents().is_empty() && result.counts().is_empty())); + + let (proof, page) = drive + .query_composite_documents_with_proof(&query, pv) + .expect("proves"); + assert!(page.is_empty()); + let (_root, verified) = query + .verify_composite_documents_proof(&proof, pv) + .expect("verifies"); + assert!(verified.page_documents.is_empty()); + assert_eq!(verified.sub_results.len(), query.sub_queries.len()); +} + +/// A proof covering only the page — what a node that ignores the +/// sub-queries would serve — cannot satisfy the merged query. +#[test] +fn should_refuse_a_page_only_proof() { + let (drive, feed, dashpay) = setup(); + seed_feed(&drive, &feed, &dashpay); + let pv = platform_version(); + let query = feed_query(&feed, &dashpay, None); + + let (page_only_proof, _cost) = query + .page + .clone() + .execute_with_proof(&drive, None, None, pv) + .expect("the page alone proves"); + assert!( + query + .verify_composite_documents_proof(&page_only_proof, pv) + .is_err(), + "a page-only proof must fail the composite verification" + ); +} + +/// A by-id join whose derived id has no document is an invalid proof +/// (and corrupted state on the server): a permanentDocument reference +/// cannot dangle. +#[test] +fn should_refuse_a_dangling_reference() { + let (drive, feed, _dashpay) = setup(); + insert_post( + &drive, + &feed, + POST_A, + OWNER_1, + "dash", + Some(MISSING_POST), + 1, + ); + let pv = platform_version(); + let query = DriveCompositeDocumentQuery { + page: page_by_hashtag(&feed, "dash", Some(10)), + sub_queries: vec![bound( + &feed, + "post", + SubQueryKind::Documents, + BindingSource::Page, + "quotedPostId", + "$id", + None, + )], + }; + + let refused = drive.query_composite_documents(&query, None, None, pv); + assert!( + matches!(refused, Err(Error::Proof(_))), + "expected the missing-document refusal, got {refused:?}" + ); + let (proof, _) = drive + .query_composite_documents_with_proof(&query, pv) + .expect("the proof itself generates"); + assert!( + query.verify_composite_documents_proof(&proof, pv).is_err(), + "the verifier must refuse a dangling reference" + ); +} + +/// When the page is itself a by-ids fetch and a join targets the same +/// type, both land in the primary tree: the page keeps its own ids, the +/// join keeps the derived ones. +#[test] +fn should_tell_a_by_ids_page_from_a_join_on_the_same_type() { + let (drive, feed, dashpay) = setup(); + seed_feed(&drive, &feed, &dashpay); + let pv = platform_version(); + let post_type = feed.document_type_for_name("post").expect("post"); + let page = DriveDocumentQuery { + contract: &feed, + document_type: post_type, + internal_clauses: InternalClauses { + primary_key_in_clause: Some(WhereClause { + field: "$id".to_string(), + operator: WhereOperator::In, + value: Value::Array(vec![Value::Identifier(POST_A), Value::Identifier(POST_B)]), + }), + primary_key_equal_clause: None, + in_clauses: vec![], + range_clause: None, + equal_clauses: Default::default(), + }, + offset: None, + limit: Some(2), + order_by: Default::default(), + start_at: None, + start_at_included: false, + block_time_ms: None, + resolved_time_ranges: vec![], + }; + let query = DriveCompositeDocumentQuery { + page, + sub_queries: vec![bound( + &feed, + "post", + SubQueryKind::Documents, + BindingSource::Page, + "quotedPostId", + "$id", + None, + )], + }; + + let materialized = drive + .query_composite_documents(&query, None, None, pv) + .expect("executes") + .result; + let (proof, _) = drive + .query_composite_documents_with_proof(&query, pv) + .expect("proves"); + let (_root, verified) = query + .verify_composite_documents_proof(&proof, pv) + .expect("verifies"); + for result in [&materialized, &verified] { + assert_eq!(ids(&result.page_documents), vec![POST_A, POST_B]); + assert_eq!(ids(result.sub_results[0].documents()), vec![POST_D]); + } +} + +#[test] +fn should_reject_invalid_composite_shapes() { + let (drive, feed, dashpay) = setup(); + let pv = platform_version(); + let base = feed_query(&feed, &dashpay, Some(OWNER_1)); + let expect_unsupported = |query: DriveCompositeDocumentQuery, what: &str| { + let result = drive.query_composite_documents(&query, None, None, pv); + assert!( + matches!(result, Err(Error::Query(_))), + "{what}: expected a query rejection, got {result:?}" + ); + }; + + let mut no_limit = base.clone(); + no_limit.page.limit = None; + expect_unsupported(no_limit, "page without a limit"); + + let mut oversized = base.clone(); + oversized.page.limit = Some(101); + expect_unsupported(oversized, "page limit above the bound-value cap"); + + let mut none = base.clone(); + none.sub_queries.clear(); + expect_unsupported(none, "no sub-queries"); + + let mut too_many = base.clone(); + let extra = too_many.sub_queries[LIKE_COUNTS].clone(); + while too_many.sub_queries.len() <= MAX_SUB_QUERIES { + too_many.sub_queries.push(extra.clone()); + } + expect_unsupported(too_many, "more sub-queries than the cap"); + + let mut counted_with_limit = base.clone(); + counted_with_limit.sub_queries[LIKE_COUNTS].limit = Some(5); + expect_unsupported(counted_with_limit, "count with a limit"); + + let mut unbound_count = base.clone(); + unbound_count.sub_queries[LIKE_COUNTS].binding = None; + expect_unsupported(unbound_count, "unbound count"); + + let mut join_without_reference = base.clone(); + join_without_reference.sub_queries[QUOTED_POSTS] + .binding + .as_mut() + .expect("bound") + .source_property = "$ownerId".to_string(); + expect_unsupported( + join_without_reference, + "by-id join from a non-refersTo source", + ); + + let mut join_with_limit = base.clone(); + join_with_limit.sub_queries[QUOTED_POSTS].limit = Some(5); + expect_unsupported(join_with_limit, "by-id join with a limit"); + + let mut filtered_join = base.clone(); + filtered_join.sub_queries[QUOTED_POSTS] + .where_clauses + .push(WhereClause { + field: "hashtag".to_string(), + operator: WhereOperator::Equal, + value: Value::Text("dash".to_string()), + }); + for result in [ + filtered_join + .sub_query_document_query( + &filtered_join.sub_queries[QUOTED_POSTS], + &[Identifier::from(POST_D)], + pv, + ) + .map(|_| ()), + drive + .query_composite_documents(&filtered_join, None, None, pv) + .map(|_| ()), + drive + .query_composite_documents_with_proof(&filtered_join, pv) + .map(|_| ()), + filtered_join + .verify_composite_documents_proof(&[], pv) + .map(|_| ()), + ] { + assert!(matches!(result, Err(Error::Query(_))), "{result:?}"); + assert!(result.unwrap_err().to_string().contains("no fixed clauses")); + } + + let mut lookup_without_limit = base.clone(); + lookup_without_limit.sub_queries[REPOSTS].limit = None; + expect_unsupported(lookup_without_limit, "non-unique lookup without a limit"); + + let mut bounded_lookup_with_limit = base.clone(); + bounded_lookup_with_limit.sub_queries[AUTHOR_PROFILES].limit = Some(20); + expect_unsupported( + bounded_lookup_with_limit, + "value-bounded lookup with a limit", + ); + + let mut forward_binding = base.clone(); + forward_binding.sub_queries[LIKE_COUNTS] + .binding + .as_mut() + .expect("bound") + .source = BindingSource::SubQuery(QUOTED_POSTS); + expect_unsupported(forward_binding, "binding to a later sub-query"); + + let mut bound_to_a_count = base.clone(); + bound_to_a_count.sub_queries[QUOTED_AUTHOR_PROFILES] + .binding + .as_mut() + .expect("bound") + .source = BindingSource::SubQuery(LIKE_COUNTS); + expect_unsupported(bound_to_a_count, "binding to a count sub-query"); + + let mut fixed_on_bound_field = base.clone(); + fixed_on_bound_field.sub_queries[REPOSTS] + .where_clauses + .push(WhereClause { + field: "postId".to_string(), + operator: WhereOperator::Equal, + value: Value::Identifier(POST_A), + }); + expect_unsupported(fixed_on_bound_field, "fixed clause on the bound field"); + + let mut unknown_property = base.clone(); + unknown_property.sub_queries[LIKE_COUNTS] + .binding + .as_mut() + .expect("bound") + .source_property = "nope".to_string(); + expect_unsupported(unknown_property, "unknown source property"); + + let mut non_identifier_property = base.clone(); + non_identifier_property.sub_queries[LIKE_COUNTS] + .binding + .as_mut() + .expect("bound") + .source_property = "hashtag".to_string(); + expect_unsupported(non_identifier_property, "non-identifier source property"); + + let mut ordered_join = base.clone(); + ordered_join.sub_queries[QUOTED_POSTS].order_by = vec![OrderClause { + field: "hashtag".to_string(), + ascending: true, + }]; + expect_unsupported(ordered_join, "ordered by-id join"); + + let mut count_on_a_looked_up_index = base.clone(); + count_on_a_looked_up_index.sub_queries.push(bound( + &feed, + "repost", + SubQueryKind::Count, + BindingSource::Page, + "$id", + "postId", + None, + )); + expect_unsupported( + count_on_a_looked_up_index, + "count on the index a documents lookup reads through", + ); +} + +#[test] +fn should_check_count_and_document_descents_against_the_actual_bound_values() { + let (drive, feed, dashpay) = setup(); + seed_feed(&drive, &feed, &dashpay); + insert_repost(&drive, &feed, OWNER_3, POST_D, 23); + let pv = platform_version(); + let mut query = DriveCompositeDocumentQuery { + page: page_by_hashtag(&feed, "dash", Some(10)), + sub_queries: vec![ + bound( + &feed, + "repost", + SubQueryKind::Count, + BindingSource::Page, + "$id", + "postId", + None, + ), + DriveSubQuery { + contract: &feed, + document_type: feed.document_type_for_name("repost").expect("repost"), + kind: SubQueryKind::Documents, + where_clauses: vec![WhereClause { + field: "postId".into(), + operator: WhereOperator::Equal, + value: Value::Identifier(POST_B), + }], + order_by: vec![], + limit: None, + binding: Some(SubQueryBinding { + source: BindingSource::Page, + source_property: "$ownerId".into(), + field: "$ownerId".into(), + }), + }, + ], + }; + for result in [ + drive + .query_composite_documents(&query, None, None, pv) + .map(|_| ()), + drive + .query_composite_documents_with_proof(&query, pv) + .map(|_| ()), + ] { + assert!(matches!(result, Err(Error::Query(_))), "{result:?}"); + let error = result.unwrap_err().to_string(); + assert!(error.contains("another component descends"), "{error}"); + } + + // Moving the document selection to D leaves the A/B/C count trees + // untouched, although the base paths still nest on the same index. + query.sub_queries[1].where_clauses[0].value = Value::Identifier(POST_D); + let materialized = drive + .query_composite_documents(&query, None, None, pv) + .expect("disjoint counts and documents materialize") + .result; + let (proof, _) = drive + .query_composite_documents_with_proof(&query, pv) + .expect("disjoint counts and documents prove"); + let (_, verified) = query + .verify_composite_documents_proof(&proof, pv) + .expect("disjoint counts and documents verify"); + assert_eq!( + counts(&verified.sub_results[0]), + BTreeMap::from([(POST_A, 1), (POST_B, 2)]) + ); + assert_eq!( + post_ids_of(&verified.sub_results[1], "postId"), + vec![POST_D] + ); + assert_eq!(verified.sub_results, materialized.sub_results); +} + +/// A minimal request never conflicts with the page's direction: a +/// documents sub-query the caller left unordered on its bound field walks +/// the page's way, so a descending page with default lookups merges and +/// verifies, while an explicit ordering that disagrees is still refused. +#[test] +fn should_inherit_the_page_direction_for_unordered_lookups() { + let (drive, feed, dashpay) = setup(); + seed_feed(&drive, &feed, &dashpay); + let pv = platform_version(); + let descending_page = || { + let mut page = page_by_hashtag(&feed, "dash", Some(3)); + page.internal_clauses = InternalClauses::extract_from_clauses( + vec![WhereClause { + field: "$id".into(), + operator: WhereOperator::In, + value: Value::Array(vec![ + Value::Identifier(POST_A), + Value::Identifier(POST_B), + Value::Identifier(POST_C), + ]), + }], + pv, + ) + .expect("by-id page"); + page.order_by.insert( + "$id".into(), + OrderClause { + field: "$id".into(), + ascending: false, + }, + ); + page + }; + let like_counts = || { + bound( + &feed, + "like", + SubQueryKind::Count, + BindingSource::Page, + "$id", + "postId", + None, + ) + }; + let round_trip = |query: &DriveCompositeDocumentQuery, what: &str| { + let materialized = drive + .query_composite_documents(query, None, None, pv) + .unwrap_or_else(|e| panic!("{what} materializes: {e}")) + .result; + assert_eq!( + ids(&materialized.page_documents), + vec![POST_C, POST_B, POST_A] + ); + let (proof, _) = drive + .query_composite_documents_with_proof(query, pv) + .unwrap_or_else(|e| panic!("{what} proves: {e}")); + let (_, verified) = query + .verify_composite_documents_proof(&proof, pv) + .unwrap_or_else(|e| panic!("{what} verifies: {e}")); + assert_eq!(verified.page_documents, materialized.page_documents); + assert_eq!(verified.sub_results, materialized.sub_results); + materialized + }; + + // The feed shape: cross-contract profiles, the viewer's marks (both + // value-bounded) and a count, none of them ordered by the caller. + let mut viewer_likes = bound( + &feed, + "like", + SubQueryKind::Documents, + BindingSource::Page, + "$id", + "postId", + None, + ); + viewer_likes.where_clauses = vec![WhereClause { + field: "$ownerId".into(), + operator: WhereOperator::Equal, + value: Value::Identifier(OWNER_1), + }]; + let feed_shape = DriveCompositeDocumentQuery { + page: descending_page(), + sub_queries: vec![ + bound( + &dashpay, + "profile", + SubQueryKind::Documents, + BindingSource::Page, + "$ownerId", + "$ownerId", + None, + ), + viewer_likes, + like_counts(), + ], + }; + let result = round_trip(&feed_shape, "the descending feed shape"); + // The lookups inherited the page's direction: descending by their + // bound field. + assert_eq!( + owner_ids(result.sub_results[0].documents()), + vec![OWNER_3, OWNER_1], + "profiles walk owners descending" + ); + assert_eq!( + post_ids_of(&result.sub_results[1], "postId"), + vec![POST_B, POST_A], + "the viewer's likes, posts descending" + ); + assert_eq!( + counts(&result.sub_results[2]), + BTreeMap::from([(POST_A, 2), (POST_B, 1)]) + ); + + // A limited lookup under the page's own contract. Its limit caps the + // rows it returns in total, in walk order, like an ordinary `IN` + // query's: walking posts descending, the one row is B's. + let limited_lookup = DriveCompositeDocumentQuery { + page: descending_page(), + sub_queries: vec![ + bound( + &feed, + "repost", + SubQueryKind::Documents, + BindingSource::Page, + "$id", + "postId", + Some(1), + ), + like_counts(), + ], + }; + let result = round_trip(&limited_lookup, "the limited lookup"); + assert_eq!( + post_ids_of(&result.sub_results[0], "postId"), + vec![POST_B], + "the single repost row comes from the highest post id" + ); + + // Both at once: the cross-contract lookup lifts the merged root to the + // tree root, so the page's contract becomes a synthesized split that + // the limited lookup descends into. grovedb #851 gives that split the + // inputs' direction; before it, this descending composition was + // refused while its ascending twin merged. + let combined = DriveCompositeDocumentQuery { + page: descending_page(), + sub_queries: vec![ + bound( + &dashpay, + "profile", + SubQueryKind::Documents, + BindingSource::Page, + "$ownerId", + "$ownerId", + None, + ), + bound( + &feed, + "repost", + SubQueryKind::Documents, + BindingSource::Page, + "$id", + "postId", + Some(1), + ), + like_counts(), + ], + }; + let result = round_trip(&combined, "the cross-contract shape with a limited lookup"); + assert_eq!( + owner_ids(result.sub_results[0].documents()), + vec![OWNER_3, OWNER_1] + ); + assert_eq!(post_ids_of(&result.sub_results[1], "postId"), vec![POST_B]); + + // An explicit ordering that disagrees with the page is still refused, + // on every entry point. + let mut conflicting = limited_lookup.clone(); + conflicting.sub_queries[0].order_by.push(OrderClause { + field: "postId".into(), + ascending: true, + }); + for result in [ + drive + .query_composite_documents(&conflicting, None, None, pv) + .map(|_| ()), + drive + .query_composite_documents_with_proof(&conflicting, pv) + .map(|_| ()), + conflicting + .verify_composite_documents_proof(&[], pv) + .map(|_| ()), + ] { + assert!(matches!(result, Err(Error::Query(_))), "{result:?}"); + assert!(result.unwrap_err().to_string().contains("outer ordering")); + } +} 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 e6ecb106050..a28ac2c257e 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 @@ -27,6 +27,7 @@ //! suite's fixture and assertion helpers. mod chained_query_e2e_tests; +mod composite_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 72b5dcab3e5..a34f54d0dba 100644 --- a/packages/rs-drive/src/drive/document/query/mod.rs +++ b/packages/rs-drive/src/drive/document/query/mod.rs @@ -5,6 +5,7 @@ mod fetch_document_history_query; mod query_chained_documents; +mod query_composite_documents; /// query of the vote state pub mod query_contested_documents_vote_state; mod query_documents; @@ -14,6 +15,7 @@ mod query_documents_with_flags; pub mod query_contested_documents_storage; pub use query_chained_documents::*; +pub use query_composite_documents::*; pub use query_documents::*; pub use query_documents_with_flags::*; diff --git a/packages/rs-drive/src/drive/document/query/query_composite_documents/mod.rs b/packages/rs-drive/src/drive/document/query/query_composite_documents/mod.rs new file mode 100644 index 00000000000..ad69de31400 --- /dev/null +++ b/packages/rs-drive/src/drive/document/query/query_composite_documents/mod.rs @@ -0,0 +1,74 @@ +mod v0; + +use crate::drive::Drive; +use crate::error::drive::DriveError; +use crate::error::Error; +use crate::query::drive_composite_document_query::DriveCompositeDocumentQuery; +use dpp::block::epoch::Epoch; +use dpp::document::Document; +use dpp::version::PlatformVersion; +use grovedb::TransactionArg; + +pub use v0::QueryCompositeDocumentsOutcomeV0; + +impl Drive { + /// Executes a composite document query (a page plus its derived + /// sub-queries) without proofs and returns the materialized results + /// plus the processing cost (when an epoch is given). + pub fn query_composite_documents( + &self, + query: &DriveCompositeDocumentQuery, + epoch: Option<&Epoch>, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result { + match platform_version + .drive + .methods + .document + .query + .query_composite_documents + { + 0 => self.query_composite_documents_v0(query, epoch, transaction, platform_version), + version => Err(Error::Drive(DriveError::UnknownVersionMismatch { + method: "query_composite_documents".to_string(), + known_versions: vec![0], + received: version, + })), + } + } + + /// Executes a composite document query AND generates its single + /// merged proof: the page and every derived sub-query 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 + /// [`DriveCompositeDocumentQuery::execute_with_proof_internal`]. + /// Shares the `query_composite_documents` version slot with the + /// no-proof path (one surface, one version). + /// + /// Returns the merged proof plus the materialized page (the + /// caller's pagination cursor derives from it); the sub-query + /// results are covered by the proof and not materialized twice. + pub fn query_composite_documents_with_proof( + &self, + query: &DriveCompositeDocumentQuery, + platform_version: &PlatformVersion, + ) -> Result<(Vec, Vec), Error> { + match platform_version + .drive + .methods + .document + .query + .query_composite_documents + { + 0 => self.query_composite_documents_with_proof_v0(query, platform_version), + version => Err(Error::Drive(DriveError::UnknownVersionMismatch { + method: "query_composite_documents_with_proof".to_string(), + known_versions: vec![0], + received: version, + })), + } + } +} diff --git a/packages/rs-drive/src/drive/document/query/query_composite_documents/v0/mod.rs b/packages/rs-drive/src/drive/document/query/query_composite_documents/v0/mod.rs new file mode 100644 index 00000000000..e1e43a04027 --- /dev/null +++ b/packages/rs-drive/src/drive/document/query/query_composite_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_composite_document_query::{ + CompositeDocumentsResult, DriveCompositeDocumentQuery, +}; +use dpp::block::epoch::Epoch; +use dpp::version::PlatformVersion; +use grovedb::TransactionArg; + +/// The outcome of a composite document query: the materialized results +/// and the processing cost. +#[derive(Debug, Default)] +pub struct QueryCompositeDocumentsOutcomeV0 { + /// The materialized page and sub-query results. + pub result: CompositeDocumentsResult, + /// The processing cost, when an epoch was given. + pub cost: u64, +} + +impl Drive { + #[inline(always)] + pub(super) fn query_composite_documents_v0( + &self, + query: &DriveCompositeDocumentQuery, + 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(QueryCompositeDocumentsOutcomeV0 { result, cost }) + } + + #[inline(always)] + pub(super) fn query_composite_documents_with_proof_v0( + &self, + query: &DriveCompositeDocumentQuery, + 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_composite_document_query/mod.rs b/packages/rs-drive/src/query/drive_composite_document_query/mod.rs new file mode 100644 index 00000000000..4a59d660317 --- /dev/null +++ b/packages/rs-drive/src/query/drive_composite_document_query/mod.rs @@ -0,0 +1,1803 @@ +//! Composite document queries: one page query plus sub-queries derived +//! from its proven results, answered as ONE merged grovedb proof. +//! +//! A feed is a page of posts and then, for that page, the things a card +//! renders: the referenced (quoted) posts, the per-post engagement +//! counts, the authors' profiles, the viewer's own likes. Each of those +//! is a query whose INPUT is the page — its ids, its owners, a +//! property's values — and asking for them one round trip at a time +//! turns a single feed into a burst of dependent calls. A composite +//! query carries the page and its sub-queries in one request and proves +//! them together: the server materializes the page, derives every +//! sub-query's `IN` clause from it (or from an earlier sub-query's +//! documents), and `prove_query_many` merges all the component path +//! queries into one proof over one state root. +//! +//! Soundness never rests on the server's derivation. The verifier +//! bootstraps the page (a subset pass against the merged proof), derives +//! every sub-query itself with the SAME builders the server ran, merges +//! the same way, and verifies the whole composition in one authoritative +//! pass; then it recomputes the derived values from the proven page and +//! refuses any divergence from the bootstrap, any result outside a +//! derived value set, and (for by-id joins on `refersTo: +//! permanentDocument` properties, which cannot dangle) any missing +//! referenced document. A node that ignores the sub-queries serves a +//! page-only proof, which cannot satisfy the merged query whenever a +//! sub-query derived anything — the composition fails closed. +//! +//! Three sub-query shapes, one binding rule: +//! +//! - **Documents by id** (`bind.field == "$id"`): the classic join. The +//! source property must declare `refersTo: permanentDocument` targeting +//! the sub-query's type, so every derived id MUST resolve — the result +//! is the referenced documents in first-appearance order, set-equal to +//! the derived ids. +//! - **Documents by an indexed property** (`bind.field` is `$ownerId` or +//! an indexed property): a lookup, `WHERE AND +//! IN `, with an explicit limit unless the values +//! already bound it (a unique index, or an indexOnly terminal with +//! every prefix fixed, yields at most one row per value). Absence is +//! inherent in the range proof (a value with no document simply +//! yields none), so profiles keyed by owner or reposts keyed by post +//! work without absence proofs, and the target may live in another +//! contract. +//! - **Count** by an indexed property: the grouped point-lookup count +//! `COUNT(*) WHERE AND IN +//! GROUP BY ` on a `countable` index — one entry per value that +//! has a count tree (zero-count trees are not materialized). +//! +//! A sub-query without a binding is a **sibling**: an independent +//! documents query proven under the same root (counts must be bound — +//! the aggregate and range count shapes have their own proof +//! primitives and stay on the regular count surface). +//! +//! Derived values are identifiers only (v1): the page's `$id`, its +//! `$ownerId`, or an identifier-typed property. The page limit is +//! required and capped at [`MAX_BOUND_VALUES`] (an `IN` clause admits at +//! most that many values); the page takes no cursor and no offset — +//! paginate with a range clause, exactly as chained queries do. + +use crate::error::drive::DriveError; +use crate::error::proof::ProofError; +use crate::error::query::QuerySyntaxError; +use crate::error::Error; +use crate::query::index_only_synthesis::synthesize_index_only_document; +use crate::query::{ + DriveDocumentCountQuery, DriveDocumentQuery, InternalClauses, OrderClause, SplitCountEntry, + 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::data_contract::DataContract; +use dpp::document::serialization_traits::DocumentPlatformConversionMethodsV0; +use dpp::document::{Document, DocumentV0Getters}; +use dpp::identifier::Identifier; +use dpp::platform_value::btreemap_extensions::BTreeValueMapPathHelper; +use dpp::platform_value::Value; +use dpp::version::PlatformVersion; +use grovedb::{Element, PathQuery}; +use std::collections::{BTreeMap, BTreeSet}; + +/// The most sub-queries one composite request carries. Every sub-query +/// is another branch of one merged proof; ten covers a feed card's +/// whole enrichment (quotes, four counts, reposts, profiles, names, +/// the viewer's marks) with room to spare. +pub const MAX_SUB_QUERIES: usize = 10; + +/// The most values one binding can derive: a derived `IN` clause admits +/// at most this many (`WhereClause::in_values`), so the page limit and +/// every sub-query limit that feeds a later binding are capped here. +pub const MAX_BOUND_VALUES: usize = 100; + +/// Where a sub-query's derived values come from. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BindingSource { + /// The page's proven documents. + Page, + /// An earlier documents sub-query's proven documents (its index in + /// [`DriveCompositeDocumentQuery::sub_queries`]). + SubQuery(usize), +} + +/// The derived clause of a sub-query: ` IN `, where the +/// values are read off the source's proven documents. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SubQueryBinding { + /// Whose documents supply the values. + pub source: BindingSource, + /// The source property read off each document: `$id`, `$ownerId`, + /// or an identifier-typed property (dotted paths reach nested + /// properties). Documents without the property contribute nothing. + pub source_property: String, + /// The sub-query field that receives the `IN` clause: `$id` for a + /// by-id join, otherwise `$ownerId` or an indexed property. + pub field: String, +} + +/// What a sub-query returns. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SubQueryKind { + /// The matching documents. + Documents, + /// One count per derived value, from the countable index covering + /// the fixed clauses plus the bound field. + Count, +} + +/// One sub-query of a composite request. +#[derive(Debug, Clone)] +pub struct DriveSubQuery<'a> { + /// The contract the sub-query targets — the page's, or another one. + pub contract: &'a DataContract, + /// The document type queried. + pub document_type: DocumentTypeRef<'a>, + /// Documents or counts. + pub kind: SubQueryKind, + /// The fixed clauses (everything but the derived `IN`), typed. + /// Must be empty for a by-id join, which resolves every derived id. + pub where_clauses: Vec, + /// Ordering; documents only. Every component of the merged proof + /// walks in the page's direction, so a documents sub-query must agree + /// with it: a bound field the caller did not order by is appended in + /// the page's direction (a minimal request never conflicts), and an + /// explicit ordering that disagrees is refused, because changing it + /// for the proof would change the rows its limit selects. + pub order_by: Vec, + /// Required for a documents lookup on a non-unique index: it caps the + /// rows the lookup returns in total, in walk order, exactly as the + /// limit of an ordinary `IN` query does (at most `MAX_BOUND_VALUES`). + /// Forbidden for a value-bounded lookup, a by-id join (completeness is + /// set-based) and a count. + pub limit: Option, + /// The derived clause, or `None` for a sibling. + pub binding: Option, +} + +/// A composite document query. +/// +/// Construction contract: every sub-query's `document_type` MUST be a +/// document type of its own `contract`. [`Self::validate`] enforces +/// everything derivable from the shapes themselves. +#[derive(Debug, Clone)] +pub struct DriveCompositeDocumentQuery<'a> { + /// The page: an ordinary document query with an explicit limit. + pub page: DriveDocumentQuery<'a>, + /// The sub-queries, in binding order (a sub-query may only bind an + /// earlier one). + pub sub_queries: Vec>, +} + +/// One sub-query's materialized result. +#[derive(Debug, Clone, PartialEq)] +pub enum SubQueryResult { + /// Documents: for a by-id join, in first-appearance order of their + /// ids among the source documents; otherwise in query order. + Documents(Vec), + /// Counts keyed by the bound value's index-key bytes (a 32-byte + /// identifier), one entry per value with a materialized count. + Counts(Vec), +} + +impl SubQueryResult { + /// The documents of a documents result, or an empty slice. + pub fn documents(&self) -> &[Document] { + match self { + Self::Documents(documents) => documents, + Self::Counts(_) => &[], + } + } + + /// The entries of a count result, or an empty slice. + pub fn counts(&self) -> &[SplitCountEntry] { + match self { + Self::Counts(entries) => entries, + Self::Documents(_) => &[], + } + } +} + +/// The materialized result of a composite query. +#[derive(Debug, Default)] +pub struct CompositeDocumentsResult { + /// The page, exactly as the page query alone would return it. + pub page_documents: Vec, + /// One result per sub-query, in request order. + pub sub_results: Vec, +} + +/// The values one binding derived, deduplicated to first appearance. +type DerivedValues = Vec; + +/// A `(path, key, element)` triple as grovedb's verifier reports it — +/// the element absent for a queried key that is not there. +pub(crate) type ProvedTrio = (Vec>, Vec, Option); + +/// A proved triple whose element is present. +pub(crate) type PresentTrio = (Vec>, Vec, Element); + +/// What the routing step decoded out of one path-query group's trios. +enum DecodedItems { + Documents(Vec), + Counts(Vec), +} + +/// A component of the merged proof: the page or one sub-query. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Component { + Page, + Sub(usize), +} + +fn unsupported(message: String) -> Error { + Error::Query(QuerySyntaxError::Unsupported(message)) +} + +fn corrupted_proof(message: String) -> Error { + Error::Proof(ProofError::CorruptedProof(message)) +} + +/// The bound identifier a document carries for `field`, or `None` when +/// the property is absent. +fn document_bound_value(document: &Document, field: &str) -> Result, Error> { + use dpp::document::property_names::{ID, OWNER_ID}; + if field == ID { + return Ok(Some(document.id())); + } + if field == OWNER_ID { + return Ok(Some(document.owner_id())); + } + let Some(value) = document + .properties() + .get_optional_at_path(field) + .ok() + .flatten() + else { + return Ok(None); + }; + value.to_identifier().map(Some).map_err(|_| { + Error::Drive(DriveError::CorruptedCodeExecution( + "a bound composite property must decode as an identifier: validate() only \ + admits identifier-typed properties", + )) + }) +} + +/// Canonical value order for a derived `IN` clause: byte-ascending, so +/// the built query — and therefore the proof — is byte-identical between +/// the server and a verifier that extracted the ids in any order. +fn sorted_values(values: &[Identifier]) -> Vec { + let mut sorted = values.to_vec(); + sorted.sort(); + sorted +} + +impl<'a> DriveSubQuery<'a> { + fn bound_field(&self) -> Option<&str> { + self.binding.as_ref().map(|binding| binding.field.as_str()) + } + + fn is_by_id_join(&self) -> bool { + self.bound_field() == Some(dpp::document::property_names::ID) + } +} + +impl<'a> DriveCompositeDocumentQuery<'a> { + /// Validates the composite shape. Called by the server before + /// executing and by the verifier before verifying, so an invalid + /// request fails identically on both sides. + pub fn validate(&self, platform_version: &PlatformVersion) -> Result<(), Error> { + if self.sub_queries.is_empty() { + return Err(unsupported( + "a composite query needs at least one sub-query; a page alone is a plain \ + documents query" + .to_string(), + )); + } + if self.sub_queries.len() > MAX_SUB_QUERIES { + return Err(unsupported(format!( + "a composite query carries at most {} sub-queries, got {}", + MAX_SUB_QUERIES, + self.sub_queries.len(), + ))); + } + match self.page.limit { + None => { + return Err(unsupported( + "composite queries require an explicit limit on the page: the page size \ + bounds every derived sub-query" + .to_string(), + )); + } + Some(limit) if limit as usize > MAX_BOUND_VALUES => { + return Err(unsupported(format!( + "a composite page limit of {} exceeds {}: a derived `IN` clause admits at \ + most that many values", + limit, MAX_BOUND_VALUES, + ))); + } + Some(_) => {} + } + if self.page.offset.is_some() { + return Err(unsupported( + "composite queries do not support a page offset; paginate with a range clause" + .to_string(), + )); + } + if self.page.start_at.is_some() { + return Err(unsupported( + "composite queries do not support a page cursor (startAt/startAfter); \ + paginate with a range clause on the page's ordering property" + .to_string(), + )); + } + // A by-ids page is proven without its limit (see + // `page_path_query`), so the limit must not be what bounds it. + if self.page_is_by_ids() { + let ids = self.page_ids()?.len(); + if (self.page.limit.unwrap_or(0) as usize) < ids { + return Err(unsupported(format!( + "a by-ids composite page addresses {} ids but its limit is {}: the ids \ + bound the page, so the limit must cover them", + ids, + self.page.limit.unwrap_or(0), + ))); + } + } + // The page must lower to a path query at all — an unindexed + // shape fails here, before any sub-query is inspected. + self.page_path_query(platform_version)?; + + for (index, sub_query) in self.sub_queries.iter().enumerate() { + self.validate_sub_query(index, sub_query, platform_version)?; + } + self.validate_component_paths(platform_version) + } + + fn validate_sub_query( + &self, + index: usize, + sub_query: &DriveSubQuery<'a>, + platform_version: &PlatformVersion, + ) -> Result<(), Error> { + let label = |message: &str| unsupported(format!("sub-query {}: {}", index, message)); + + let Some(binding) = &sub_query.binding else { + // A sibling: an independent documents query. + if sub_query.kind == SubQueryKind::Count { + return Err(label( + "a count sub-query must be bound (`COUNT ... WHERE IN GROUP BY `); unbound counts stay on the regular count \ + surface", + )); + } + match sub_query.limit { + None => { + return Err(label( + "a sibling documents sub-query requires an explicit limit", + )); + } + Some(limit) if limit as usize > MAX_BOUND_VALUES => { + return Err(label(&format!( + "limit {} exceeds {}", + limit, MAX_BOUND_VALUES + ))); + } + Some(_) => {} + } + // Must lower to a path query. + self.sub_query_document_query(sub_query, &[], platform_version)? + .construct_path_query(None, platform_version)?; + return Ok(()); + }; + + // The source must precede this sub-query and produce documents. + let (source_contract, source_type, source_is_index_only_query) = match binding.source { + BindingSource::Page => ( + self.page.contract, + self.page.document_type, + self.page.document_type.index_only(), + ), + BindingSource::SubQuery(source_index) => { + if source_index >= index { + return Err(label("a binding may only reference an earlier sub-query")); + } + let source = &self.sub_queries[source_index]; + if source.kind != SubQueryKind::Documents { + return Err(label("a binding must reference a documents sub-query")); + } + ( + source.contract, + source.document_type, + source.document_type.index_only(), + ) + } + }; + + // The source property: a system identifier or an identifier-typed + // property of the source type. + let source_property_type: Option<&DocumentPropertyType> = { + use dpp::document::property_names::{ID, OWNER_ID}; + if binding.source_property == ID || binding.source_property == OWNER_ID { + None + } else { + let Some(property) = source_type + .flattened_properties() + .get(binding.source_property.as_str()) + else { + return Err(label(&format!( + "source property \"{}\" does not name a property of \"{}\"", + binding.source_property, + source_type.name(), + ))); + }; + if !matches!( + property.property_type, + DocumentPropertyType::Identifier + | DocumentPropertyType::IdentifierWithReference(_) + ) { + return Err(label(&format!( + "source property \"{}\" is not identifier-typed; composite bindings \ + derive identifiers only", + binding.source_property, + ))); + } + Some(&property.property_type) + } + }; + + // An indexOnly source proves only what its resolved index + // carries, so the property must sit on that index. + if source_is_index_only_query { + let carries = |index: &dpp::data_contract::document_type::Index| { + index.terminal.as_deref() == Some(binding.source_property.as_str()) + || index + .properties + .iter() + .any(|property| property.name == binding.source_property) + }; + let (carried, index_name) = match binding.source { + BindingSource::Page => { + let index = self.page.index_only_query_index(platform_version)?; + (carries(index), index.name.clone()) + } + BindingSource::SubQuery(source_index) => { + let source = &self.sub_queries[source_index]; + let shape = self.sub_query_document_query( + source, + &[Identifier::default()], + platform_version, + )?; + let index = shape.index_only_query_index(platform_version)?; + (carries(index), index.name.clone()) + } + }; + if !carried { + return Err(label(&format!( + "the indexOnly source resolves to index \"{}\", which does not carry the \ + source property \"{}\"", + index_name, binding.source_property, + ))); + } + } + + if sub_query + .where_clauses + .iter() + .any(|clause| clause.field == binding.field) + { + return Err(label(&format!( + "the fixed clauses may not name the bound field \"{}\"; its `IN` clause is \ + derived", + binding.field, + ))); + } + + match sub_query.kind { + SubQueryKind::Documents if sub_query.is_by_id_join() => { + if sub_query.document_type.index_only() { + return Err(label( + "a by-id join cannot target an indexOnly type: there is no \ + primary-key tree to fetch from", + )); + } + if sub_query.limit.is_some() { + return Err(label( + "a by-id join takes no limit: every derived id must resolve, so \ + completeness is set equality, not a page", + )); + } + if !sub_query.order_by.is_empty() { + return Err(label( + "a by-id join takes no ordering: results follow the derived ids' \ + first appearance", + )); + } + // Only a permanentDocument reference guarantees every + // derived id resolves, which is what lets a missing + // document be an invalid proof instead of an absence. + match source_property_type { + Some(DocumentPropertyType::IdentifierWithReference( + DocumentPropertyReferenceTarget::PermanentDocument { + contract_id, + document_type_name, + .. + }, + )) => { + let referenced_contract = + contract_id.unwrap_or_else(|| source_contract.id()); + if referenced_contract != sub_query.contract.id() + || document_type_name != sub_query.document_type.name() + { + return Err(label(&format!( + "the source property's refersTo targets \"{}\", not this \ + sub-query's type \"{}\"", + document_type_name, + sub_query.document_type.name(), + ))); + } + } + _ => { + return Err(label(&format!( + "a by-id join needs a source property declaring `refersTo: \ + permanentDocument` (\"{}\" does not): only a permanent-document \ + reference guarantees every derived id resolves", + binding.source_property, + ))); + } + } + } + SubQueryKind::Documents => { + // Must lower to a path query with a representative value. + let shape = self.sub_query_document_query( + sub_query, + &[Identifier::default()], + platform_version, + )?; + shape.construct_path_query(None, platform_version)?; + if sub_query.document_type.index_only() { + // The lookup's own field must be provable positionally: + // the resolved index has to carry it. + let index = shape.index_only_query_index(platform_version)?; + let carried = index.terminal.as_deref() == Some(binding.field.as_str()) + || index + .properties + .iter() + .any(|property| property.name == binding.field); + if !carried { + return Err(label(&format!( + "the indexOnly lookup resolves to index \"{}\", which does not \ + carry the bound field \"{}\"", + index.name, binding.field, + ))); + } + } + // A lookup whose rows are bounded by its values (at most + // one per derived value) carries no limit: the values + // are the bound, and a limit it does not need is exactly + // what would keep it from merging with another lookup on + // the same index. Anything else needs one, to bound the + // walk under each value. + let value_bounded = + self.lookup_is_value_bounded(sub_query, &shape, platform_version)?; + match (value_bounded, sub_query.limit) { + (true, Some(_)) => { + return Err(label( + "a value-bounded lookup (a unique index, or an indexOnly terminal \ + with every prefix fixed, yields at most one row per derived \ + value) takes no limit", + )); + } + (false, None) => { + return Err(label( + "a documents lookup on a non-unique index requires an explicit \ + limit: it bounds the walk under each derived value", + )); + } + (false, Some(limit)) if limit as usize > MAX_BOUND_VALUES => { + return Err(label(&format!( + "limit {} exceeds {}", + limit, MAX_BOUND_VALUES + ))); + } + _ => {} + } + } + SubQueryKind::Count => { + if sub_query.limit.is_some() { + return Err(label("a count sub-query takes no limit")); + } + if !sub_query.order_by.is_empty() { + return Err(label("a count sub-query takes no ordering")); + } + if sub_query.is_by_id_join() { + return Err(label( + "a count sub-query counts by an indexed property, not by `$id`", + )); + } + // Must resolve a countable index with a representative value. + self.sub_query_count_query(sub_query, &[Identifier::default()], platform_version)? + .point_lookup_count_path_query(platform_version)?; + } + } + Ok(()) + } + + /// Whether a bound documents lookup yields at most one row per + /// derived value: on an indexOnly type, when the resolved index's + /// terminal is the bound field and every prefix property is fixed + /// by an equality (entries are unique per full index path); on a + /// stored type, when a `unique` index's properties are exactly the + /// fixed equality fields plus the bound field. + fn lookup_is_value_bounded( + &self, + sub_query: &DriveSubQuery<'a>, + shape: &DriveDocumentQuery<'a>, + platform_version: &PlatformVersion, + ) -> Result { + let Some(binding) = &sub_query.binding else { + return Ok(false); + }; + let fixed_equalities: BTreeSet<&str> = sub_query + .where_clauses + .iter() + .filter(|clause| clause.operator == WhereOperator::Equal) + .map(|clause| clause.field.as_str()) + .collect(); + if sub_query.document_type.index_only() { + let index = shape.index_only_query_index(platform_version)?; + let terminal_is_bound = index.terminal.as_deref() == Some(binding.field.as_str()); + let prefix_fixed = index + .properties + .iter() + .all(|property| fixed_equalities.contains(property.name.as_str())); + return Ok(terminal_is_bound && prefix_fixed); + } + let mut wanted: BTreeSet<&str> = fixed_equalities.clone(); + wanted.insert(binding.field.as_str()); + Ok(sub_query.document_type.indexes().values().any(|index| { + index.unique + && index.properties.len() == wanted.len() + && index + .properties + .iter() + .all(|property| wanted.contains(property.name.as_str())) + })) + } + + /// Whether the page is a primary-key fetch (`$id IN` / `$id ==`). + fn page_is_by_ids(&self) -> bool { + self.page.internal_clauses.primary_key_in_clause.is_some() + || self + .page + .internal_clauses + .primary_key_equal_clause + .is_some() + } + + /// The page's path query as the proof covers it. A by-ids page is + /// built WITHOUT its limit: its ids already bound it, and grovedb + /// cannot lift a limit off a query that lands at the merged root + /// (which a by-ids page shares with a join on the same type). Every + /// other page keeps its limit, lifted into its branch on merge. + pub fn page_path_query(&self, platform_version: &PlatformVersion) -> Result { + if self.page_is_by_ids() { + let mut unlimited = self.page.clone(); + unlimited.limit = None; + return unlimited.construct_path_query(None, platform_version); + } + self.page.construct_path_query(None, platform_version) + } + + /// Document entries are routed back to components by the longest + /// matching base path and then by bound-value membership; counts use + /// their exact terminal positions. Document routing and merging need + /// two things the + /// shapes must guarantee up front: no limited component may land at + /// the merged root (grovedb has no branch to lift its limit into), + /// and documents components sharing a base path must be tellable + /// apart by their derived values — so a sibling, which has none, + /// stays alone, and a page only shares the primary tree with joins + /// when it is itself a by-ids fetch. + fn validate_component_paths(&self, platform_version: &PlatformVersion) -> Result<(), Error> { + let representative = [Identifier::default()]; + let mut components: Vec<(Vec>, Component, bool)> = Vec::new(); + let page = self.page_path_query(platform_version)?; + let direction = page.query.query.left_to_right; + components.push((page.path, Component::Page, page.query.limit.is_some())); + for (index, sub_query) in self.sub_queries.iter().enumerate() { + let path_query = self.sub_query_proof_path_query( + sub_query, + &representative, + direction, + platform_version, + )?; + components.push(( + path_query.path, + Component::Sub(index), + path_query.query.limit.is_some(), + )); + } + + let merged_root: Vec> = + components + .iter() + .skip(1) + .fold(components[0].0.clone(), |common, (path, _, _)| { + common + .iter() + .zip(path) + .take_while(|(a, b)| a == b) + .map(|(a, _)| a.clone()) + .collect() + }); + for (path, component, limited) in &components { + if *limited && *path == merged_root { + return Err(unsupported(format!( + "{} carries a limit and lands at the merged root of the composite proof, \ + where grovedb has no branch to lift the limit into; give it a clause that \ + narrows its path, or split it into a separate request", + match component { + Component::Page => "the page".to_string(), + Component::Sub(index) => format!("sub-query {}", index), + } + ))); + } + } + + let mut groups: BTreeMap<&Vec>, Vec> = BTreeMap::new(); + for (path, component, _) in &components { + groups.entry(path).or_default().push(*component); + } + for members in groups.values() { + let documents_members: Vec = members + .iter() + .copied() + .filter(|component| match component { + Component::Page => true, + Component::Sub(index) => { + self.sub_queries[*index].kind == SubQueryKind::Documents + } + }) + .collect(); + let has_count_member = members.iter().any(|component| { + matches!(component, Component::Sub(index) if self.sub_queries[*index].kind == SubQueryKind::Count) + }); + // A count reads an index's value trees themselves; a documents + // component on the same index descends past them to the rows. + // One tree node cannot serve both selections in one proof. + if has_count_member && !documents_members.is_empty() { + return Err(unsupported( + "a count sub-query shares its index path with a documents component: \ + the count reads the index's value trees themselves while the documents \ + query descends past them, and one proof cannot serve both; count on \ + another index, or split them into separate requests" + .to_string(), + )); + } + if documents_members.len() < 2 { + continue; + } + let has_sibling = documents_members.iter().any(|component| { + matches!(component, Component::Sub(index) if self.sub_queries[*index].binding.is_none()) + }); + let has_page = documents_members.contains(&Component::Page); + let all_subs_are_joins = documents_members.iter().all(|component| match component { + Component::Page => true, + Component::Sub(index) => self.sub_queries[*index].is_by_id_join(), + }); + if has_sibling || (has_page && !(self.page_is_by_ids() && all_subs_are_joins)) { + return Err(unsupported( + "two documents components of the composite query address the same index \ + path and cannot be told apart by their derived values (a sibling, or a \ + page that is not a by-ids fetch, shares a path with another component); \ + split them into separate requests" + .to_string(), + )); + } + } + Ok(()) + } + + /// Extracts a binding's values from its source documents in their + /// order, deduplicated to first appearance. ONE extraction both the + /// server and the verifier run — the single-builder rule that keeps + /// every derived sub-query identical on both sides. + pub fn derive_values( + &self, + binding: &SubQueryBinding, + source_documents: &[Document], + ) -> Result { + let mut seen: BTreeSet = BTreeSet::new(); + let mut values = Vec::new(); + for document in source_documents { + if let Some(value) = document_bound_value(document, &binding.source_property)? { + if seen.insert(value) { + values.push(value); + } + } + } + if values.len() > MAX_BOUND_VALUES { + return Err(unsupported(format!( + "{} derived values exceed the {} a derived `IN` clause admits", + values.len(), + MAX_BOUND_VALUES, + ))); + } + Ok(values) + } + + /// The concrete documents query of a sub-query for `values`: the + /// fixed clauses plus the derived `IN`, or a pure by-ids fetch for + /// a join. A sibling ignores `values`. + pub fn sub_query_document_query( + &self, + sub_query: &DriveSubQuery<'a>, + values: &[Identifier], + platform_version: &PlatformVersion, + ) -> Result, Error> { + let ids = sorted_values(values); + let in_value = || { + Value::Array( + ids.iter() + .map(|id| Value::Identifier(id.to_buffer())) + .collect(), + ) + }; + + if sub_query.is_by_id_join() { + if !sub_query.where_clauses.is_empty() { + return Err(unsupported( + "a by-id join takes no fixed clauses: every derived id must resolve" + .to_string(), + )); + } + return Ok(DriveDocumentQuery { + contract: sub_query.contract, + document_type: sub_query.document_type, + internal_clauses: InternalClauses { + primary_key_in_clause: Some(WhereClause { + field: dpp::document::property_names::ID.to_string(), + operator: WhereOperator::In, + value: in_value(), + }), + 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(), + }); + } + + let mut clauses = sub_query.where_clauses.clone(); + let mut order_by: indexmap::IndexMap = sub_query + .order_by + .iter() + .map(|clause| (clause.field.clone(), clause.clone())) + .collect(); + if let Some(binding) = &sub_query.binding { + clauses.push(WhereClause { + field: binding.field.clone(), + operator: WhereOperator::In, + value: in_value(), + }); + // An `IN` on a secondary index orders by the bound field; + // supply the ordering when the caller did not, so the + // request stays minimal and both sides build the same query. + // It inherits the page's direction: the merged proof walks + // every component the page's way, and a documents sub-query + // may not be turned around behind the caller's back (see + // `sub_query_proof_path_query`), so this default is what + // keeps an unordered lookup mergeable under a descending page. + if !order_by.contains_key(&binding.field) { + order_by.insert( + binding.field.clone(), + OrderClause { + field: binding.field.clone(), + ascending: self.page_direction(platform_version)?, + }, + ); + } + } + Ok(DriveDocumentQuery { + contract: sub_query.contract, + document_type: sub_query.document_type, + internal_clauses: InternalClauses::extract_from_clauses(clauses, platform_version)?, + offset: None, + limit: sub_query.limit, + order_by, + start_at: None, + start_at_included: false, + block_time_ms: None, + resolved_time_ranges: Vec::new(), + }) + } + + /// The concrete count query of a bound count sub-query for `values`. + /// Borrows the covering index through `sub_query`, so the count query + /// lives as long as that reference. + pub fn sub_query_count_query<'b>( + &'b self, + sub_query: &'b DriveSubQuery<'a>, + values: &[Identifier], + platform_version: &PlatformVersion, + ) -> Result, Error> { + let Some(binding) = &sub_query.binding else { + return Err(unsupported("a count sub-query must be bound".to_string())); + }; + let mut where_clauses = sub_query.where_clauses.clone(); + where_clauses.push(WhereClause { + field: binding.field.clone(), + operator: WhereOperator::In, + value: Value::Array( + sorted_values(values) + .into_iter() + .map(|id| Value::Identifier(id.to_buffer())) + .collect(), + ), + }); + let index = DriveDocumentCountQuery::find_countable_index_for_where_clauses( + sub_query.document_type.indexes(), + &where_clauses, + &[], + ) + .ok_or_else(|| { + unsupported(format!( + "count sub-query on \"{}\" needs a `countable: true` index covering its fixed \ + clauses and the bound field \"{}\"", + sub_query.document_type.name(), + binding.field, + )) + })?; + let _ = platform_version; + Ok(DriveDocumentCountQuery { + document_type: sub_query.document_type, + contract_id: sub_query.contract.id().to_buffer(), + document_type_name: sub_query.document_type.name().to_string(), + index, + where_clauses, + }) + } + + /// The path query of one sub-query for `values`. + pub fn sub_query_path_query( + &self, + sub_query: &DriveSubQuery<'a>, + values: &[Identifier], + platform_version: &PlatformVersion, + ) -> Result { + match sub_query.kind { + SubQueryKind::Documents => self + .sub_query_document_query(sub_query, values, platform_version)? + .construct_path_query(None, platform_version), + SubQueryKind::Count => self + .sub_query_count_query(sub_query, values, platform_version)? + .point_lookup_count_path_query(platform_version), + } + } + + /// The page's walk direction: what every component of the merged + /// proof walks in, and what an unordered documents sub-query inherits. + fn page_direction(&self, platform_version: &PlatformVersion) -> Result { + Ok(self + .page_path_query(platform_version)? + .query + .query + .left_to_right) + } + + /// Aligns set-based components for merging without changing a + /// documents query's ordering or the rows selected by its limit. + /// Validation, proof generation and bootstrap use the same check, + /// including when a binding will derive no values at execution time. + pub(crate) fn sub_query_proof_path_query( + &self, + sub_query: &DriveSubQuery<'a>, + values: &[Identifier], + direction: bool, + platform_version: &PlatformVersion, + ) -> Result { + let mut path_query = self.sub_query_path_query(sub_query, values, platform_version)?; + if sub_query.kind == SubQueryKind::Documents + && !sub_query.is_by_id_join() + && path_query.query.query.left_to_right != direction + { + return Err(unsupported( + "a documents sub-query's outer ordering must match the page's direction; \ + changing it for the merged proof would change its result" + .to_string(), + )); + } + // Joins restore first-appearance order after decoding. Counts + // restore key order. Their selected sets do not depend on direction. + path_query.query.query.left_to_right = direction; + Ok(path_query) + } + + /// The component path queries the merged proof covers, in component + /// order: the page, then one entry per sub-query — `None` for a + /// bound sub-query whose binding derived nothing (it has no branch). + /// Every sub-query walks in the page's direction: documents must + /// already agree, while counts and by-id joins may be aligned without + /// changing their selected sets. ONE builder both the prover + /// (`prove_query_many`) and the verifier (`PathQuery::merge`) call, + /// so the merged query is byte-identical on both sides. + pub fn proof_path_queries( + &self, + derived: &[DerivedValues], + platform_version: &PlatformVersion, + ) -> Result<(PathQuery, Vec>), Error> { + if derived.len() != self.sub_queries.len() { + return Err(Error::Drive(DriveError::CorruptedCodeExecution( + "one derived value list per sub-query", + ))); + } + let page = self.page_path_query(platform_version)?; + let direction = page.query.query.left_to_right; + let mut sub_path_queries = Vec::with_capacity(self.sub_queries.len()); + for (sub_query, values) in self.sub_queries.iter().zip(derived) { + if sub_query.binding.is_some() && values.is_empty() { + sub_path_queries.push(None); + continue; + } + let path_query = + self.sub_query_proof_path_query(sub_query, values, direction, platform_version)?; + sub_path_queries.push(Some(path_query)); + } + // GroveDB cannot return a count tree and descend through that + // same tree for another component in one merged selection. Check + // concrete values so disjoint selections on the same index remain + // usable, including documents whose base path is below the count's. + let mut count_terminal_paths = BTreeSet::new(); + for (sub_query, path_query) in self.sub_queries.iter().zip(&sub_path_queries) { + if sub_query.kind != SubQueryKind::Count { + continue; + } + if let Some(path_query) = path_query { + for (mut path, key) in path_query + .terminal_keys(MAX_BOUND_VALUES, &platform_version.drive.grove_version)? + { + path.push(key); + count_terminal_paths.insert(path); + } + } + } + for terminal_path in count_terminal_paths { + for component in std::iter::once(&page).chain(sub_path_queries.iter().flatten()) { + if Self::path_query_descends_through(component, &terminal_path, platform_version)? { + return Err(unsupported( + "a count sub-query selects a tree another component descends through; \ + split them into separate requests" + .to_string(), + )); + } + } + } + Ok((page, sub_path_queries)) + } + + /// Whether a component walks through this count terminal to a deeper + /// result. Check membership at every level: a default subquery alone + /// does not mean its parent key was selected by this component. + fn path_query_descends_through( + query: &PathQuery, + terminal_path: &[Vec], + platform_version: &PlatformVersion, + ) -> Result { + let mut prefix = Vec::with_capacity(terminal_path.len()); + for key in terminal_path { + let Some(selection) = + query.query_items_at_path(&prefix, &platform_version.drive.grove_version)? + else { + return Ok(false); + }; + if !selection.items.iter().any(|item| item.contains(key)) + || !selection.has_subquery_or_matching_in_path_on_key(key) + { + return Ok(false); + } + prefix.push(key.as_slice()); + } + Ok(true) + } + + /// Merges the component path queries into the one query the proof + /// covers. + pub fn merged_path_query( + page: &PathQuery, + sub_path_queries: &[Option], + platform_version: &PlatformVersion, + ) -> Result { + let mut components: Vec<&PathQuery> = vec![page]; + components.extend(sub_path_queries.iter().flatten()); + if components.len() == 1 { + return Ok(page.clone()); + } + PathQuery::merge(components, &platform_version.drive.grove_version).map_err(Error::from) + } + + /// Decodes the proved entries of a documents component: stored + /// documents from item elements, indexOnly projections synthesized + /// from their proved positions. + pub(crate) fn decode_document_trios( + query: &DriveDocumentQuery<'a>, + trios: Vec, + platform_version: &PlatformVersion, + ) -> Result, Error> { + if query.document_type.index_only() { + let index = query.index_only_query_index(platform_version)?; + return trios + .into_iter() + .map(|(path, key, _)| { + synthesize_index_only_document( + query.contract.id(), + query.document_type, + index, + &path, + &key, + ) + }) + .collect(); + } + trios + .into_iter() + .map(|(_, _, element)| { + let serialized = element.into_item_bytes().map_err(Error::from)?; + Document::from_bytes(serialized.as_slice(), query.document_type, platform_version) + .map_err(|e| Error::Protocol(Box::new(e))) + }) + .collect() + } + + /// Decodes a documents sub-query and applies the same result assembly + /// as execution, particularly a join's first-appearance ordering, + /// before its documents can supply values to a later binding. + pub(crate) fn decode_sub_query_document_trios( + &self, + sub_query: &DriveSubQuery<'a>, + values: &[Identifier], + trios: Vec, + platform_version: &PlatformVersion, + ) -> Result, Error> { + let query = self.sub_query_document_query(sub_query, values, platform_version)?; + let documents = Self::decode_document_trios(&query, trios, platform_version)?; + match self.assemble_sub_result(sub_query, values, &DecodedItems::Documents(documents))? { + SubQueryResult::Documents(documents) => Ok(documents), + SubQueryResult::Counts(_) => Err(Error::Drive(DriveError::CorruptedCodeExecution( + "a documents sub-query must assemble documents", + ))), + } + } + + /// Decodes the proved entries of a count component: one entry per + /// count tree, keyed by the `IN` value — which sits one segment + /// past the base path when the walk descended through trailing + /// equalities, and IS the key otherwise (the same layout + /// `verify_point_lookup_count_proof` reads). + fn decode_count_trios(base_path_len: usize, trios: Vec) -> Vec { + let mut entries: Vec<_> = trios + .into_iter() + .map(|(path, key, element)| { + let key = if path.len() > base_path_len { + path[base_path_len].clone() + } else { + key + }; + SplitCountEntry { + in_key: None, + key, + count: Some(element.count_value_or_default()), + } + }) + .collect(); + // Proof merging may align the count walk with a descending page; + // count results retain the ordinary point-lookup's key order. + entries.sort_by(|a, b| a.key.cmp(&b.key)); + entries + } + + /// Assembles one sub-query's result from the decoded items routed to + /// its group, keeping only the items its derived values admit and, + /// for a by-id join, enforcing exact set equality in first-appearance + /// order. Shared by the server (where a violation is corrupted + /// state) and the verifier (where it is an invalid proof). + fn assemble_sub_result( + &self, + sub_query: &DriveSubQuery<'a>, + values: &[Identifier], + items: &DecodedItems, + ) -> Result { + let admitted: BTreeSet = values.iter().copied().collect(); + match (sub_query.kind, items) { + (SubQueryKind::Documents, DecodedItems::Documents(documents)) => { + let Some(binding) = &sub_query.binding else { + return Ok(SubQueryResult::Documents(documents.clone())); + }; + if sub_query.is_by_id_join() { + let mut by_id: BTreeMap = BTreeMap::new(); + for document in documents { + let id = document.id(); + if !admitted.contains(&id) { + // Another join on the same type owns it. + continue; + } + if by_id.insert(id, document.clone()).is_some() { + return Err(corrupted_proof(format!( + "composite join results carry document {} twice", + id + ))); + } + } + let mut ordered = Vec::with_capacity(values.len()); + for value in values { + let document = by_id.remove(value).ok_or_else(|| { + corrupted_proof(format!( + "composite join results are missing referenced document {}: \ + a permanentDocument reference cannot dangle, so the proof \ + does not cover the derived query", + value + )) + })?; + ordered.push(document); + } + return Ok(SubQueryResult::Documents(ordered)); + } + let mut mine = Vec::new(); + for document in documents { + match document_bound_value(document, &binding.field)? { + Some(value) if admitted.contains(&value) => mine.push(document.clone()), + _ => {} + } + } + Ok(SubQueryResult::Documents(mine)) + } + (SubQueryKind::Count, DecodedItems::Counts(entries)) => { + let mut mine = Vec::new(); + for entry in entries { + let Ok(value) = Identifier::from_bytes(&entry.key) else { + return Err(corrupted_proof( + "a composite count entry is keyed by something other than an \ + identifier" + .to_string(), + )); + }; + if admitted.contains(&value) { + mine.push(entry.clone()); + } + } + Ok(SubQueryResult::Counts(mine)) + } + _ => Err(Error::Drive(DriveError::CorruptedCodeExecution( + "a component group decoded into the wrong kind of items", + ))), + } + } + + /// Routes the proved trios of the merged query back to the page and + /// the sub-queries, decodes each group, and assembles every + /// component's result. Every trio must land in a component, and + /// every decoded item must be claimed by one — an entry the + /// derivation never asked for means the responding node steered the + /// composition. + pub(crate) fn assemble_from_trios( + &self, + derived: &[DerivedValues], + page_path_query: &PathQuery, + sub_path_queries: &[Option], + trios: Vec, + platform_version: &PlatformVersion, + ) -> Result { + // Group documents by base path. Counts instead route by their + // complete terminal positions: a shared base and bound value can + // still select different trailing equality values. A terminal may + // belong to several counts, including counts with nested base paths. + let mut groups: Vec<(Vec>, Vec)> = Vec::new(); + let mut count_members_by_position: BTreeMap<_, Vec> = BTreeMap::new(); + let mut register = |path: &Vec>, component: Component| { + if let Some((_, members)) = groups.iter_mut().find(|(p, _)| p == path) { + members.push(component); + } else { + groups.push((path.clone(), vec![component])); + } + }; + register(&page_path_query.path, Component::Page); + for (index, path_query) in sub_path_queries.iter().enumerate() { + if let Some(path_query) = path_query { + if self.sub_queries[index].kind == SubQueryKind::Count { + for position in path_query + .terminal_keys(MAX_BOUND_VALUES, &platform_version.drive.grove_version)? + { + count_members_by_position + .entry(position) + .or_default() + .push(index); + } + } else { + register(&path_query.path, Component::Sub(index)); + } + } + } + + // Distribute counts before their positional information is lost + // during decoding, and documents by the longest matching base path. + let mut trios_by_group: Vec> = vec![Vec::new(); groups.len()]; + let mut count_trios_by_sub: Vec> = + vec![Vec::new(); self.sub_queries.len()]; + for (path, key, element) in trios { + let Some(element) = element else { + continue; + }; + if !matches!(element, Element::Item(..)) { + let position = (path, key); + let members = count_members_by_position.get(&position).ok_or_else(|| { + corrupted_proof( + "the composite proof carries a count at a position no component \ + selected" + .to_string(), + ) + })?; + for index in members { + count_trios_by_sub[*index].push(( + position.0.clone(), + position.1.clone(), + element.clone(), + )); + } + continue; + } + let best = groups + .iter() + .enumerate() + .filter(|(_, (base, _))| path.starts_with(base)) + .max_by_key(|(_, (base, _))| base.len()) + .map(|(index, _)| index) + .ok_or_else(|| { + corrupted_proof( + "the composite proof proved an entry outside every component's \ + subtree" + .to_string(), + ) + })?; + trios_by_group[best].push((path, key, element)); + } + + // Decode each documents group once, then let every member claim + // its share. + let mut page_documents: Option> = None; + let mut sub_results: Vec> = vec![None; self.sub_queries.len()]; + for ((_, documents_members), document_trios) in groups.iter().zip(trios_by_group) { + // Every documents member of a group addresses the same + // type, so any member's query decodes the group. + let documents = match documents_members[0] { + Component::Page => { + Self::decode_document_trios(&self.page, document_trios, platform_version)? + } + Component::Sub(index) => { + let query = self.sub_query_document_query( + &self.sub_queries[index], + &derived[index], + platform_version, + )?; + Self::decode_document_trios(&query, document_trios, platform_version)? + } + }; + let decoded = DecodedItems::Documents(documents.clone()); + let mut claimed: BTreeSet = BTreeSet::new(); + for member in documents_members { + match member { + Component::Page => { + let page_ids: Option> = if documents_members.len() > 1 + { + Some(self.page_ids()?) + } else { + None + }; + let mut mine = Vec::new(); + for (position, document) in documents.iter().enumerate() { + let is_mine = page_ids + .as_ref() + .is_none_or(|ids| ids.contains(&document.id())); + if is_mine { + claimed.insert(position); + mine.push(document.clone()); + } + } + page_documents = Some(mine); + } + Component::Sub(index) => { + let sub_query = &self.sub_queries[*index]; + let result = + self.assemble_sub_result(sub_query, &derived[*index], &decoded)?; + let mine_ids: BTreeSet = result + .documents() + .iter() + .map(|document| document.id()) + .collect(); + for (position, document) in documents.iter().enumerate() { + if mine_ids.contains(&document.id()) { + claimed.insert(position); + } + } + sub_results[*index] = Some(result); + } + } + } + if claimed.len() != documents.len() { + return Err(corrupted_proof( + "the composite proof carries a document that no component's \ + derivation asked for" + .to_string(), + )); + } + } + + for (index, count_trios) in count_trios_by_sub.into_iter().enumerate() { + if self.sub_queries[index].kind != SubQueryKind::Count { + continue; + } + let Some(path_query) = &sub_path_queries[index] else { + continue; + }; + let entries = Self::decode_count_trios(path_query.path.len(), count_trios); + sub_results[index] = Some(self.assemble_sub_result( + &self.sub_queries[index], + &derived[index], + &DecodedItems::Counts(entries), + )?); + } + + Ok(CompositeDocumentsResult { + page_documents: page_documents.unwrap_or_default(), + sub_results: sub_results + .into_iter() + .zip(&self.sub_queries) + .map(|(result, sub_query)| { + result.unwrap_or_else(|| match sub_query.kind { + SubQueryKind::Documents => SubQueryResult::Documents(Vec::new()), + SubQueryKind::Count => SubQueryResult::Counts(Vec::new()), + }) + }) + .collect(), + }) + } + + /// The ids a by-ids page addresses (its `$id IN` / `$id ==` clause), + /// used to tell the page's documents from a join's when they share + /// the primary tree. + fn page_ids(&self) -> Result, Error> { + let mut ids = BTreeSet::new(); + if let Some(clause) = &self.page.internal_clauses.primary_key_equal_clause { + ids.insert(clause.value.to_identifier().map_err(|_| { + Error::Drive(DriveError::CorruptedCodeExecution( + "a primary-key equality clause holds an identifier", + )) + })?); + } + if let Some(clause) = &self.page.internal_clauses.primary_key_in_clause { + for value in clause + .in_values() + .into_data() + .map_err(|_| { + Error::Drive(DriveError::CorruptedCodeExecution( + "a primary-key in clause holds an array", + )) + })? + .iter() + { + ids.insert(value.to_identifier().map_err(|_| { + Error::Drive(DriveError::CorruptedCodeExecution( + "a primary-key in clause holds identifiers", + )) + })?); + } + } + Ok(ids) + } + + /// Derives every sub-query's values from the (materialized or + /// proven) page and earlier sub-query documents, in request order. + pub fn derive_all( + &self, + page_documents: &[Document], + sub_documents: &dyn Fn(usize) -> Option>, + ) -> Result, Error> { + let mut derived: Vec = Vec::with_capacity(self.sub_queries.len()); + for sub_query in &self.sub_queries { + let Some(binding) = &sub_query.binding else { + derived.push(Vec::new()); + continue; + }; + let values = match binding.source { + BindingSource::Page => self.derive_values(binding, page_documents)?, + BindingSource::SubQuery(source_index) => { + let documents = sub_documents(source_index).ok_or_else(|| { + Error::Drive(DriveError::CorruptedCodeExecution( + "a binding's source sub-query was not materialized before it", + )) + })?; + self.derive_values(binding, &documents)? + } + }; + derived.push(values); + } + Ok(derived) + } + + /// Whether a sub-query's documents feed a later binding. + pub(crate) fn is_binding_source(&self, index: usize) -> bool { + self.sub_queries.iter().any(|sub_query| { + matches!( + sub_query.binding, + Some(SubQueryBinding { + source: BindingSource::SubQuery(source), + .. + }) if source == index + ) + }) + } +} + +#[cfg(feature = "server")] +impl<'a> DriveCompositeDocumentQuery<'a> { + /// Materializes the page without a proof. + fn materialize_page( + &self, + drive: &crate::drive::Drive, + transaction: grovedb::TransactionArg, + drive_operations: &mut Vec, + platform_version: &PlatformVersion, + ) -> Result, Error> { + Self::materialize_documents( + &self.page, + drive, + transaction, + drive_operations, + platform_version, + ) + } + + /// Materializes a documents query without a proof: indexOnly + /// projections are synthesized, stored documents deserialized. + fn materialize_documents( + query: &DriveDocumentQuery<'a>, + drive: &crate::drive::Drive, + transaction: grovedb::TransactionArg, + drive_operations: &mut Vec, + platform_version: &PlatformVersion, + ) -> Result, Error> { + if query.document_type.index_only() { + let (documents, _skipped) = query.execute_index_only_documents_no_proof_internal( + drive, + transaction, + drive_operations, + platform_version, + )?; + return Ok(documents); + } + let (serialized, _skipped) = query.execute_raw_results_no_proof_internal( + drive, + transaction, + drive_operations, + platform_version, + )?; + serialized + .into_iter() + .map(|bytes| { + Document::from_bytes(bytes.as_slice(), query.document_type, platform_version) + .map_err(|e| Error::Protocol(Box::new(e))) + }) + .collect() + } + + /// Materializes one sub-query's result without a proof. + fn materialize_sub_result( + &self, + sub_query: &DriveSubQuery<'a>, + values: &[Identifier], + drive: &crate::drive::Drive, + transaction: grovedb::TransactionArg, + drive_operations: &mut Vec, + platform_version: &PlatformVersion, + ) -> Result { + use grovedb::query_result_type::{QueryResultElement, QueryResultType}; + + if sub_query.binding.is_some() && values.is_empty() { + return Ok(match sub_query.kind { + SubQueryKind::Documents => SubQueryResult::Documents(Vec::new()), + SubQueryKind::Count => SubQueryResult::Counts(Vec::new()), + }); + } + match sub_query.kind { + SubQueryKind::Documents => { + let query = self.sub_query_document_query(sub_query, values, platform_version)?; + let documents = Self::materialize_documents( + &query, + drive, + transaction, + drive_operations, + platform_version, + )?; + self.assemble_sub_result(sub_query, values, &DecodedItems::Documents(documents)) + } + SubQueryKind::Count => { + let path_query = self + .sub_query_count_query(sub_query, values, platform_version)? + .point_lookup_count_path_query(platform_version)?; + let base_path_len = path_query.path.len(); + let (results, _skipped) = match drive.grove_get_path_query( + &path_query, + transaction, + QueryResultType::QueryPathKeyElementTrioResultType, + drive_operations, + &platform_version.drive, + ) { + // No count tree yet under this index: every count is zero. + Err(Error::GroveDB(e)) + if matches!( + e.as_ref(), + grovedb::Error::PathKeyNotFound(_) + | grovedb::Error::PathNotFound(_) + | grovedb::Error::PathParentLayerNotFound(_) + ) => + { + return Ok(SubQueryResult::Counts(Vec::new())); + } + other => other?, + }; + let trios = results + .elements + .into_iter() + .filter_map(|element| match element { + QueryResultElement::PathKeyElementTrioResultItem(trio) => Some(trio), + _ => None, + }) + .collect(); + let entries = Self::decode_count_trios(base_path_len, trios); + self.assemble_sub_result(sub_query, values, &DecodedItems::Counts(entries)) + } + } + } + + /// Executes the composite 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 { + self.validate(platform_version)?; + + let page_documents = + self.materialize_page(drive, transaction, drive_operations, platform_version)?; + let mut sub_results: Vec = Vec::with_capacity(self.sub_queries.len()); + let mut derived = Vec::with_capacity(self.sub_queries.len()); + for sub_query in &self.sub_queries { + let values = match &sub_query.binding { + None => Vec::new(), + Some(binding) => match binding.source { + BindingSource::Page => self.derive_values(binding, &page_documents)?, + BindingSource::SubQuery(source) => { + self.derive_values(binding, sub_results[source].documents())? + } + }, + }; + sub_results.push(self.materialize_sub_result( + sub_query, + &values, + drive, + transaction, + drive_operations, + platform_version, + )?); + derived.push(values); + } + // Count-tree conflicts depend on the actual derived values, not + // just the representative shapes checked by validate(). Reject + // them on the materialized entry point as on the proof entry point. + self.proof_path_queries(&derived, platform_version)?; + Ok(CompositeDocumentsResult { + page_documents, + sub_results, + }) + } + + /// Executes the composite query AND generates its single merged + /// proof. + /// + /// The page (and every sub-query that feeds a later binding) is + /// materialized so the sub-queries can be derived; then + /// [`Self::proof_path_queries`] builds the component path queries + /// and `prove_query_many` merges them — one proof, one root by + /// construction. Grovedb proves committed state only, so the + /// materialize/prove sequence is bracketed by root-hash reads and + /// retried if a block commit interleaved (otherwise the proof's page + /// branch could disagree with the sub-queries derived from a stale + /// materialization and every verifier would reject it). + /// + /// Returns the proof and the materialized page (the caller's + /// pagination cursor derives from it); the sub-query results are + /// covered by the proof and not materialized twice. + 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)?; + + const MAX_ATTEMPTS: usize = 3; + for _ in 0..MAX_ATTEMPTS { + let root_before = drive + .grove + .root_hash(None, &platform_version.drive.grove_version) + .unwrap()?; + + let page_documents = + self.materialize_page(drive, None, drive_operations, platform_version)?; + // Sub-queries that feed later bindings are materialized in + // order; everything else is only derived. + let mut derived: Vec = Vec::with_capacity(self.sub_queries.len()); + let mut materialized: Vec>> = vec![None; self.sub_queries.len()]; + for (index, sub_query) in self.sub_queries.iter().enumerate() { + let values = match &sub_query.binding { + None => Vec::new(), + Some(binding) => match binding.source { + BindingSource::Page => self.derive_values(binding, &page_documents)?, + BindingSource::SubQuery(source) => { + let documents = materialized[source].as_deref().ok_or_else(|| { + Error::Drive(DriveError::CorruptedCodeExecution( + "a binding's source sub-query was not materialized", + )) + })?; + self.derive_values(binding, documents)? + } + }, + }; + if self.is_binding_source(index) { + let result = self.materialize_sub_result( + sub_query, + &values, + drive, + None, + drive_operations, + platform_version, + )?; + materialized[index] = Some(result.documents().to_vec()); + } + derived.push(values); + } + + let (page_path_query, sub_path_queries) = + self.proof_path_queries(&derived, platform_version)?; + let mut components: Vec<&PathQuery> = vec![&page_path_query]; + components.extend(sub_path_queries.iter().flatten()); + let proof = drive + .grove + .prove_query_many(components, 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, page_documents)); + } + Err(Error::Drive(DriveError::NotSupported( + "composite 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 f154d2fd128..37fe4720293 100644 --- a/packages/rs-drive/src/query/mod.rs +++ b/packages/rs-drive/src/query/mod.rs @@ -293,6 +293,12 @@ pub(crate) mod index_only_synthesis; #[cfg(any(feature = "server", feature = "verify"))] pub mod drive_chained_document_query; +/// Composite document queries — a page plus sub-queries derived from its +/// proven results (joins, lookups, counts), proven as one merged proof +/// against one state root. See the module docs. +#[cfg(any(feature = "server", feature = "verify"))] +pub mod drive_composite_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/composite_document/mod.rs b/packages/rs-drive/src/verify/composite_document/mod.rs new file mode 100644 index 00000000000..7a35555ce3c --- /dev/null +++ b/packages/rs-drive/src/verify/composite_document/mod.rs @@ -0,0 +1,4 @@ +//! Composite document query proof verification — the verifier half of +//! [`drive_composite_document_query`](crate::query::drive_composite_document_query). + +mod verify_composite_documents_proof; diff --git a/packages/rs-drive/src/verify/composite_document/verify_composite_documents_proof/mod.rs b/packages/rs-drive/src/verify/composite_document/verify_composite_documents_proof/mod.rs new file mode 100644 index 00000000000..c15a0431287 --- /dev/null +++ b/packages/rs-drive/src/verify/composite_document/verify_composite_documents_proof/mod.rs @@ -0,0 +1,55 @@ +mod v0; + +use crate::error::drive::DriveError; +use crate::error::Error; +use crate::query::drive_composite_document_query::{ + CompositeDocumentsResult, DriveCompositeDocumentQuery, +}; +use crate::verify::RootHash; +use dpp::version::PlatformVersion; + +impl DriveCompositeDocumentQuery<'_> { + /// Verifies a composite query's single merged proof and returns + /// `(root_hash, result)`. + /// + /// The verifier trusts nothing about the derivation, and needs + /// nothing beyond the proof itself: a BOOTSTRAP subset pass runs the + /// page query (and every sub-query that feeds a later binding) alone + /// against the merged proof to extract candidate values; every + /// sub-query is derived from those exactly as the prover derived it + /// from its materialization, the merged query is rebuilt, and the + /// AUTHORITATIVE full pass verifies the whole composition — grovedb + /// enforces every component's lifted per-instance limit and range + /// completeness. The proven results are then routed back to their + /// components: an entry no derivation asked for is an invalid proof, + /// so is a by-id join missing a referenced document (a + /// `permanentDocument` reference cannot dangle), and so is any + /// divergence between the values the proven page derives and the + /// candidates the query was built from. A proof covering only the + /// page (an old node serving the plain query) fails the full pass + /// whenever a sub-query derived anything. + /// + /// 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_composite_documents_proof( + &self, + proof: &[u8], + platform_version: &PlatformVersion, + ) -> Result<(RootHash, CompositeDocumentsResult), Error> { + match platform_version + .drive + .methods + .verify + .composite_document + .verify_composite_documents_proof + { + 0 => self.verify_composite_documents_proof_v0(proof, platform_version), + version => Err(Error::Drive(DriveError::UnknownVersionMismatch { + method: "DriveCompositeDocumentQuery::verify_composite_documents_proof".to_string(), + known_versions: vec![0], + received: version, + })), + } + } +} diff --git a/packages/rs-drive/src/verify/composite_document/verify_composite_documents_proof/v0/mod.rs b/packages/rs-drive/src/verify/composite_document/verify_composite_documents_proof/v0/mod.rs new file mode 100644 index 00000000000..8cd8eebf304 --- /dev/null +++ b/packages/rs-drive/src/verify/composite_document/verify_composite_documents_proof/v0/mod.rs @@ -0,0 +1,123 @@ +use crate::error::proof::ProofError; +use crate::error::Error; +use crate::query::drive_composite_document_query::{ + BindingSource, CompositeDocumentsResult, DriveCompositeDocumentQuery, PresentTrio, ProvedTrio, +}; +use crate::verify::RootHash; +use dpp::document::Document; +use dpp::version::PlatformVersion; +use grovedb::GroveDb; + +impl DriveCompositeDocumentQuery<'_> { + /// v0 of the composite proof verification — see the versioned + /// wrapper for the trust model. + #[inline(always)] + pub(super) fn verify_composite_documents_proof_v0( + &self, + proof: &[u8], + platform_version: &PlatformVersion, + ) -> Result<(RootHash, CompositeDocumentsResult), Error> { + self.validate(platform_version)?; + let grove_version = &platform_version.drive.grove_version; + + let present = |trios: Vec| { + trios + .into_iter() + .filter_map(|(path, key, element)| element.map(|element| (path, key, element))) + .collect::>() + }; + + // BOOTSTRAP PASS: the page alone against the merged proof (subset + // verification — succinctness off, so the sub-query branches' + // extra coverage is tolerated), decoded into candidate documents. + // Candidates only reconstruct the merged query; the full pass + // below is the authority. + let page_path_query = self.page_path_query(platform_version)?; + let direction = page_path_query.query.query.left_to_right; + let (_, page_trios) = GroveDb::verify_subset_query(proof, &page_path_query, grove_version)?; + let bootstrap_page = + Self::decode_document_trios(&self.page, present(page_trios), platform_version)?; + + // Derive every sub-query in order. A sub-query that feeds a later + // binding is itself bootstrapped by a subset pass, so the later + // binding has candidates to derive from. + let mut derived = Vec::with_capacity(self.sub_queries.len()); + let mut bootstrap_sub_documents: Vec>> = + vec![None; self.sub_queries.len()]; + for (index, sub_query) in self.sub_queries.iter().enumerate() { + let values = match &sub_query.binding { + None => Vec::new(), + Some(binding) => match binding.source { + BindingSource::Page => self.derive_values(binding, &bootstrap_page)?, + BindingSource::SubQuery(source) => { + let documents = + bootstrap_sub_documents[source].as_deref().ok_or_else(|| { + Error::Proof(ProofError::CorruptedProof( + "a binding's source sub-query was not bootstrapped before \ + it" + .to_string(), + )) + })?; + self.derive_values(binding, documents)? + } + }, + }; + if self.is_binding_source(index) { + let documents = if sub_query.binding.is_some() && values.is_empty() { + Vec::new() + } else { + let path_query = self.sub_query_proof_path_query( + sub_query, + &values, + direction, + platform_version, + )?; + let (_, trios) = + GroveDb::verify_subset_query(proof, &path_query, grove_version)?; + self.decode_sub_query_document_trios( + sub_query, + &values, + present(trios), + platform_version, + )? + }; + bootstrap_sub_documents[index] = Some(documents); + } + derived.push(values); + } + + // AUTHORITATIVE PASS: rebuild every component from the candidates, + // re-merge at the same grove version (identical to the prover's + // merge by the single-builder rule), and verify the whole + // composition with succinctness on. + let (page_path_query, sub_path_queries) = + self.proof_path_queries(&derived, platform_version)?; + let merged_query = + Self::merged_path_query(&page_path_query, &sub_path_queries, platform_version)?; + let (root_hash, proved_trios) = GroveDb::verify_query(proof, &merged_query, grove_version)?; + + let result = self.assemble_from_trios( + &derived, + &page_path_query, + &sub_path_queries, + proved_trios, + platform_version, + )?; + + // The PROVEN results are authoritative: every derivation must come + // out identical from them, or the proof was built over a different + // page than it proves. + let authoritative = self.derive_all(&result.page_documents, &|index| { + Some(result.sub_results[index].documents().to_vec()) + })?; + if authoritative != derived { + return Err(Error::Proof(ProofError::CorruptedProof( + "the composite proof's page derives different sub-query values than the \ + ones the proof covers" + .to_string(), + ))); + } + + Ok((root_hash, result)) + } +} diff --git a/packages/rs-drive/src/verify/mod.rs b/packages/rs-drive/src/verify/mod.rs index a6c0593912b..2b6f2e49b8c 100644 --- a/packages/rs-drive/src/verify/mod.rs +++ b/packages/rs-drive/src/verify/mod.rs @@ -3,6 +3,10 @@ /// Chained document query (provable semi-join) verification methods on /// proofs — two grovedb proofs verified as one composed statement. pub mod chained_document; +/// Composite document query (page plus derived sub-queries) +/// verification methods on proofs — one merged proof verified as one +/// composed statement. +pub mod composite_document; ///DataContract verification methods on proofs pub mod contract; /// Document verification methods on proofs diff --git a/packages/rs-drive/tests/supporting_files/contract/yappr-feed/yappr-feed-contract.json b/packages/rs-drive/tests/supporting_files/contract/yappr-feed/yappr-feed-contract.json new file mode 100644 index 00000000000..875985f6766 --- /dev/null +++ b/packages/rs-drive/tests/supporting_files/contract/yappr-feed/yappr-feed-contract.json @@ -0,0 +1,189 @@ +{ + "$formatVersion": "0", + "id": "7RJ5bcEyBLDXFbmDSNzHeAKUC5z7z3Du5mKLY7FuyeeA", + "ownerId": "AtirhSVpAWF7dEt6dLAmesC4Sr1MsJ9bFC1nLAoNnq2S", + "version": 1, + "documentSchemas": { + "post": { + "type": "object", + "documentsMutable": false, + "canBeDeleted": false, + "indices": [ + { + "name": "byHashtag", + "properties": [ + { + "hashtag": "asc" + } + ] + }, + { + "name": "quotesOfPost", + "properties": [ + { + "quotedPostId": "asc" + } + ], + "countable": true + } + ], + "properties": { + "hashtag": { + "type": "string", + "minLength": 1, + "maxLength": 63, + "position": 0 + }, + "message": { + "type": "string", + "maxLength": 280, + "position": 1 + }, + "quotedPostId": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "refersTo": { + "type": "permanentDocument", + "documentType": "post" + }, + "position": 2 + } + }, + "required": [ + "hashtag", + "message" + ], + "additionalProperties": false + }, + "like": { + "type": "object", + "indexOnly": true, + "documentsMutable": false, + "canBeDeleted": true, + "indices": [ + { + "name": "byHashtagPost", + "properties": [ + { + "hashtag": "asc" + }, + { + "postId": "asc" + } + ], + "countable": "countable", + "terminal": "$ownerId" + }, + { + "name": "byPost", + "properties": [ + { + "postId": "asc" + } + ], + "countable": "countable", + "rangeCountable": true, + "rankedCountable": true + }, + { + "name": "byLiker", + "properties": [ + { + "$ownerId": "asc" + } + ], + "terminal": "postId" + } + ], + "properties": { + "hashtag": { + "type": "string", + "minLength": 1, + "maxLength": 63, + "position": 0 + }, + "postId": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "refersTo": { + "type": "permanentDocument", + "documentType": "post", + "propertyAgreement": { + "hashtag": "hashtag" + } + }, + "position": 1 + } + }, + "required": [ + "hashtag", + "postId" + ], + "additionalProperties": false + }, + "repost": { + "type": "object", + "documentsMutable": false, + "canBeDeleted": true, + "indices": [ + { + "name": "byPost", + "properties": [ + { + "postId": "asc" + } + ], + "countable": true + }, + { + "name": "ownerAndPost", + "properties": [ + { + "$ownerId": "asc" + }, + { + "postId": "asc" + } + ], + "unique": true + }, + { + "name": "postAndOwner", + "properties": [ + { + "postId": "asc" + }, + { + "$ownerId": "asc" + } + ], + "countable": true + } + ], + "properties": { + "postId": { + "type": "array", + "byteArray": true, + "minItems": 32, + "maxItems": 32, + "contentMediaType": "application/x.dash.dpp.identifier", + "refersTo": { + "type": "permanentDocument", + "documentType": "post" + }, + "position": 0 + } + }, + "required": [ + "postId" + ], + "additionalProperties": false + } + } +} 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 c5ceef2e1ec..4cfcfd81df0 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 @@ -23,6 +23,10 @@ pub struct DriveDocumentQueryMethodVersions { /// 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, + /// Composite document queries (a page plus sub-queries derived from + /// its results, one merged proof): the version slot shared by the + /// no-proof and the proof execution paths. + pub query_composite_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 66aaa63c641..471d5d35457 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 @@ -10,6 +10,7 @@ pub const DRIVE_DOCUMENT_METHOD_VERSIONS_V1: DriveDocumentMethodVersions = query: DriveDocumentQueryMethodVersions { query_documents: 0, query_chained_documents: 0, + query_composite_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 ce962c55a75..1f9438ee229 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 @@ -12,6 +12,7 @@ pub const DRIVE_DOCUMENT_METHOD_VERSIONS_V2: DriveDocumentMethodVersions = query: DriveDocumentQueryMethodVersions { query_documents: 0, query_chained_documents: 0, + query_composite_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 f2635d5ff18..b2d3a909e28 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 @@ -22,6 +22,7 @@ pub const DRIVE_DOCUMENT_METHOD_VERSIONS_V3: DriveDocumentMethodVersions = query: DriveDocumentQueryMethodVersions { query_documents: 0, query_chained_documents: 0, + query_composite_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 14dd88c5839..f55291f9d45 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 @@ -69,6 +69,7 @@ pub const DRIVE_DOCUMENT_METHOD_VERSIONS_V4: DriveDocumentMethodVersions = query: DriveDocumentQueryMethodVersions { query_documents: 0, query_chained_documents: 0, + query_composite_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 bda81602f40..017bd182bcb 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 @@ -8,6 +8,7 @@ pub struct DriveVerifyMethodVersions { pub contract: DriveVerifyContractMethodVersions, pub document: DriveVerifyDocumentMethodVersions, pub chained_document: DriveVerifyChainedDocumentMethodVersions, + pub composite_document: DriveVerifyCompositeDocumentMethodVersions, pub document_count: DriveVerifyDocumentCountMethodVersions, pub document_sum: DriveVerifyDocumentSumMethodVersions, pub document_ranked: DriveVerifyDocumentRankedMethodVersions, @@ -55,6 +56,14 @@ pub struct DriveVerifyChainedDocumentMethodVersions { pub verify_chained_documents_proof: FeatureVersion, } +/// Versions for the composite document query (page plus derived +/// sub-queries) prove-path verifier (grovedb-level — the tenderdash +/// composition layer lives in rs-drive-proof-verifier). +#[derive(Clone, Debug, Default)] +pub struct DriveVerifyCompositeDocumentMethodVersions { + pub verify_composite_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 02c7fd7f1cb..b2a0fc7e687 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,9 +1,9 @@ use crate::version::drive_versions::drive_verify_method_versions::{ DriveVerifyAddressFundsMethodVersions, DriveVerifyChainedDocumentMethodVersions, - DriveVerifyContractMethodVersions, DriveVerifyDocumentCountMethodVersions, - DriveVerifyDocumentMethodVersions, DriveVerifyDocumentRankedMethodVersions, - DriveVerifyDocumentSumMethodVersions, DriveVerifyGroupMethodVersions, - DriveVerifyIdentityMethodVersions, DriveVerifyMethodVersions, + DriveVerifyCompositeDocumentMethodVersions, DriveVerifyContractMethodVersions, + DriveVerifyDocumentCountMethodVersions, DriveVerifyDocumentMethodVersions, + DriveVerifyDocumentRankedMethodVersions, DriveVerifyDocumentSumMethodVersions, + DriveVerifyGroupMethodVersions, DriveVerifyIdentityMethodVersions, DriveVerifyMethodVersions, DriveVerifyShieldedMethodVersions, DriveVerifySingleDocumentMethodVersions, DriveVerifyStateTransitionMethodVersions, DriveVerifySystemMethodVersions, DriveVerifyTokenMethodVersions, DriveVerifyVoteMethodVersions, @@ -24,6 +24,9 @@ pub const DRIVE_VERIFY_METHOD_VERSIONS_V1: DriveVerifyMethodVersions = DriveVeri chained_document: DriveVerifyChainedDocumentMethodVersions { verify_chained_documents_proof: 0, }, + composite_document: DriveVerifyCompositeDocumentMethodVersions { + verify_composite_documents_proof: 0, + }, document_count: DriveVerifyDocumentCountMethodVersions { verify_aggregate_count_proof: 0, verify_carrier_aggregate_count_proof: 0,