diff --git a/packages/rs-drive/src/drive/contract/insert/insert_contract/v0/tests/shared_prefix_aggregation_e2e_tests.rs b/packages/rs-drive/src/drive/contract/insert/insert_contract/v0/tests/shared_prefix_aggregation_e2e_tests.rs index 4bc16c14986..fdd752fa5d1 100644 --- a/packages/rs-drive/src/drive/contract/insert/insert_contract/v0/tests/shared_prefix_aggregation_e2e_tests.rs +++ b/packages/rs-drive/src/drive/contract/insert/insert_contract/v0/tests/shared_prefix_aggregation_e2e_tests.rs @@ -3,8 +3,40 @@ //! A shorter index can terminate at a prefix while another index //! continues below that same prefix. When the prefix index uses count //! + sum aggregation, child continuation trees under the prefix value -//! tree must either be supported by Drive's wrapper logic or the -//! contract must be rejected before publish. +//! tree must contribute zero to every axis the value tree aggregates. +//! +//! Before platform v14, only the diagonal of the parent×child matrix +//! could be inserted: count-only parents accepted non-sum children and +//! sum-bearing parents accepted sum-bearing children. Everything else +//! registered fine as a contract but rejected every document insert. +//! The v14 index walkers (v2) complete the matrix — via +//! `Element::NonCounted` for non-sum children of count-sum parents, +//! unwrapped inserts for non-sum children of sum-only parents, and the +//! demotion of provable count-bearing value trees to `CountSumTree` +//! when continuations exist (grovedb's stated design rejects +//! count-suppressed children under provable count parents; pre-v14 +//! those shapes only worked at all through an unenforced in-batch +//! creation path). Key-changing updates materialize index branches +//! through their own walker, which is bumped to v1 at v14 with the +//! same demotion + zero-contribution treatment. +//! +//! Suites: +//! - `..._insert_update_delete_at_v14` proves the entire matrix +//! inserts at v14, that the value trees carry exactly the `[0]` +//! ref-bucket's (count, sum) — never the structural overhead of +//! continuations — through insert, key-changing update (which +//! materializes a fresh branch via the update walker), and delete, +//! and that the continuation trees land with the exact expected +//! wrapper on both the insert- and update-materialized branches. +//! - `..._frozen_at_v13` pins the pre-v14 behavior (which combos +//! insert, which error) so the consensus-locked v1 walkers cannot +//! drift. +//! - `..._estimated_costs_do_not_write_state` exercises the v2 +//! walkers' stateless-estimation branches (`apply: false`). +//! - `..._v13_and_v14_layouts_coexist` proves a provable value tree +//! created at v13 keeps working at v14 next to newly-demoted +//! `CountSumTree` siblings, through inserts and full-cleanup +//! deletes. use crate::drive::Drive; use crate::util::object_size_info::DocumentInfo::DocumentRefInfo; @@ -15,11 +47,12 @@ use dpp::block::block_info::BlockInfo; use dpp::data_contract::accessors::v0::DataContractV0Getters; use dpp::data_contract::document_type::random_document::CreateRandomDocument; use dpp::data_contract::DataContractFactory; -use dpp::document::DocumentV0Setters; +use dpp::document::{Document, DocumentV0Getters, DocumentV0Setters}; use dpp::platform_value::{platform_value, Value}; use dpp::prelude::DataContract; use dpp::tests::utils::generate_random_identifier_struct; use dpp::version::PlatformVersion; +use grovedb::{Element, TreeType}; use std::collections::BTreeMap; const PROTOCOL_VERSION_V12: u32 = 12; @@ -69,26 +102,121 @@ impl IndexFlags { summable: true, range_summable: true, }; - const RANGE_COUNT_SUM: Self = Self { + const COUNT_SUM_RANGE_COUNT: Self = Self { countable: true, range_countable: true, summable: true, + range_summable: false, + }; + const COUNT_SUM_RANGE_SUM: Self = Self { + countable: true, + range_countable: false, + summable: true, range_summable: true, }; + const RANGE_COUNT_RANGE_SUM: Self = Self { + countable: true, + range_countable: true, + summable: true, + range_summable: true, + }; + + /// The property-name (continuation) tree type this sub-level's + /// range flags produce — the inner tree hung under the prefix + /// value tree when these are the CHILD index's flags. + fn continuation_tree_type(&self) -> TreeType { + match (self.range_countable, self.range_summable) { + (true, true) => TreeType::ProvableCountProvableSumTree, + (true, false) => TreeType::ProvableCountTree, + (false, true) => TreeType::ProvableSumTree, + (false, false) => TreeType::NormalTree, + } + } + + fn is_sum_bearing_continuation(&self) -> bool { + self.range_summable + } +} + +/// Which axes the prefix index aggregates. With a compound sibling +/// present, this alone determines the value-tree type at v14+: the +/// provable count-bearing variants demote to `CountSumTree`, so the +/// range flags stop mattering for the value tree (they still upgrade +/// the property-name tree one level up). +#[derive(Clone, Copy, PartialEq)] +enum ParentAxes { + Count, + Sum, + CountSum, +} + +impl ParentAxes { + fn from_flags(flags: &IndexFlags) -> Self { + match (flags.countable, flags.summable) { + (true, false) => ParentAxes::Count, + (false, true) => ParentAxes::Sum, + (true, true) => ParentAxes::CountSum, + (false, false) => panic!("prefix index must aggregate at least one axis"), + } + } } struct SharedPrefixCase { - name: &'static str, + name: String, prefix_flags: IndexFlags, child_flags: IndexFlags, - must_insert: bool, + /// Whether the combination could be inserted by the v1 walkers + /// (protocol v12/v13). Pins today's consensus-frozen behavior. + works_at_v13: bool, } -enum SharedPrefixOutcome { - Inserted, - ContractRejected(String), - ApplyFailed(String), - InsertFailed(String), +/// Every aggregating prefix combination × every child combination. +/// `works_at_v13` marks the pre-v14 diagonal, empirically pinned: +/// count-only parents accepted non-sum children (`NonCounted`'s v0 +/// inner set), and every sum-bearing parent — *including* the provable +/// count-bearing variants — accepted sum-bearing children +/// (`NotSummed` / `NotCountedOrSummed`'s inner set). The provable +/// parents accept them only because grovedb's wrapper-vs-provable +/// batch guard fires solely when the parent merk pre-exists, and the +/// walker always creates parent and wrapped child in one batch. +fn all_cases() -> Vec { + let prefixes: [(&'static str, IndexFlags); 8] = [ + ("count", IndexFlags::COUNT), + ("sum", IndexFlags::SUM), + ("count_sum", IndexFlags::COUNT_SUM), + ("range_count", IndexFlags::RANGE_COUNT), + ("range_sum", IndexFlags::RANGE_SUM), + ("count_sum_range_count", IndexFlags::COUNT_SUM_RANGE_COUNT), + ("count_sum_range_sum", IndexFlags::COUNT_SUM_RANGE_SUM), + ("range_count_range_sum", IndexFlags::RANGE_COUNT_RANGE_SUM), + ]; + let children: [(&'static str, IndexFlags); 4] = [ + ("plain", IndexFlags::PLAIN), + ("range_count", IndexFlags::RANGE_COUNT), + ("range_sum", IndexFlags::RANGE_SUM), + ("range_count_range_sum", IndexFlags::RANGE_COUNT_RANGE_SUM), + ]; + + let mut cases = Vec::new(); + for (prefix_name, prefix_flags) in prefixes { + for (child_name, child_flags) in children { + let works_at_v13 = match ParentAxes::from_flags(&prefix_flags) { + // v1's NonCounted helper accepted only count-ish inners. + ParentAxes::Count => !child_flags.is_sum_bearing_continuation(), + // v1's NotSummed / NotCountedOrSummed helpers accepted + // only sum-bearing inners (and the provable parents let + // them through — see the fn doc). + ParentAxes::Sum | ParentAxes::CountSum => child_flags.is_sum_bearing_continuation(), + }; + cases.push(SharedPrefixCase { + name: format!("{prefix_name}_parent_{child_name}_child"), + prefix_flags, + child_flags, + works_at_v13, + }); + } + } + cases } fn review_index(name: &str, properties: Vec, flags: IndexFlags) -> Value { @@ -160,8 +288,8 @@ fn build_review_contract( let document_schema = platform_value!({ "type": "object", "documentsMutable": true, - "documentsKeepHistory": true, - "canBeDeleted": false, + "documentsKeepHistory": false, + "canBeDeleted": true, "properties": { "resourceId": { "type": "string", @@ -195,7 +323,11 @@ fn build_review_contract( .map_err(|error| format!("{error:?}")) } -fn apply_contract(drive: &Drive, contract: &DataContract) -> Result<(), String> { +fn apply_contract( + drive: &Drive, + contract: &DataContract, + platform_version: &PlatformVersion, +) -> Result<(), String> { drive .apply_contract( contract, @@ -203,35 +335,37 @@ fn apply_contract(drive: &Drive, contract: &DataContract) -> Result<(), String> true, StorageFlags::optional_default_as_cow(), None, - PlatformVersion::latest(), + platform_version, ) .map(|_| ()) .map_err(|error| format!("{error:?}")) } -fn insert_review_document_for_case(case: &SharedPrefixCase) -> SharedPrefixOutcome { - let drive = setup_drive_with_initial_state_structure(None); - let pv = PlatformVersion::latest(); - let contract = match build_review_contract(case.prefix_flags, case.child_flags) { - Ok(contract) => contract, - Err(error) => return SharedPrefixOutcome::ContractRejected(error), - }; - if let Err(error) = apply_contract(&drive, &contract) { - return SharedPrefixOutcome::ApplyFailed(error); - } - +/// Inserts a review document. `apply: false` runs the stateless +/// estimation path instead of writing state. +/// +/// Seeds must be distinct across calls: the `ownerAndResource` index +/// is unique on `($ownerId, resourceId)` and all documents share a +/// per-test `resourceId`, so only the seed-derived random owner keeps +/// the inserts from colliding on that index. +fn insert_review_document( + drive: &Drive, + contract: &DataContract, + seed: u64, + resource: &str, + rating: u8, + apply: bool, + platform_version: &PlatformVersion, +) -> Result { let document_type = contract .document_type_for_name("review") .expect("review document type exists"); let mut document = document_type - .random_document(Some(1), pv) + .random_document(Some(seed), platform_version) .expect("random review document"); let mut properties = BTreeMap::new(); - properties.insert( - "resourceId".to_string(), - Value::Text("resource-1".to_string()), - ); - properties.insert("rating".to_string(), Value::U8(5)); + properties.insert("resourceId".to_string(), Value::Text(resource.to_string())); + properties.insert("rating".to_string(), Value::U8(rating)); properties.insert( "reviewText".to_string(), Value::Text("works as expected".to_string()), @@ -242,124 +376,498 @@ fn insert_review_document_for_case(case: &SharedPrefixCase) -> SharedPrefixOutco .add_document_for_contract( DocumentAndContractInfo { owned_document_info: OwnedDocumentInfo { + // Use the document's own random owner — the delete + // path re-reads `$ownerId` from the stored document, + // so an override here would strand the owner-index + // entries. document_info: DocumentRefInfo((&document, None)), - owner_id: Some(generate_random_identifier_struct().into()), + owner_id: None, }, - contract: &contract, + contract, document_type, }, false, BlockInfo::default(), + apply, + None, + platform_version, + None, + ) + .map(|_| document) + .map_err(|error| format!("{error:?}")) +} + +/// Moves a stored review document to a new `resourceId` through the +/// document UPDATE path — the update walker materializes the new +/// index branch itself, which is exactly the surface the v14 +/// update-walker bump covers. +fn update_review_document_resource( + drive: &Drive, + contract: &DataContract, + document: &Document, + new_resource: &str, + platform_version: &PlatformVersion, +) -> Result { + let document_type = contract + .document_type_for_name("review") + .expect("review document type exists"); + let mut updated = document.clone(); + updated.set("resourceId", Value::Text(new_resource.to_string())); + updated.set_revision(updated.revision().map(|revision| revision + 1)); + + drive + .update_document_for_contract( + &updated, + contract, + document_type, + None, + BlockInfo::default(), true, None, - pv, + None, + platform_version, None, ) - .map_or_else( - |error| SharedPrefixOutcome::InsertFailed(format!("{error:?}")), - |_| SharedPrefixOutcome::Inserted, + .map(|_| updated) + .map_err(|error| format!("{error:?}")) +} + +fn delete_review_document( + drive: &Drive, + contract: &DataContract, + document: &Document, + platform_version: &PlatformVersion, +) -> Result<(), String> { + drive + .delete_document_for_contract( + document.id(), + contract, + "review", + BlockInfo::default(), + true, + None, + platform_version, + None, ) + .map(|_| ()) + .map_err(|error| format!("{error:?}")) } -#[test] -#[ignore = "tracks #3960; unsupported shared-prefix aggregate layouts are accepted but fail insertion today"] -fn shared_prefix_aggregate_index_combinations_reject_or_insert() { - let cases = [ - SharedPrefixCase { - name: "count_parent_plain_child", - prefix_flags: IndexFlags::COUNT, - child_flags: IndexFlags::PLAIN, - must_insert: true, - }, - SharedPrefixCase { - name: "count_parent_range_count_child", - prefix_flags: IndexFlags::COUNT, - child_flags: IndexFlags::RANGE_COUNT, - must_insert: true, - }, - SharedPrefixCase { - name: "count_parent_range_sum_child", - prefix_flags: IndexFlags::COUNT, - child_flags: IndexFlags::RANGE_SUM, - must_insert: false, - }, - SharedPrefixCase { - name: "count_parent_range_count_sum_child", - prefix_flags: IndexFlags::COUNT, - child_flags: IndexFlags::RANGE_COUNT_SUM, - must_insert: false, - }, - SharedPrefixCase { - name: "sum_parent_plain_child", - prefix_flags: IndexFlags::SUM, - child_flags: IndexFlags::PLAIN, - must_insert: false, - }, - SharedPrefixCase { - name: "sum_parent_range_count_child", - prefix_flags: IndexFlags::SUM, - child_flags: IndexFlags::RANGE_COUNT, - must_insert: false, - }, - SharedPrefixCase { - name: "sum_parent_range_sum_child", - prefix_flags: IndexFlags::SUM, - child_flags: IndexFlags::RANGE_SUM, - must_insert: true, - }, - SharedPrefixCase { - name: "sum_parent_range_count_sum_child", - prefix_flags: IndexFlags::SUM, - child_flags: IndexFlags::RANGE_COUNT_SUM, - must_insert: true, - }, - SharedPrefixCase { - name: "count_sum_parent_plain_child", - prefix_flags: IndexFlags::COUNT_SUM, - child_flags: IndexFlags::PLAIN, - must_insert: false, - }, - SharedPrefixCase { - name: "count_sum_parent_range_count_child", - prefix_flags: IndexFlags::COUNT_SUM, - child_flags: IndexFlags::RANGE_COUNT, - must_insert: false, - }, - SharedPrefixCase { - name: "count_sum_parent_range_sum_child", - prefix_flags: IndexFlags::COUNT_SUM, - child_flags: IndexFlags::RANGE_SUM, - must_insert: true, - }, - SharedPrefixCase { - name: "count_sum_parent_range_count_sum_child", - prefix_flags: IndexFlags::COUNT_SUM, - child_flags: IndexFlags::RANGE_COUNT_SUM, - must_insert: true, - }, +/// Reads the raw element at `@/contract/1/review/resourceId` + +/// `resource` — the prefix index's value tree — or None if absent. +fn probe_value_tree(drive: &Drive, contract: &DataContract, resource: &str) -> Option { + probe(drive, contract, &["resourceId"], resource.as_bytes()) +} + +/// Reads the raw element at +/// `@/contract/1/review/resourceId/` + `"rating"` — the +/// compound index's continuation property-name tree. +fn probe_continuation_tree( + drive: &Drive, + contract: &DataContract, + resource: &str, +) -> Option { + probe(drive, contract, &["resourceId", resource], b"rating") +} + +fn probe(drive: &Drive, contract: &DataContract, sub_path: &[&str], key: &[u8]) -> Option { + use crate::drive::RootTree; + use crate::util::grove_operations::DirectQueryType; + use grovedb_path::SubtreePath; + + let platform_version = PlatformVersion::latest(); + let contract_id = contract.id().to_buffer(); + let mut path: Vec> = vec![ + vec![RootTree::DataContractDocuments as u8], + contract_id.to_vec(), + vec![1u8], + b"review".to_vec(), ]; + path.extend(sub_path.iter().map(|segment| segment.as_bytes().to_vec())); + let path_slices: Vec<&[u8]> = path.iter().map(|segment| segment.as_slice()).collect(); + drive + .grove_get_raw_optional( + SubtreePath::from(path_slices.as_slice()), + key, + DirectQueryType::StatefulDirectQuery, + None, + &mut vec![], + &platform_version.drive, + ) + .expect("probe must succeed") +} - let failures = cases - .iter() - .filter_map(|case| match insert_review_document_for_case(case) { - SharedPrefixOutcome::Inserted => None, - SharedPrefixOutcome::ContractRejected(_) if !case.must_insert => None, - SharedPrefixOutcome::ContractRejected(error) => Some(format!( - "{}: compatible shared-prefix aggregate indexes were rejected: {}", - case.name, error - )), - SharedPrefixOutcome::ApplyFailed(error) => Some(format!( - "{}: contract application failed: {}", - case.name, error - )), - SharedPrefixOutcome::InsertFailed(error) => Some(format!("{}: {}", case.name, error)), - }) - .collect::>(); +/// Checks the value tree carries exactly the `[0]` bucket's +/// (count, sum) on the axes the prefix aggregates — with the +/// continuation demotion, always the non-provable variant. +fn check_value_tree_aggregates( + element: &Element, + axes: ParentAxes, + expected_count: u64, + expected_sum: i64, +) -> Result<(), String> { + match (axes, element) { + (ParentAxes::Count, Element::CountTree(_, count, _)) => { + if *count != expected_count { + return Err(format!( + "value tree count {count} != expected ref-bucket contribution {expected_count}" + )); + } + } + (ParentAxes::Sum, Element::SumTree(_, sum, _)) => { + if *sum != expected_sum { + return Err(format!( + "value tree sum {sum} != expected ref-bucket contribution {expected_sum}" + )); + } + } + (ParentAxes::CountSum, Element::CountSumTree(_, count, sum, _)) => { + if (*count, *sum) != (expected_count, expected_sum) { + return Err(format!( + "value tree (count, sum) ({count}, {sum}) != expected ref-bucket \ + contribution ({expected_count}, {expected_sum})" + )); + } + } + (_, other) => { + return Err(format!( + "unexpected value tree element (demotion or axis mismatch): {other:?}" + )) + } + } + Ok(()) +} + +/// Checks the continuation property-name tree landed with the exact +/// wrapper the zero-contribution matrix specifies for this +/// parent-axes / child-tree-type combination. +fn check_continuation_shape( + element: &Element, + axes: ParentAxes, + child_flags: &IndexFlags, +) -> Result<(), String> { + let expected_inner = child_flags.continuation_tree_type(); + let child_is_sum_bearing = child_flags.is_sum_bearing_continuation(); + + let inner: &Element = match (axes, child_is_sum_bearing, element) { + // Count-only parents wrap every child NonCounted. + (ParentAxes::Count, _, Element::NonCounted(inner)) => inner, + // Count-sum parents: sum-bearing children get NotCountedOrSummed, + // non-sum children get NonCounted. + (ParentAxes::CountSum, true, Element::NotCountedOrSummed(inner)) => inner, + (ParentAxes::CountSum, false, Element::NonCounted(inner)) => inner, + // Sum-only parents: sum-bearing children get NotSummed, non-sum + // children are stored unwrapped (they contribute 0 naturally). + (ParentAxes::Sum, true, Element::NotSummed(inner)) => inner, + (ParentAxes::Sum, false, plain) => plain, + (_, _, other) => return Err(format!("unexpected continuation wrapper: {other:?}")), + }; + + let inner_tree_type = match inner { + Element::Tree(..) => TreeType::NormalTree, + Element::ProvableCountTree(..) => TreeType::ProvableCountTree, + Element::ProvableSumTree(..) => TreeType::ProvableSumTree, + Element::ProvableCountProvableSumTree(..) => TreeType::ProvableCountProvableSumTree, + other => return Err(format!("unexpected continuation inner element: {other:?}")), + }; + if inner_tree_type != expected_inner { + return Err(format!( + "continuation inner tree type {inner_tree_type:?} != expected {expected_inner:?}" + )); + } + Ok(()) +} + +/// Full v14 lifecycle for one matrix case: two inserts, a +/// key-changing update (branch materialized by the UPDATE walker), +/// and deletes down to full cleanup, with the value-tree aggregates +/// checked against the `[0]`-bucket contribution at every step. +fn run_case_at_v14( + case: &SharedPrefixCase, + platform_version: &PlatformVersion, +) -> Result<(), String> { + let drive = setup_drive_with_initial_state_structure(None); + let contract = build_review_contract(case.prefix_flags, case.child_flags) + .map_err(|error| format!("contract must build: {error}"))?; + apply_contract(&drive, &contract, platform_version) + .map_err(|error| format!("contract must apply: {error}"))?; + + let axes = ParentAxes::from_flags(&case.prefix_flags); + + let first_document = insert_review_document( + &drive, + &contract, + 1, + "resource-1", + 5, + true, + platform_version, + ) + .map_err(|error| format!("first insert must succeed: {error}"))?; + let second_document = insert_review_document( + &drive, + &contract, + 2, + "resource-1", + 3, + true, + platform_version, + ) + .map_err(|error| format!("second insert must succeed: {error}"))?; + + let value_tree = probe_value_tree(&drive, &contract, "resource-1") + .ok_or("value tree must exist after inserts")?; + check_value_tree_aggregates(&value_tree, axes, 2, 8) + .map_err(|error| format!("after inserts: {error}"))?; + let continuation = probe_continuation_tree(&drive, &contract, "resource-1") + .ok_or("continuation tree must exist after inserts")?; + check_continuation_shape(&continuation, axes, &case.child_flags) + .map_err(|error| format!("insert-materialized continuation: {error}"))?; + + // Move the second document to a fresh resource through the UPDATE + // path — the update walker materializes the resource-2 branch. + let second_document = update_review_document_resource( + &drive, + &contract, + &second_document, + "resource-2", + platform_version, + ) + .map_err(|error| format!("key-changing update must succeed: {error}"))?; + + let value_tree = probe_value_tree(&drive, &contract, "resource-1") + .ok_or("resource-1 value tree must survive the update")?; + check_value_tree_aggregates(&value_tree, axes, 1, 5) + .map_err(|error| format!("after update, old branch: {error}"))?; + let value_tree = probe_value_tree(&drive, &contract, "resource-2") + .ok_or("resource-2 value tree must exist after the update")?; + check_value_tree_aggregates(&value_tree, axes, 1, 3) + .map_err(|error| format!("after update, new branch: {error}"))?; + let continuation = probe_continuation_tree(&drive, &contract, "resource-2") + .ok_or("continuation tree must exist on the update-materialized branch")?; + check_continuation_shape(&continuation, axes, &case.child_flags) + .map_err(|error| format!("update-materialized continuation: {error}"))?; + + // Deleting each branch's last document must clean its trees away + // entirely (through the wrapped continuations). + delete_review_document(&drive, &contract, &second_document, platform_version) + .map_err(|error| format!("delete of second document must succeed: {error}"))?; + if probe_value_tree(&drive, &contract, "resource-2").is_some() { + return Err("resource-2 value tree must be cleaned up once empty".to_string()); + } + delete_review_document(&drive, &contract, &first_document, platform_version) + .map_err(|error| format!("delete of first document must succeed: {error}"))?; + if probe_value_tree(&drive, &contract, "resource-1").is_some() { + return Err("resource-1 value tree must be cleaned up once empty".to_string()); + } + Ok(()) +} + +/// The whole matrix must insert, update, and delete correctly at +/// protocol v14. Pinned to v14 explicitly (not `latest()`) so the +/// exact v14 → Drive v9 → v2-walker dispatch chain stays covered when +/// later protocol versions appear. +#[test] +fn shared_prefix_aggregate_index_combinations_insert_update_delete_at_v14() { + let platform_version = PlatformVersion::get(14).expect("platform version 14 must be known"); + + let mut failures = Vec::new(); + for case in all_cases() { + if let Err(error) = run_case_at_v14(&case, platform_version) { + failures.push(format!("{}: {error}", case.name)); + } + } assert!( failures.is_empty(), - "shared-prefix aggregate index contracts must either reject unsupported layouts at \ - contract creation or allow document insertion; incompatible accepted layouts failed for:\n{}", + "v14 shared-prefix aggregate matrix failed:\n{}", failures.join("\n") ); } + +/// Pins the consensus-frozen v1 walker behavior: at protocol v13 the +/// pre-v14 diagonal still inserts and everything else still errors. +/// If this test ever changes outcome for any case, v13 consensus has +/// drifted. +#[test] +fn shared_prefix_aggregate_index_combinations_frozen_at_v13() { + let platform_version_v13 = PlatformVersion::get(13).expect("platform version 13 must be known"); + + let mut mismatches = Vec::new(); + for case in all_cases() { + let drive = setup_drive_with_initial_state_structure(None); + let contract = build_review_contract(case.prefix_flags, case.child_flags) + .unwrap_or_else(|error| panic!("{}: contract must build: {error}", case.name)); + apply_contract(&drive, &contract, platform_version_v13) + .unwrap_or_else(|error| panic!("{}: contract must apply at v13: {error}", case.name)); + + let result = insert_review_document( + &drive, + &contract, + 1, + "resource-1", + 5, + true, + platform_version_v13, + ); + match (case.works_at_v13, result) { + (true, Err(error)) => mismatches.push(format!( + "{}: expected insert to succeed at v13, got: {error}", + case.name + )), + (false, Ok(_)) => mismatches.push(format!( + "{}: expected insert to fail at v13, but it succeeded", + case.name + )), + _ => {} + } + } + + assert!( + mismatches.is_empty(), + "v13 consensus freeze drifted:\n{}", + mismatches.join("\n") + ); +} + +/// The v2 walkers' stateless-estimation branches (`apply: false`) +/// must produce a fee without writing any state — covering the +/// post-demotion `tree_type` / `EstimatedSumTrees` layer info and the +/// stateless zero-contribution op construction. +#[test] +fn shared_prefix_aggregate_estimated_costs_do_not_write_state() { + let platform_version = PlatformVersion::get(14).expect("platform version 14 must be known"); + + // One demoted layout (provable count-bearing prefix) and one + // non-demoted aggregating layout (sum-only prefix). + let representative = [ + ( + "demoted_range_count_range_sum_parent_plain_child", + IndexFlags::RANGE_COUNT_RANGE_SUM, + IndexFlags::PLAIN, + ), + ("sum_parent_plain_child", IndexFlags::SUM, IndexFlags::PLAIN), + ]; + + for (name, prefix_flags, child_flags) in representative { + let drive = setup_drive_with_initial_state_structure(None); + let contract = build_review_contract(prefix_flags, child_flags) + .unwrap_or_else(|error| panic!("{name}: contract must build: {error}")); + apply_contract(&drive, &contract, platform_version) + .unwrap_or_else(|error| panic!("{name}: contract must apply: {error}")); + + insert_review_document( + &drive, + &contract, + 1, + "resource-1", + 5, + false, + platform_version, + ) + .unwrap_or_else(|error| panic!("{name}: estimated insert must succeed: {error}")); + + assert!( + probe_value_tree(&drive, &contract, "resource-1").is_none(), + "{name}: estimated insert must not write state" + ); + } +} + +/// A provable count-sum value tree created at v13 (through the +/// unenforced in-batch wrapper path) must keep working at v14 next to +/// newly-demoted `CountSumTree` siblings: inserts into both branches +/// keep exact aggregates, and deletes clean both away. +#[test] +fn shared_prefix_aggregate_v13_and_v14_layouts_coexist() { + let platform_version_v13 = PlatformVersion::get(13).expect("platform version 13 must be known"); + let platform_version_v14 = PlatformVersion::get(14).expect("platform version 14 must be known"); + + // countable + rangeCountable + summable prefix → ProvableCountSumTree + // value trees at v13; sum-bearing (rangeSummable) child so the shape + // is insertable at v13. + let drive = setup_drive_with_initial_state_structure(None); + let contract = build_review_contract(IndexFlags::COUNT_SUM_RANGE_COUNT, IndexFlags::RANGE_SUM) + .expect("contract must build"); + apply_contract(&drive, &contract, platform_version_v13).expect("contract must apply at v13"); + + let first_document = insert_review_document( + &drive, + &contract, + 1, + "resource-1", + 5, + true, + platform_version_v13, + ) + .expect("v13 insert must succeed"); + + let v13_tree = + probe_value_tree(&drive, &contract, "resource-1").expect("v13 value tree must exist"); + match &v13_tree { + Element::ProvableCountSumTree(_, count, sum, _) => { + assert_eq!((*count, *sum), (1, 5), "v13 provable value tree aggregates"); + } + other => panic!("expected ProvableCountSumTree at v13, got {other:?}"), + } + + // v14 insert into the EXISTING v13-created provable branch. + let second_document = insert_review_document( + &drive, + &contract, + 2, + "resource-1", + 3, + true, + platform_version_v14, + ) + .expect("v14 insert into the v13 branch must succeed"); + let v13_tree = + probe_value_tree(&drive, &contract, "resource-1").expect("v13 value tree must survive"); + match &v13_tree { + Element::ProvableCountSumTree(_, count, sum, _) => { + assert_eq!( + (*count, *sum), + (2, 8), + "v13-created provable value tree must keep exact aggregates at v14" + ); + } + other => panic!("v13-created value tree must keep its type at v14, got {other:?}"), + } + + // v14 insert materializing a NEW branch — demoted CountSumTree. + let third_document = insert_review_document( + &drive, + &contract, + 3, + "resource-2", + 4, + true, + platform_version_v14, + ) + .expect("v14 insert into a new branch must succeed"); + let v14_tree = + probe_value_tree(&drive, &contract, "resource-2").expect("v14 value tree must exist"); + match &v14_tree { + Element::CountSumTree(_, count, sum, _) => { + assert_eq!((*count, *sum), (1, 4), "v14 demoted value tree aggregates"); + } + other => panic!("expected demoted CountSumTree at v14, got {other:?}"), + } + + // Deletes at v14 must clean both layouts away entirely. + for document in [&first_document, &second_document] { + delete_review_document(&drive, &contract, document, platform_version_v14) + .expect("v14 delete from the v13 branch must succeed"); + } + assert!( + probe_value_tree(&drive, &contract, "resource-1").is_none(), + "v13-created value tree must be cleaned up once empty" + ); + delete_review_document(&drive, &contract, &third_document, platform_version_v14) + .expect("v14 delete from the v14 branch must succeed"); + assert!( + probe_value_tree(&drive, &contract, "resource-2").is_none(), + "v14-created value tree must be cleaned up once empty" + ); +} diff --git a/packages/rs-drive/src/drive/document/delete/remove_indices_for_index_level_for_contract_operations/mod.rs b/packages/rs-drive/src/drive/document/delete/remove_indices_for_index_level_for_contract_operations/mod.rs index 2944414fd9f..85ddd3b9b51 100644 --- a/packages/rs-drive/src/drive/document/delete/remove_indices_for_index_level_for_contract_operations/mod.rs +++ b/packages/rs-drive/src/drive/document/delete/remove_indices_for_index_level_for_contract_operations/mod.rs @@ -1,5 +1,6 @@ mod v0; mod v1; +mod v2; use crate::drive::Drive; use crate::error::drive::DriveError; use crate::error::Error; @@ -107,9 +108,26 @@ impl Drive { batch_operations, platform_version, ), + // v2 (platform v14+): tree types via the shared + // continuation-demotion helper, mirroring the v2 insert walker. + 2 => self.remove_indices_for_index_level_for_contract_operations_v2( + document_and_contract_info, + index_path_info, + index_level, + any_fields_null, + all_fields_null, + parent_value_tree_type, + storage_flags, + previous_batch_operations, + estimated_costs_only_with_layer_info, + event_id, + transaction, + batch_operations, + platform_version, + ), version => Err(Error::Drive(DriveError::UnknownVersionMismatch { method: "remove_indices_for_index_level_for_contract_operations".to_string(), - known_versions: vec![0, 1], + known_versions: vec![0, 1, 2], received: version, })), } diff --git a/packages/rs-drive/src/drive/document/delete/remove_indices_for_index_level_for_contract_operations/v2/mod.rs b/packages/rs-drive/src/drive/document/delete/remove_indices_for_index_level_for_contract_operations/v2/mod.rs new file mode 100644 index 00000000000..1d817df50a4 --- /dev/null +++ b/packages/rs-drive/src/drive/document/delete/remove_indices_for_index_level_for_contract_operations/v2/mod.rs @@ -0,0 +1,186 @@ +use grovedb::batch::KeyInfoPath; + +use grovedb::EstimatedLayerCount::{ApproximateElements, PotentiallyAtMaxElements}; +use grovedb::EstimatedLayerSizes::AllSubtrees; +use grovedb::{EstimatedLayerInformation, TransactionArg, TreeType}; + +use dpp::data_contract::document_type::IndexLevel; + +use grovedb::EstimatedSumTrees::NoSumTrees; +use std::collections::HashMap; + +use crate::drive::document::estimation_costs::estimated_sum_trees_for_value_tree_type::estimated_sum_trees_for_value_tree_type; +use crate::drive::document::index_level_tree_types::index_level_tree_types_with_continuation_demotion; +use crate::util::type_constants::DEFAULT_HASH_SIZE_U8; + +use crate::util::storage_flags::StorageFlags; + +use crate::util::object_size_info::DriveKeyInfo::KeyRef; + +use crate::drive::Drive; +use crate::util::object_size_info::{DocumentAndContractInfo, DocumentInfoV0Methods, PathInfo}; + +use crate::error::fee::FeeError; +use crate::error::Error; +use crate::fees::op::LowLevelDriveOperation; + +use dpp::version::PlatformVersion; + +impl Drive { + /// Removes indices for an index level and recurses. + /// + /// v2 derives tree types through the shared + /// [`index_level_tree_types_with_continuation_demotion`] helper so + /// the estimation layer info describes the exact on-disk shape the + /// v2 insert walker writes — including the continuation demotion of + /// provable count-bearing value trees to `CountSumTree`. Must stay + /// in lockstep with + /// [`Drive::add_indices_for_index_level_for_contract_operations_v2`]; + /// part of the platform v14 shared-prefix aggregate fix. + /// + /// The delete path constructs no wrapper elements itself — grovedb + /// looks through `NonCounted` / `NotSummed` / `NotCountedOrSummed` + /// when deleting trees and subtracts the stored (zero) feature + /// contribution, so only the tree-type derivation needs to mirror + /// the insert side. + #[inline] + #[allow(clippy::too_many_arguments)] + pub(super) fn remove_indices_for_index_level_for_contract_operations_v2( + &self, + document_and_contract_info: &DocumentAndContractInfo, + index_path_info: PathInfo<0>, + index_level: &IndexLevel, + mut any_fields_null: bool, + mut all_fields_null: bool, + parent_value_tree_type: TreeType, + storage_flags: &Option<&StorageFlags>, + previous_batch_operations: &Option<&mut Vec>, + estimated_costs_only_with_layer_info: &mut Option< + HashMap, + >, + event_id: [u8; 32], + transaction: TransactionArg, + batch_operations: &mut Vec, + platform_version: &PlatformVersion, + ) -> Result<(), Error> { + let sub_level_index_count = index_level.sub_levels().len() as u32; + + if let Some(estimated_costs_only_with_layer_info) = estimated_costs_only_with_layer_info { + // On this level we will have a 0 and all the top index paths. + // `parent_value_tree_type` carries the (post-demotion) + // TreeType the v2 insert walker actually wrote. + estimated_costs_only_with_layer_info.insert( + index_path_info.clone().convert_to_key_info_path(), + EstimatedLayerInformation { + tree_type: parent_value_tree_type, + estimated_layer_count: ApproximateElements(sub_level_index_count + 1), + estimated_layer_sizes: AllSubtrees( + DEFAULT_HASH_SIZE_U8, + NoSumTrees, + storage_flags.map(|s| s.serialized_size()), + ), + }, + ); + } + + if let Some(index_type) = index_level.has_index_with_type() { + self.remove_reference_for_index_level_for_contract_operations( + document_and_contract_info, + index_path_info.clone(), + index_type, + any_fields_null, + all_fields_null, + storage_flags, + previous_batch_operations, + estimated_costs_only_with_layer_info, + event_id, + transaction, + batch_operations, + platform_version, + )?; + } + + let document_type = document_and_contract_info.document_type; + + // fourth we need to store a reference to the document for each index + for (name, sub_level) in index_level.sub_levels() { + let tree_types = index_level_tree_types_with_continuation_demotion(sub_level); + let property_name_tree_type = tree_types.property_name_tree_type; + let value_tree_type = tree_types.value_tree_type; + + let mut sub_level_index_path_info = index_path_info.clone(); + let index_property_key = KeyRef(name.as_bytes()); + + let document_index_field = document_and_contract_info + .owned_document_info + .document_info + .get_raw_for_document_type( + name, + document_type, + document_and_contract_info.owned_document_info.owner_id, + Some((sub_level, event_id)), + platform_version, + )? + .unwrap_or_default(); + + sub_level_index_path_info.push(index_property_key)?; + + if let Some(estimated_costs_only_with_layer_info) = estimated_costs_only_with_layer_info + { + let document_top_field_estimated_size = document_and_contract_info + .owned_document_info + .document_info + .get_estimated_size_for_document_type(name, document_type, platform_version)?; + + if document_top_field_estimated_size > u8::MAX as u16 { + return Err(Error::Fee(FeeError::Overflow( + "document field is too big for being an index", + ))); + } + + // The property-name layer's children are value trees of + // type `value_tree_type` (post-demotion — matching what + // the v2 insert walker actually writes). + estimated_costs_only_with_layer_info.insert( + sub_level_index_path_info.clone().convert_to_key_info_path(), + EstimatedLayerInformation { + tree_type: property_name_tree_type, + estimated_layer_count: PotentiallyAtMaxElements, + estimated_layer_sizes: AllSubtrees( + document_top_field_estimated_size as u8, + estimated_sum_trees_for_value_tree_type(value_tree_type), + storage_flags.map(|s| s.serialized_size()), + ), + }, + ); + } + + // Iteration 1. the index path is now something likeDataContracts/ContractID/Documents(1)/$ownerId//toUserId + // Iteration 2. the index path is now something likeDataContracts/ContractID/Documents(1)/$ownerId//toUserId//accountReference + + any_fields_null |= document_index_field.is_empty(); + all_fields_null &= document_index_field.is_empty(); + + // we push the actual value of the index path + sub_level_index_path_info.push(document_index_field)?; + // Iteration 1. the index path is now something likeDataContracts/ContractID/Documents(1)/$ownerId//toUserId// + // Iteration 2. the index path is now something likeDataContracts/ContractID/Documents(1)/$ownerId//toUserId//accountReference/ + self.remove_indices_for_index_level_for_contract_operations_v2( + document_and_contract_info, + sub_level_index_path_info, + sub_level, + any_fields_null, + all_fields_null, + value_tree_type, + storage_flags, + previous_batch_operations, + estimated_costs_only_with_layer_info, + event_id, + transaction, + batch_operations, + platform_version, + )?; + } + Ok(()) + } +} diff --git a/packages/rs-drive/src/drive/document/delete/remove_indices_for_top_index_level_for_contract_operations/mod.rs b/packages/rs-drive/src/drive/document/delete/remove_indices_for_top_index_level_for_contract_operations/mod.rs index 804c93e0769..65e0d8bd98e 100644 --- a/packages/rs-drive/src/drive/document/delete/remove_indices_for_top_index_level_for_contract_operations/mod.rs +++ b/packages/rs-drive/src/drive/document/delete/remove_indices_for_top_index_level_for_contract_operations/mod.rs @@ -1,5 +1,6 @@ mod v0; mod v1; +mod v2; use crate::drive::Drive; use crate::util::object_size_info::DocumentAndContractInfo; @@ -64,9 +65,19 @@ impl Drive { batch_operations, platform_version, ), + // v2 (platform v14+): tree types via the shared + // continuation-demotion helper, mirroring the v2 insert walker. + 2 => self.remove_indices_for_top_index_level_for_contract_operations_v2( + document_and_contract_info, + previous_batch_operations, + estimated_costs_only_with_layer_info, + transaction, + batch_operations, + platform_version, + ), version => Err(Error::Drive(DriveError::UnknownVersionMismatch { method: "remove_indices_for_top_index_level_for_contract_operations".to_string(), - known_versions: vec![0, 1], + known_versions: vec![0, 1, 2], received: version, })), } diff --git a/packages/rs-drive/src/drive/document/delete/remove_indices_for_top_index_level_for_contract_operations/v2/mod.rs b/packages/rs-drive/src/drive/document/delete/remove_indices_for_top_index_level_for_contract_operations/v2/mod.rs new file mode 100644 index 00000000000..267c7f94656 --- /dev/null +++ b/packages/rs-drive/src/drive/document/delete/remove_indices_for_top_index_level_for_contract_operations/v2/mod.rs @@ -0,0 +1,186 @@ +use grovedb::batch::KeyInfoPath; + +use grovedb::EstimatedLayerCount::{ApproximateElements, PotentiallyAtMaxElements}; +use grovedb::EstimatedLayerSizes::AllSubtrees; +use grovedb::{EstimatedLayerInformation, TransactionArg, TreeType}; + +use grovedb::EstimatedSumTrees::NoSumTrees; +use std::collections::HashMap; + +use crate::drive::document::estimation_costs::estimated_sum_trees_for_value_tree_type::estimated_sum_trees_for_value_tree_type; +use crate::drive::document::index_level_tree_types::index_level_tree_types_with_continuation_demotion; +use crate::drive::document::unique_event_id; +use crate::util::type_constants::DEFAULT_HASH_SIZE_U8; + +use crate::drive::Drive; +use crate::util::object_size_info::{DocumentAndContractInfo, DocumentInfoV0Methods, PathInfo}; + +use crate::error::fee::FeeError; +use crate::error::Error; +use crate::fees::op::LowLevelDriveOperation; + +use dpp::data_contract::accessors::v0::DataContractV0Getters; +use dpp::data_contract::config::v0::DataContractConfigGettersV0; +use dpp::data_contract::document_type::accessors::DocumentTypeV0Getters; + +use crate::drive::document::paths::contract_document_type_path_vec; +use dpp::version::PlatformVersion; + +impl Drive { + /// Removes indices for the top index level and calls for lower levels. + /// + /// v2 derives tree types through the shared + /// [`index_level_tree_types_with_continuation_demotion`] helper so + /// the estimation layer info describes the exact on-disk shape the + /// v2 insert walker writes — including the continuation demotion of + /// provable count-bearing value trees to `CountSumTree`. Must stay + /// in lockstep with + /// [`Drive::add_indices_for_top_index_level_for_contract_operations_v2`]; + /// part of the platform v14 shared-prefix aggregate fix. + #[inline(always)] + pub(super) fn remove_indices_for_top_index_level_for_contract_operations_v2( + &self, + document_and_contract_info: &DocumentAndContractInfo, + previous_batch_operations: &Option<&mut Vec>, + estimated_costs_only_with_layer_info: &mut Option< + HashMap, + >, + transaction: TransactionArg, + batch_operations: &mut Vec, + platform_version: &PlatformVersion, + ) -> Result<(), Error> { + let document_type = document_and_contract_info.document_type; + let index_level = document_type.index_structure(); + let contract = document_and_contract_info.contract; + let event_id = unique_event_id(); + let storage_flags = + if document_type.documents_mutable() || contract.config().can_be_deleted() { + document_and_contract_info + .owned_document_info + .document_info + .get_storage_flags_ref() + } else { + None //there are no need for storage flags if documents are not mutable and contract can not be deleted + }; + + // we need to construct the path for documents on the contract + // the path is + // * Document andDataContract root tree + // *DataContract ID recovered from document + // * 0 to signify Documents and notDataContract + let contract_document_type_path = contract_document_type_path_vec( + document_and_contract_info.contract.id_ref().as_bytes(), + document_and_contract_info.document_type.name().as_str(), + ); + + let sub_level_index_count = index_level.sub_levels().len() as u32; + + if let Some(estimated_costs_only_with_layer_info) = estimated_costs_only_with_layer_info { + // On this level we will have a 0 and all the top index paths + estimated_costs_only_with_layer_info.insert( + KeyInfoPath::from_known_owned_path(contract_document_type_path.clone()), + EstimatedLayerInformation { + tree_type: TreeType::NormalTree, + estimated_layer_count: ApproximateElements(sub_level_index_count + 1), + estimated_layer_sizes: AllSubtrees( + DEFAULT_HASH_SIZE_U8, + NoSumTrees, + storage_flags.map(|s| s.serialized_size()), + ), + }, + ); + } + + // next we need to store a reference to the document for each index + for (name, sub_level) in index_level.sub_levels() { + let tree_types = index_level_tree_types_with_continuation_demotion(sub_level); + let property_name_tree_type = tree_types.property_name_tree_type; + let value_tree_type = tree_types.value_tree_type; + + // at this point the contract path is to the contract documents + // for each index the top index component will already have been added + // when the contract itself was created + let mut index_path: Vec> = contract_document_type_path.clone(); + index_path.push(Vec::from(name.as_bytes())); + + // with the example of the dashpay contract's first index + // the index path is now something likeDataContracts/ContractID/Documents(1)/$ownerId + let document_top_field = document_and_contract_info + .owned_document_info + .document_info + .get_raw_for_document_type( + name, + document_type, + document_and_contract_info.owned_document_info.owner_id, + Some((sub_level, event_id)), + platform_version, + )? + .unwrap_or_default(); + + if let Some(estimated_costs_only_with_layer_info) = estimated_costs_only_with_layer_info + { + let document_top_field_estimated_size = document_and_contract_info + .owned_document_info + .document_info + .get_estimated_size_for_document_type(name, document_type, platform_version)?; + + if document_top_field_estimated_size > u8::MAX as u16 { + return Err(Error::Fee(FeeError::Overflow( + "document top field is too big for being an index", + ))); + } + + // The property-name layer's children are value trees of + // type `value_tree_type` (post-demotion — matching what + // the v2 insert walker actually writes). + estimated_costs_only_with_layer_info.insert( + KeyInfoPath::from_known_owned_path(index_path.clone()), + EstimatedLayerInformation { + tree_type: property_name_tree_type, + estimated_layer_count: PotentiallyAtMaxElements, + estimated_layer_sizes: AllSubtrees( + document_top_field_estimated_size as u8, + estimated_sum_trees_for_value_tree_type(value_tree_type), + storage_flags.map(|s| s.serialized_size()), + ), + }, + ); + } + + let any_fields_null = document_top_field.is_empty(); + let all_fields_null = document_top_field.is_empty(); + + let mut index_path_info = if document_and_contract_info + .owned_document_info + .document_info + .is_document_size() + { + // This is a stateless operation + PathInfo::PathWithSizes(KeyInfoPath::from_known_owned_path(index_path)) + } else { + PathInfo::PathAsVec::<0>(index_path) + }; + + // we push the actual value of the index path + index_path_info.push(document_top_field)?; + // the index path is now something likeDataContracts/ContractID/Documents(1)/$ownerId/ + + self.remove_indices_for_index_level_for_contract_operations( + document_and_contract_info, + index_path_info, + sub_level, + any_fields_null, + all_fields_null, + value_tree_type, + &storage_flags, + previous_batch_operations, + estimated_costs_only_with_layer_info, + event_id, + transaction, + batch_operations, + platform_version, + )?; + } + Ok(()) + } +} diff --git a/packages/rs-drive/src/drive/document/index_level_tree_types.rs b/packages/rs-drive/src/drive/document/index_level_tree_types.rs new file mode 100644 index 00000000000..f6914e5bb89 --- /dev/null +++ b/packages/rs-drive/src/drive/document/index_level_tree_types.rs @@ -0,0 +1,253 @@ +//! Shared index-walker tree-type derivation for the v2 walkers. +//! +//! The four v2 index walkers (insert/delete × top-level/recursive) must +//! agree byte-for-byte on which grovedb `TreeType` each property-name +//! tree and each per-value tree gets: the insert side writes the trees +//! and the delete side emits `EstimatedLayerInformation` describing +//! them, and any drift between the two produces dry-run fees that +//! disagree with applied fees. The v1 walkers each carried a private +//! copy of the dispatch tables; v2 centralizes them here so the four +//! call sites cannot drift. +//! +//! ## The continuation demotion (new in v2) +//! +//! v2 exists to fix the shared-prefix aggregate layout defect (a +//! contract declaring an aggregating index `[a]` next to a compound +//! index `[a, b]` registered fine but rejected most document inserts). +//! Continuation property-name trees (`b`) are stored as children of +//! the aggregating value trees of `[a]`, and must contribute zero to +//! every axis the value tree aggregates. grovedb's provable +//! count-bearing trees (`ProvableCountSumTree`, +//! `ProvableCountProvableSumTree`) commit their count into every node +//! hash and therefore reject count-suppressed (`NonCounted` / +//! `NotCountedOrSummed`) children *by design* — and there is no legal +//! wrapper at all for a plain continuation under them +//! (`Element::new_not_counted_or_summed` requires a sum-bearing +//! inner). So when a sub-level has continuations, v2 demotes those two +//! value-tree variants to plain `CountSumTree`, whose count/sum live +//! in the element (not the node hashes) and which accepts suppressed +//! children. +//! +//! The demotion loses nothing observable: point-lookup count/sum +//! proofs read the aggregate off the value-tree *element* (proven by +//! inclusion in the parent merk) for provable and non-provable +//! variants alike, and the range-aggregate queries +//! (`AggregateCountOnRange` / `AggregateSumOnRange`) walk the +//! *property-name* tree one level up, to which a `CountSumTree` child +//! contributes its (count, sum) exactly like a provable child would. +//! Per-node commitments *inside* a value tree would only matter for +//! range aggregation over the value tree's own children (the `[0]` +//! ref-bucket and sibling continuations) — a query no reader +//! performs. +//! +//! Gating the demotion on "has continuations" keeps v2 bit-identical +//! to v1 for every shape without a compound sibling. One caveat for +//! shapes WITH one: pre-v14, a provable count-bearing value tree with +//! exclusively sum-bearing continuations could actually be inserted — +//! grovedb's wrapper-vs-provable guard fires only when the parent merk +//! pre-exists, and the walker always creates parent and wrapped child +//! in one batch. Contracts that used that hole (possible only since +//! the v13 sum-index grammar activated) keep their existing provable +//! value trees; values first seen at v14+ get `CountSumTree` ones. +//! Readers are indifferent — both variants serialize their (count, +//! sum) into the element and contribute identically to the +//! property-name tree's per-node aggregates — but the demotion means +//! new writes no longer depend on the unenforced guard hole. + +use dpp::data_contract::document_type::IndexLevel; +use grovedb::TreeType; + +/// The two tree types an index sub-level materializes: the +/// property-name tree (keys = the property's distinct values) and the +/// per-value trees underneath it (hosting the `[0]` ref-bucket plus +/// any continuation property-name trees). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) struct IndexLevelTreeTypes { + /// Tree type of the property-name tree. Upgraded from + /// `NormalTree` only by the `range*` flags, which opt into + /// per-node aggregate commitments for range-aggregate proofs. + pub property_name_tree_type: TreeType, + /// Tree type of each per-value tree. Aggregating whenever the + /// sub-level terminates a countable and/or summable index, with + /// the continuation demotion applied (see module docs). + pub value_tree_type: TreeType, +} + +/// Derives both tree types for an index sub-level, applying the +/// continuation demotion. This is the single source of truth for the +/// v2 walkers; the v1 tables live inline in the (consensus-frozen) v1 +/// walker modules. +pub(crate) fn index_level_tree_types_with_continuation_demotion( + sub_level: &IndexLevel, +) -> IndexLevelTreeTypes { + let info = sub_level.has_index_with_type(); + derive_index_level_tree_types( + info.map(|i| i.countable.is_countable()).unwrap_or(false), + info.map(|i| i.range_countable).unwrap_or(false), + info.map(|i| i.summable.is_some()).unwrap_or(false), + info.map(|i| i.range_summable).unwrap_or(false), + !sub_level.sub_levels().is_empty(), + ) +} + +/// Pure derivation over the level's four terminator flags plus whether +/// continuations hang beneath its value trees. Split out so the full +/// input space is unit-testable without constructing `IndexLevel`s. +fn derive_index_level_tree_types( + countable_terminator: bool, + range_countable: bool, + summable_terminator: bool, + range_summable: bool, + has_continuations: bool, +) -> IndexLevelTreeTypes { + let property_name_tree_type = match (range_countable, range_summable) { + (true, true) => TreeType::ProvableCountProvableSumTree, + (true, false) => TreeType::ProvableCountTree, + (false, true) => TreeType::ProvableSumTree, + (false, false) => TreeType::NormalTree, + }; + + // Same dispatch table as the v1 walkers. + let value_tree_type = match ( + countable_terminator, + range_countable, + summable_terminator, + range_summable, + ) { + (true, true, true, true) => TreeType::ProvableCountProvableSumTree, + (true, false, true, false) => TreeType::CountSumTree, + (true, true, true, false) => TreeType::ProvableCountSumTree, + (true, false, true, true) => TreeType::ProvableCountProvableSumTree, + (true, _, false, false) => TreeType::CountTree, + (false, false, true, _) => TreeType::SumTree, + (false, _, false, _) => TreeType::NormalTree, + _ => TreeType::NormalTree, + }; + + let value_tree_type = if has_continuations { + match value_tree_type { + TreeType::ProvableCountSumTree | TreeType::ProvableCountProvableSumTree => { + TreeType::CountSumTree + } + other => other, + } + } else { + value_tree_type + }; + + IndexLevelTreeTypes { + property_name_tree_type, + value_tree_type, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::fees::op::LowLevelDriveOperation; + + fn all_flag_combinations() -> impl Iterator { + (0u8..16).map(|bits| (bits & 1 != 0, bits & 2 != 0, bits & 4 != 0, bits & 8 != 0)) + } + + /// The load-bearing cross-module invariant: whenever continuations + /// exist, the derived value-tree type must be accepted as a parent + /// by the zero-contribution dispatcher for every continuation + /// property-name tree type the derivation can produce. A future + /// edit to either table that breaks this surfaces here instead of + /// as a `NotSupported` insert failure at v14. + #[test] + fn demoted_value_trees_are_accepted_zero_contribution_parents() { + // Every property-name (continuation) tree type the derivation + // can produce for a child level. + let possible_continuations = [ + TreeType::NormalTree, + TreeType::ProvableCountTree, + TreeType::ProvableSumTree, + TreeType::ProvableCountProvableSumTree, + ]; + + for (countable, range_countable, summable, range_summable) in all_flag_combinations() { + let with_continuations = derive_index_level_tree_types( + countable, + range_countable, + summable, + range_summable, + true, + ); + + // Provable count-bearing value trees must never host + // continuations — grovedb rejects count-suppressed + // children under them. + assert!( + !matches!( + with_continuations.value_tree_type, + TreeType::ProvableCountTree + | TreeType::ProvableCountSumTree + | TreeType::ProvableCountProvableSumTree + ), + "flags ({countable}, {range_countable}, {summable}, {range_summable}): \ + value tree with continuations must not be provable count-bearing, got {:?}", + with_continuations.value_tree_type + ); + + if matches!(with_continuations.value_tree_type, TreeType::NormalTree) { + // Non-aggregating parents take the plain insert path. + continue; + } + for continuation in possible_continuations { + LowLevelDriveOperation::for_known_path_key_empty_tree_contributing_zero_to_parent( + vec![b"path".to_vec()], + b"key".to_vec(), + with_continuations.value_tree_type, + continuation, + None, + ) + .unwrap_or_else(|error| { + panic!( + "flags ({countable}, {range_countable}, {summable}, {range_summable}): \ + dispatcher must accept parent {:?} with continuation {continuation:?}: \ + {error}", + with_continuations.value_tree_type + ) + }); + } + } + } + + /// Without continuations, the derivation must match the v1 + /// walkers' (consensus-frozen) tables exactly — restated here + /// literally as the frozen expectation. + #[test] + fn derivation_without_continuations_matches_v1_tables() { + for (countable, range_countable, summable, range_summable) in all_flag_combinations() { + let derived = derive_index_level_tree_types( + countable, + range_countable, + summable, + range_summable, + false, + ); + + let expected_property = match (range_countable, range_summable) { + (true, true) => TreeType::ProvableCountProvableSumTree, + (true, false) => TreeType::ProvableCountTree, + (false, true) => TreeType::ProvableSumTree, + (false, false) => TreeType::NormalTree, + }; + let expected_value = match (countable, range_countable, summable, range_summable) { + (true, true, true, true) => TreeType::ProvableCountProvableSumTree, + (true, false, true, false) => TreeType::CountSumTree, + (true, true, true, false) => TreeType::ProvableCountSumTree, + (true, false, true, true) => TreeType::ProvableCountProvableSumTree, + (true, _, false, false) => TreeType::CountTree, + (false, false, true, _) => TreeType::SumTree, + (false, _, false, _) => TreeType::NormalTree, + _ => TreeType::NormalTree, + }; + + assert_eq!(derived.property_name_tree_type, expected_property); + assert_eq!(derived.value_tree_type, expected_value); + } + } +} diff --git a/packages/rs-drive/src/drive/document/insert/add_indices_for_index_level_for_contract_operations/mod.rs b/packages/rs-drive/src/drive/document/insert/add_indices_for_index_level_for_contract_operations/mod.rs index 9669273ccf0..214dc33eb6c 100644 --- a/packages/rs-drive/src/drive/document/insert/add_indices_for_index_level_for_contract_operations/mod.rs +++ b/packages/rs-drive/src/drive/document/insert/add_indices_for_index_level_for_contract_operations/mod.rs @@ -1,5 +1,6 @@ mod v0; mod v1; +mod v2; use crate::util::storage_flags::StorageFlags; @@ -99,9 +100,26 @@ impl Drive { batch_operations, platform_version, ), + // v2 (platform v14+): shared-prefix aggregate layouts become + // insertable — continuation demotion + completed wrapper matrix. + 2 => self.add_indices_for_index_level_for_contract_operations_v2( + document_and_contract_info, + index_path_info, + index_level, + any_fields_null, + all_fields_null, + parent_value_tree_type, + previous_batch_operations, + storage_flags, + estimated_costs_only_with_layer_info, + event_id, + transaction, + batch_operations, + platform_version, + ), version => Err(Error::Drive(DriveError::UnknownVersionMismatch { method: "add_indices_for_index_level_for_contract_operations".to_string(), - known_versions: vec![0, 1], + known_versions: vec![0, 1, 2], received: version, })), } diff --git a/packages/rs-drive/src/drive/document/insert/add_indices_for_index_level_for_contract_operations/v2/mod.rs b/packages/rs-drive/src/drive/document/insert/add_indices_for_index_level_for_contract_operations/v2/mod.rs new file mode 100644 index 00000000000..183a6e8758e --- /dev/null +++ b/packages/rs-drive/src/drive/document/insert/add_indices_for_index_level_for_contract_operations/v2/mod.rs @@ -0,0 +1,292 @@ +use crate::drive::document::estimation_costs::estimated_sum_trees_for_value_tree_type::estimated_sum_trees_for_value_tree_type; +use crate::drive::document::index_level_tree_types::index_level_tree_types_with_continuation_demotion; +use crate::drive::Drive; +use crate::error::fee::FeeError; +use crate::error::Error; +use crate::fees::op::LowLevelDriveOperation; +use crate::util::grove_operations::BatchInsertTreeApplyType; +use crate::util::object_size_info::DriveKeyInfo::KeyRef; +use crate::util::object_size_info::{DocumentAndContractInfo, DocumentInfoV0Methods, PathInfo}; +use crate::util::storage_flags::StorageFlags; +use crate::util::type_constants::DEFAULT_HASH_SIZE_U8; +use dpp::data_contract::document_type::IndexLevel; + +use dpp::version::PlatformVersion; +use grovedb::batch::KeyInfoPath; +use grovedb::EstimatedLayerCount::{ApproximateElements, PotentiallyAtMaxElements}; +use grovedb::EstimatedLayerSizes::AllSubtrees; +use grovedb::EstimatedSumTrees::NoSumTrees; +use grovedb::{EstimatedLayerInformation, TransactionArg, TreeType}; +use std::collections::HashMap; + +impl Drive { + /// Adds indices for an index level and recurses. + /// + /// v2 fixes the shared-prefix aggregate layout defect: with v1, a + /// contract declaring an aggregating index `[a]` next to a compound + /// index `[a, b]` registered fine but rejected every document + /// insert, because the continuation property-name tree (`b`) could + /// not be legally hung under `[a]`'s aggregating value trees for + /// most flag combinations. Two changes, both consensus-affecting + /// and therefore gated to platform v14+: + /// + /// 1. **Tree-type derivation** moves to the shared + /// [`index_level_tree_types_with_continuation_demotion`] helper, + /// which demotes provable count-bearing value trees + /// (`ProvableCountSumTree` / `ProvableCountProvableSumTree`) to + /// `CountSumTree` when the sub-level has continuations — grovedb + /// rejects count-suppressed children under provable count + /// parents by design, so no wrapper could ever be legal there. + /// 2. **Continuation wrapping** goes through + /// [`Drive::batch_insert_empty_tree_contributing_zero_to_aggregating_parent_if_not_exists`], + /// which completes the parent×inner wrapper matrix that v1's + /// diagonal-only dispatcher rejected: non-sum continuations + /// under `CountSumTree` parents get `Element::NonCounted`, + /// non-sum continuations under sum-only parents are inserted + /// unwrapped (they contribute 0 to a sum naturally), and + /// sum-bearing continuations under count-only parents get + /// `Element::NonCounted` too. + /// + /// For every shape without a compound sibling under an aggregating + /// terminator, both changes are bit-identical no-ops. The one + /// intentional difference for previously-insertable shapes: a + /// provable count-bearing value tree whose continuations were all + /// sum-bearing could be inserted pre-v14 (grovedb's + /// wrapper-vs-provable guard fires only when the parent merk + /// pre-exists, which the walker's create-in-one-batch pattern never + /// triggers); at v14+ such values get demoted `CountSumTree` value + /// trees instead, so new writes stop depending on that unenforced + /// guard hole. Existing provable value trees keep working — see + /// `crate::drive::document::index_level_tree_types` for why readers + /// are indifferent. + /// + /// The invariant both changes preserve: every value tree's per-axis + /// aggregates equal exactly the contribution of its `[0]` + /// ref-bucket, never the structural overhead of sibling compound + /// continuations. + /// + /// See v1's docs for the underlying value-tree / property-name-tree + /// design (what "countable" gates versus "range_countable", etc.); + /// everything not listed above matches v1. + #[inline] + #[allow(clippy::too_many_arguments)] + pub(super) fn add_indices_for_index_level_for_contract_operations_v2( + &self, + document_and_contract_info: &DocumentAndContractInfo, + index_path_info: PathInfo<0>, + index_level: &IndexLevel, + mut any_fields_null: bool, + mut all_fields_null: bool, + parent_value_tree_type: TreeType, + previous_batch_operations: &mut Option<&mut Vec>, + storage_flags: &Option<&StorageFlags>, + estimated_costs_only_with_layer_info: &mut Option< + HashMap, + >, + event_id: [u8; 32], + transaction: TransactionArg, + batch_operations: &mut Vec, + platform_version: &PlatformVersion, + ) -> Result<(), Error> { + if let Some(index_type) = index_level.has_index_with_type() { + self.add_reference_for_index_level_for_contract_operations( + document_and_contract_info, + index_path_info.clone(), + index_type, + any_fields_null, + all_fields_null, + previous_batch_operations, + storage_flags, + estimated_costs_only_with_layer_info, + transaction, + batch_operations, + &platform_version.drive, + )?; + } + + let document_type = document_and_contract_info.document_type; + + let sub_level_index_count = index_level.sub_levels().len() as u32; + + // The current level (the value tree at index_path_info) has + // exactly the TreeType the caller already computed — pass it + // through so the layer info, the recursive call, and the + // wrapper-choice for child continuations all agree on the + // exact variant. + let current_layer_tree_type = parent_value_tree_type; + // True iff the parent value tree aggregates anything (count, + // sum, or both) — decides whether continuation children go + // through the zero-contribution helper or the plain one. + let parent_value_tree_aggregates = !matches!(parent_value_tree_type, TreeType::NormalTree); + + if let Some(estimated_costs_only_with_layer_info) = estimated_costs_only_with_layer_info { + // On this level we will have a 0 and all the top index paths + estimated_costs_only_with_layer_info.insert( + index_path_info.clone().convert_to_key_info_path(), + EstimatedLayerInformation { + tree_type: current_layer_tree_type, + estimated_layer_count: ApproximateElements(sub_level_index_count + 1), + estimated_layer_sizes: AllSubtrees( + DEFAULT_HASH_SIZE_U8, + NoSumTrees, + storage_flags.map(|s| s.serialized_size()), + ), + }, + ); + } + + // fourth we need to store a reference to the document for each index + for (name, sub_level) in index_level.sub_levels() { + let tree_types = index_level_tree_types_with_continuation_demotion(sub_level); + let property_name_tree_type = tree_types.property_name_tree_type; + let value_tree_type = tree_types.value_tree_type; + + let property_name_apply_type = if estimated_costs_only_with_layer_info.is_none() { + BatchInsertTreeApplyType::StatefulBatchInsertTree + } else { + BatchInsertTreeApplyType::StatelessBatchInsertTree { + in_tree_type: current_layer_tree_type, + tree_type: property_name_tree_type, + flags_len: storage_flags + .map(|s| s.serialized_size()) + .unwrap_or_default(), + } + }; + + let value_apply_type = if estimated_costs_only_with_layer_info.is_none() { + BatchInsertTreeApplyType::StatefulBatchInsertTree + } else { + BatchInsertTreeApplyType::StatelessBatchInsertTree { + in_tree_type: property_name_tree_type, + tree_type: value_tree_type, + flags_len: storage_flags + .map(|s| s.serialized_size()) + .unwrap_or_default(), + } + }; + + let mut sub_level_index_path_info = index_path_info.clone(); + let index_property_key = KeyRef(name.as_bytes()); + + let document_index_field = document_and_contract_info + .owned_document_info + .document_info + .get_raw_for_document_type( + name, + document_type, + document_and_contract_info.owned_document_info.owner_id, + Some((sub_level, event_id)), + platform_version, + )? + .unwrap_or_default(); + + let path_key_info = index_property_key + .clone() + .add_path_info(sub_level_index_path_info.clone()); + + // here we are inserting an empty tree that will have a subtree of all other index properties + if parent_value_tree_aggregates { + self.batch_insert_empty_tree_contributing_zero_to_aggregating_parent_if_not_exists( + path_key_info.clone(), + parent_value_tree_type, + property_name_tree_type, + *storage_flags, + property_name_apply_type, + transaction, + previous_batch_operations, + batch_operations, + &platform_version.drive, + )?; + } else { + self.batch_insert_empty_tree_if_not_exists( + path_key_info.clone(), + property_name_tree_type, + *storage_flags, + property_name_apply_type, + transaction, + previous_batch_operations, + batch_operations, + &platform_version.drive, + )?; + } + + sub_level_index_path_info.push(index_property_key)?; + + if let Some(estimated_costs_only_with_layer_info) = estimated_costs_only_with_layer_info + { + let document_top_field_estimated_size = document_and_contract_info + .owned_document_info + .document_info + .get_estimated_size_for_document_type(name, document_type, platform_version)?; + + if document_top_field_estimated_size > u8::MAX as u16 { + return Err(Error::Fee(FeeError::Overflow( + "document top field is too big for being an index", + ))); + } + + // The property-name layer's children are value trees of + // type `value_tree_type` (post-demotion — matching what + // the live path actually writes below). + estimated_costs_only_with_layer_info.insert( + sub_level_index_path_info.clone().convert_to_key_info_path(), + EstimatedLayerInformation { + tree_type: property_name_tree_type, + estimated_layer_count: PotentiallyAtMaxElements, + estimated_layer_sizes: AllSubtrees( + document_top_field_estimated_size as u8, + estimated_sum_trees_for_value_tree_type(value_tree_type), + storage_flags.map(|s| s.serialized_size()), + ), + }, + ); + } + + // Iteration 1. the index path is now something likeDataContracts/ContractID/Documents(1)/$ownerId//toUserId + // Iteration 2. the index path is now something likeDataContracts/ContractID/Documents(1)/$ownerId//toUserId//accountReference + + let path_key_info = document_index_field + .clone() + .add_path_info(sub_level_index_path_info.clone()); + + // here we are inserting the value tree + self.batch_insert_empty_tree_if_not_exists( + path_key_info.clone(), + value_tree_type, + *storage_flags, + value_apply_type, + transaction, + previous_batch_operations, + batch_operations, + &platform_version.drive, + )?; + + any_fields_null |= document_index_field.is_empty(); + all_fields_null &= document_index_field.is_empty(); + + // we push the actual value of the index path + sub_level_index_path_info.push(document_index_field)?; + // Iteration 1. the index path is now something likeDataContracts/ContractID/Documents(1)/$ownerId//toUserId// + // Iteration 2. the index path is now something likeDataContracts/ContractID/Documents(1)/$ownerId//toUserId//accountReference/ + // Propagate the actual (post-demotion) `value_tree_type` + // forward — the next level reads it to pick the correct + // zero-contribution op for its own continuation children. + self.add_indices_for_index_level_for_contract_operations_v2( + document_and_contract_info, + sub_level_index_path_info, + sub_level, + any_fields_null, + all_fields_null, + value_tree_type, + previous_batch_operations, + storage_flags, + estimated_costs_only_with_layer_info, + event_id, + transaction, + batch_operations, + platform_version, + )?; + } + Ok(()) + } +} diff --git a/packages/rs-drive/src/drive/document/insert/add_indices_for_top_index_level_for_contract_operations/mod.rs b/packages/rs-drive/src/drive/document/insert/add_indices_for_top_index_level_for_contract_operations/mod.rs index 157a5ac7dd0..b065bccdb9c 100644 --- a/packages/rs-drive/src/drive/document/insert/add_indices_for_top_index_level_for_contract_operations/mod.rs +++ b/packages/rs-drive/src/drive/document/insert/add_indices_for_top_index_level_for_contract_operations/mod.rs @@ -1,5 +1,6 @@ mod v0; mod v1; +mod v2; use crate::drive::Drive; use crate::error::drive::DriveError; @@ -25,6 +26,11 @@ impl Drive { /// per-node aggregate bytes for SumTree / ProvableSumTree / /// CountSumTree / ProvableCountSumTree / PCPS value trees. /// Unblocked by grovedb #674. + /// - **v2** (active at protocol v14+): shared-prefix aggregate fix — + /// tree types derive through the shared continuation-demotion + /// helper so aggregating prefix indexes with compound siblings + /// become insertable. Bit-identical to v1 for shapes v1 could + /// insert. #[allow(clippy::too_many_arguments)] pub(crate) fn add_indices_for_top_index_level_for_contract_operations( &self, @@ -60,9 +66,17 @@ impl Drive { batch_operations, platform_version, ), + 2 => self.add_indices_for_top_index_level_for_contract_operations_v2( + document_and_contract_info, + previous_batch_operations, + estimated_costs_only_with_layer_info, + transaction, + batch_operations, + platform_version, + ), version => Err(Error::Drive(DriveError::UnknownVersionMismatch { method: "add_indices_for_top_index_level_for_contract_operations".to_string(), - known_versions: vec![0, 1], + known_versions: vec![0, 1, 2], received: version, })), } diff --git a/packages/rs-drive/src/drive/document/insert/add_indices_for_top_index_level_for_contract_operations/v2/mod.rs b/packages/rs-drive/src/drive/document/insert/add_indices_for_top_index_level_for_contract_operations/v2/mod.rs new file mode 100644 index 00000000000..c43ef4d9c9f --- /dev/null +++ b/packages/rs-drive/src/drive/document/insert/add_indices_for_top_index_level_for_contract_operations/v2/mod.rs @@ -0,0 +1,224 @@ +use crate::drive::document::unique_event_id; +use crate::util::type_constants::DEFAULT_HASH_SIZE_U8; + +use crate::util::grove_operations::BatchInsertTreeApplyType; + +use crate::drive::Drive; +use crate::util::object_size_info::{DocumentAndContractInfo, DocumentInfoV0Methods, PathInfo}; + +use crate::error::fee::FeeError; +use crate::error::Error; +use crate::fees::op::LowLevelDriveOperation; +use dpp::data_contract::accessors::v0::DataContractV0Getters; +use dpp::data_contract::config::v0::DataContractConfigGettersV0; +use dpp::data_contract::document_type::accessors::DocumentTypeV0Getters; + +use dpp::version::PlatformVersion; + +use crate::drive::document::estimation_costs::estimated_sum_trees_for_value_tree_type::estimated_sum_trees_for_value_tree_type; +use crate::drive::document::index_level_tree_types::index_level_tree_types_with_continuation_demotion; +use crate::drive::document::paths::contract_document_type_path_vec; +use grovedb::batch::KeyInfoPath; +use grovedb::EstimatedLayerCount::{ApproximateElements, PotentiallyAtMaxElements}; +use grovedb::EstimatedLayerSizes::AllSubtrees; +use grovedb::EstimatedSumTrees::NoSumTrees; +use grovedb::{EstimatedLayerInformation, TransactionArg, TreeType}; +use std::collections::HashMap; + +impl Drive { + /// Adds indices for the top index level and calls for lower levels. + /// + /// v2 derives the per-sub-level tree types through the shared + /// [`index_level_tree_types_with_continuation_demotion`] helper + /// instead of v1's inline tables. The only behavioral difference is + /// the continuation demotion: a top-level property that both + /// terminates a countable+summable index with a range flag AND + /// prefixes a compound index gets `CountSumTree` value trees + /// instead of the provable variants, because grovedb rejects + /// count-suppressed continuation children under provable count + /// parents. Shapes without continuations (and all shapes insertable + /// under v1) produce bit-identical operations. Part of the platform + /// v14 shared-prefix aggregate fix — see + /// [`Drive::add_indices_for_index_level_for_contract_operations_v2`] + /// for the full story. + pub(super) fn add_indices_for_top_index_level_for_contract_operations_v2( + &self, + document_and_contract_info: &DocumentAndContractInfo, + previous_batch_operations: &mut Option<&mut Vec>, + estimated_costs_only_with_layer_info: &mut Option< + HashMap, + >, + transaction: TransactionArg, + batch_operations: &mut Vec, + platform_version: &PlatformVersion, + ) -> Result<(), Error> { + let drive_version = &platform_version.drive; + let index_level = &document_and_contract_info.document_type.index_structure(); + let contract = document_and_contract_info.contract; + let event_id = unique_event_id(); + let document_type = document_and_contract_info.document_type; + let storage_flags = + if document_type.documents_mutable() || contract.config().can_be_deleted() { + document_and_contract_info + .owned_document_info + .document_info + .get_storage_flags_ref() + } else { + None //there are no need for storage flags if documents are not mutable and contract can not be deleted + }; + + // we need to construct the path for documents on the contract + // the path is + // * Document and DataContract root tree + // * DataContract ID recovered from document + // * 0 to signify Documents and notDataContract + let contract_document_type_path = contract_document_type_path_vec( + document_and_contract_info.contract.id_ref().as_bytes(), + document_and_contract_info.document_type.name(), + ); + + let sub_level_index_count = index_level.sub_levels().len() as u32; + + if let Some(estimated_costs_only_with_layer_info) = estimated_costs_only_with_layer_info { + // On this level we will have a 0 and all the top index paths + estimated_costs_only_with_layer_info.insert( + KeyInfoPath::from_known_owned_path(contract_document_type_path.clone()), + EstimatedLayerInformation { + tree_type: TreeType::NormalTree, + estimated_layer_count: ApproximateElements(sub_level_index_count + 1), + estimated_layer_sizes: AllSubtrees( + DEFAULT_HASH_SIZE_U8, + NoSumTrees, + storage_flags.map(|s| s.serialized_size()), + ), + }, + ); + } + + // next we need to store a reference to the document for each index + for (name, sub_level) in index_level.sub_levels() { + let tree_types = index_level_tree_types_with_continuation_demotion(sub_level); + let property_name_tree_type = tree_types.property_name_tree_type; + let value_tree_type = tree_types.value_tree_type; + + // at this point the contract path is to the contract documents + // for each index the top index component will already have been added + // when the contract itself was created + let mut index_path: Vec> = contract_document_type_path.clone(); + index_path.push(Vec::from(name.as_bytes())); + + // with the example of the dashpay contract's first index + // the index path is now something likeDataContracts/ContractID/Documents(1)/$ownerId + let document_top_field = document_and_contract_info + .owned_document_info + .document_info + .get_raw_for_document_type( + name, + document_type, + document_and_contract_info.owned_document_info.owner_id, + Some((sub_level, event_id)), + platform_version, + )? + .unwrap_or_default(); + + // The zero will not matter here, because the PathKeyInfo is variable + let path_key_info = document_top_field.clone().add_path::<0>(index_path.clone()); + // here we are inserting the value tree (per distinct property value) + // under the top-level property-name tree. The top-level property-name + // tree itself is created at contract setup, so the apply_type's + // `in_tree_type` reflects whichever variant the contract setup used. + let value_apply_type = if estimated_costs_only_with_layer_info.is_none() { + BatchInsertTreeApplyType::StatefulBatchInsertTree + } else { + BatchInsertTreeApplyType::StatelessBatchInsertTree { + in_tree_type: property_name_tree_type, + tree_type: value_tree_type, + flags_len: storage_flags + .map(|s| s.serialized_size()) + .unwrap_or_default(), + } + }; + self.batch_insert_empty_tree_if_not_exists( + path_key_info.clone(), + value_tree_type, + storage_flags, + value_apply_type, + transaction, + previous_batch_operations, + batch_operations, + drive_version, + )?; + + if let Some(estimated_costs_only_with_layer_info) = estimated_costs_only_with_layer_info + { + let document_top_field_estimated_size = document_and_contract_info + .owned_document_info + .document_info + .get_estimated_size_for_document_type(name, document_type, platform_version)?; + + if document_top_field_estimated_size > u8::MAX as u16 { + return Err(Error::Fee(FeeError::Overflow( + "document field is too big for being an index", + ))); + } + + // On this level we will have all the user defined values + // for the paths. Children at this property-name layer + // are value trees of type `value_tree_type` (post- + // demotion — matching what the live path actually + // writes above). + estimated_costs_only_with_layer_info.insert( + KeyInfoPath::from_known_owned_path(index_path.clone()), + EstimatedLayerInformation { + tree_type: property_name_tree_type, + estimated_layer_count: PotentiallyAtMaxElements, + estimated_layer_sizes: AllSubtrees( + document_top_field_estimated_size as u8, + estimated_sum_trees_for_value_tree_type(value_tree_type), + storage_flags.map(|s| s.serialized_size()), + ), + }, + ); + } + + let any_fields_null = document_top_field.is_empty(); + let all_fields_null = document_top_field.is_empty(); + + let mut index_path_info = if document_and_contract_info + .owned_document_info + .document_info + .is_document_size() + { + // This is a stateless operation + PathInfo::PathWithSizes(KeyInfoPath::from_known_owned_path(index_path)) + } else { + PathInfo::PathAsVec::<0>(index_path) + }; + + // we push the actual value of the index path + index_path_info.push(document_top_field)?; + // the index path is now something likeDataContracts/ContractID/Documents(1)/$ownerId/ + + // Propagate the exact (post-demotion) `value_tree_type` we + // just inserted forward as the recursive level's + // `parent_value_tree_type` so its continuation children pick + // the right zero-contribution op. + self.add_indices_for_index_level_for_contract_operations( + document_and_contract_info, + index_path_info, + sub_level, + any_fields_null, + all_fields_null, + value_tree_type, + previous_batch_operations, + &storage_flags, + estimated_costs_only_with_layer_info, + event_id, + transaction, + batch_operations, + platform_version, + )?; + } + Ok(()) + } +} diff --git a/packages/rs-drive/src/drive/document/mod.rs b/packages/rs-drive/src/drive/document/mod.rs index 05477e15812..d2fe57b0d10 100644 --- a/packages/rs-drive/src/drive/document/mod.rs +++ b/packages/rs-drive/src/drive/document/mod.rs @@ -54,6 +54,10 @@ pub mod primary_key_tree_type; #[cfg(feature = "server")] pub(crate) mod prove; +/// Shared index-walker tree-type derivation for the v2 walkers +#[cfg(feature = "server")] +pub(crate) mod index_level_tree_types; + /// How many document history entries to fetch at once. This mirrors contract history /// and prevents unbounded history reads. pub const MAX_DOCUMENT_HISTORY_FETCH_LIMIT: u16 = 10; diff --git a/packages/rs-drive/src/drive/document/update/internal/update_document_for_contract_operations/mod.rs b/packages/rs-drive/src/drive/document/update/internal/update_document_for_contract_operations/mod.rs index 58486487b9e..a1bc3795d79 100644 --- a/packages/rs-drive/src/drive/document/update/internal/update_document_for_contract_operations/mod.rs +++ b/packages/rs-drive/src/drive/document/update/internal/update_document_for_contract_operations/mod.rs @@ -1,4 +1,5 @@ mod v0; +mod v1; use crate::drive::Drive; use crate::util::object_size_info::DocumentAndContractInfo; @@ -54,9 +55,21 @@ impl Drive { transaction, platform_version, ), + // v1 (platform v14+): branches materialized by key-changing + // updates get the shared-prefix aggregate treatment + // (continuation demotion + zero-contribution wrapping), + // matching the v2 insert walkers. + 1 => self.update_document_for_contract_operations_v1( + document_and_contract_info, + block_info, + previous_batch_operations, + estimated_costs_only_with_layer_info, + transaction, + platform_version, + ), version => Err(Error::Drive(DriveError::UnknownVersionMismatch { method: "update_document_for_contract_operations".to_string(), - known_versions: vec![0], + known_versions: vec![0, 1], received: version, })), } diff --git a/packages/rs-drive/src/drive/document/update/internal/update_document_for_contract_operations/v1/mod.rs b/packages/rs-drive/src/drive/document/update/internal/update_document_for_contract_operations/v1/mod.rs new file mode 100644 index 00000000000..9be44d66288 --- /dev/null +++ b/packages/rs-drive/src/drive/document/update/internal/update_document_for_contract_operations/v1/mod.rs @@ -0,0 +1,709 @@ +use crate::drive::constants::CONTRACT_DOCUMENTS_PATH_HEIGHT; +use crate::drive::document::index_level_tree_types::index_level_tree_types_with_continuation_demotion; +use crate::drive::document::{ + make_document_reference, make_document_reference_with_sum_item, read_document_sum_contribution, +}; + +use crate::drive::Drive; +use crate::error::drive::DriveError; +use crate::error::Error; +use crate::fees::op::LowLevelDriveOperation; +use crate::util::grove_operations::{ + BatchDeleteUpTreeApplyType, BatchInsertApplyType, BatchInsertTreeApplyType, DirectQueryType, + QueryType, +}; +use crate::util::object_size_info::DocumentInfo::DocumentOwnedInfo; +use crate::util::object_size_info::DriveKeyInfo::{Key, KeyRef, KeySize}; +use crate::util::object_size_info::PathKeyElementInfo::PathKeyRefElement; +use crate::util::object_size_info::{ + DocumentAndContractInfo, DocumentInfoV0Methods, DriveKeyInfo, PathKeyInfo, +}; +use crate::util::storage_flags::StorageFlags; +use dpp::block::block_info::BlockInfo; +use dpp::data_contract::accessors::v0::DataContractV0Getters; +use dpp::data_contract::document_type::accessors::DocumentTypeV0Getters; + +use dpp::document::document_methods::DocumentMethodsV0; +use dpp::document::serialization_traits::DocumentPlatformConversionMethodsV0; +use dpp::document::{Document, DocumentV0Getters}; + +use crate::drive::document::paths::{ + contract_document_type_path, + contract_documents_keeping_history_primary_key_path_for_document_id, + contract_documents_primary_key_path, +}; +use dpp::data_contract::document_type::methods::DocumentTypeBasicMethods; +use dpp::data_contract::document_type::IndexCountability; +use dpp::version::PlatformVersion; +use grovedb::batch::key_info::KeyInfo; +use grovedb::batch::key_info::KeyInfo::KnownKey; +use grovedb::batch::KeyInfoPath; +use grovedb::{Element, EstimatedLayerInformation, MaybeTree, TransactionArg, TreeType}; +use std::borrow::Cow; +use std::collections::{HashMap, HashSet}; + +/// `[0]`-key reference-bucket `TreeType` dispatch for the +/// terminator level. Mirrors the dispatch in +/// `add_reference_for_index_level_for_contract_operations_v0` — +/// this table distinguishes `Countable` (→ `CountTree`) from +/// `CountableAllowingOffset` (→ `ProvableCountTree`), where the +/// value-tree dispatch above collapses them via `is_countable()`. +/// +/// The `[0]` bucket is the leaf tree under a non-unique terminator +/// value, holding the per-doc references; it must carry the index's +/// count and sum aggregates so `count_value_or_default()` / +/// `sum_value_or_default()` walks at the value tree's parent +/// resolve to the right per-value totals. +fn reference_tree_type_for_index( + countable: IndexCountability, + summable: &Option, + range_summable: bool, +) -> TreeType { + let count_provable = matches!(countable, IndexCountability::CountableAllowingOffset); + let count_root_only = matches!(countable, IndexCountability::Countable) && !count_provable; + let sum_provable = range_summable; + let sum_root_only = summable.is_some() && !sum_provable; + match (count_provable, count_root_only, sum_provable, sum_root_only) { + (false, false, false, false) => TreeType::NormalTree, + (false, true, false, false) => TreeType::CountTree, + (true, _, false, false) => TreeType::ProvableCountTree, + (false, false, false, true) => TreeType::SumTree, + (false, false, true, _) => TreeType::ProvableSumTree, + (false, true, false, true) => TreeType::CountSumTree, + (true, _, false, true) => TreeType::ProvableCountSumTree, + (true, _, true, _) => TreeType::ProvableCountProvableSumTree, + (false, true, true, _) => TreeType::ProvableCountProvableSumTree, + } +} + +impl Drive { + /// Gathers operations for updating a document. + /// + /// v1 (platform v14+) applies the shared-prefix aggregate fix to the + /// branches a key-changing update materializes, keeping them + /// bit-identical to what the v2 insert walkers would have written: + /// + /// - value-tree and property-name tree types derive through the shared + /// [`index_level_tree_types_with_continuation_demotion`] helper, so + /// provable count-bearing value trees with compound continuations + /// demote to `CountSumTree` exactly as on insert; + /// - continuation property-name trees created under an aggregating + /// value tree go through + /// [`Drive::batch_insert_empty_tree_contributing_zero_to_aggregating_parent_if_not_exists`], + /// so they contribute zero to every axis the parent aggregates. v0 + /// inserted them unwrapped, which would let a continuation's own + /// aggregates leak into the per-value count/sum. + /// + /// The `[0]` reference-bucket dispatch and everything else match v0. + pub(in crate::drive::document::update) fn update_document_for_contract_operations_v1( + &self, + document_and_contract_info: DocumentAndContractInfo, + block_info: &BlockInfo, + previous_batch_operations: &mut Option<&mut Vec>, + estimated_costs_only_with_layer_info: &mut Option< + HashMap, + >, + transaction: TransactionArg, + platform_version: &PlatformVersion, + ) -> Result, Error> { + let drive_version = &platform_version.drive; + let mut batch_operations: Vec = vec![]; + if !document_and_contract_info.document_type.requires_revision() + // if it requires revision then there are reasons for us to be able to update in drive + { + return Err(Error::Drive(DriveError::UpdatingReadOnlyImmutableDocument( + "this document type is not mutable", + ))); + } + + // If we are going for estimated costs do an add instead as it always worse than an update + if document_and_contract_info + .owned_document_info + .document_info + .is_document_size() + || estimated_costs_only_with_layer_info.is_some() + { + return self.add_document_for_contract_operations( + document_and_contract_info, + true, // we say we should override as this skips an unnecessary check + block_info, + previous_batch_operations, + estimated_costs_only_with_layer_info, + transaction, + platform_version, + ); + } + + let contract = document_and_contract_info.contract; + let document_type = document_and_contract_info.document_type; + let owner_id = document_and_contract_info.owned_document_info.owner_id; + let Some((document, storage_flags)) = document_and_contract_info + .owned_document_info + .document_info + .get_borrowed_document_and_storage_flags() + else { + return Err(Error::Drive(DriveError::CorruptedCodeExecution( + "must have document and storage flags", + ))); + }; + // we need to construct the path for documents on the contract + // the path is + // * Document andDataContract root tree + // *DataContract ID recovered from document + // * 0 to signify Documents and notDataContract + let contract_document_type_path = + contract_document_type_path(contract.id_ref().as_bytes(), document_type.name()); + + let contract_documents_primary_key_path = + contract_documents_primary_key_path(contract.id_ref().as_bytes(), document_type.name()); + + // Per-document reference is built per-index below because + // summable indexes need `Element::ReferenceWithSumItem` (sum + // contribution propagates to ancestor sum trees) while plain + // indexes use `Element::Reference`. The non-sum reference is + // computed once here for reuse on all non-summable indexes; + // summable indexes build their own variant inside the loop. + let document_reference = make_document_reference( + document, + document_and_contract_info.document_type, + storage_flags, + ); + + // next we need to get the old document from storage + let old_document_element = if document_type.documents_keep_history() { + let contract_documents_keeping_history_primary_key_path_for_document_id = + contract_documents_keeping_history_primary_key_path_for_document_id( + contract.id_ref().as_bytes(), + document_type.name().as_str(), + document.id_ref().as_slice(), + ); + // When keeping document history the 0 is a reference that points to the current value + // O is just on one byte, so we have at most one hop of size 1 (1 byte) + self.grove_get( + (&contract_documents_keeping_history_primary_key_path_for_document_id).into(), + &[0], + QueryType::StatefulQuery, + transaction, + &mut batch_operations, + drive_version, + )? + } else { + self.grove_get_raw( + (&contract_documents_primary_key_path).into(), + document.id().as_slice(), + DirectQueryType::StatefulDirectQuery, + transaction, + &mut batch_operations, + drive_version, + )? + }; + + // we need to store the document for it's primary key + // we should be overriding if the document_type does not have history enabled + self.add_document_to_primary_storage( + &document_and_contract_info, + block_info, + true, + estimated_costs_only_with_layer_info, + transaction, + &mut batch_operations, + platform_version, + )?; + + let old_document_info = if let Some(old_document_element) = old_document_element { + // Accept BOTH plain `Item` (non-summable doctypes) AND + // `ItemWithSumItem` (summable doctypes — primary storage on + // doctypes with `documents_summable: Some(_)` is written as + // ItemWithSumItem by `add_document_to_primary_storage`). + // The sum_value is discarded here because the reload only + // needs the document body + flags; the new write below + // re-computes the sum from the freshly-supplied document. + let (old_serialized_document, element_flags) = match old_document_element { + Element::Item(bytes, flags) => (bytes, flags), + Element::ItemWithSumItem(bytes, _sum_value, flags) => (bytes, flags), + _ => { + return Err(Error::Drive(DriveError::CorruptedDocumentNotItem( + "old document is not an item or item-with-sum-item", + ))) + } + }; + let document = Document::from_bytes( + old_serialized_document.as_slice(), + document_type, + platform_version, + )?; + let storage_flags = StorageFlags::map_some_element_flags_ref(&element_flags)?; + DocumentOwnedInfo((document, storage_flags.map(Cow::Owned))) + } else { + return Err(Error::Drive(DriveError::UpdatingDocumentThatDoesNotExist( + "document being updated does not exist", + ))); + }; + + let mut batch_insertion_cache: HashSet>> = HashSet::new(); + // Pre-built tree of every index path in the doctype. Walking + // this in parallel with each `index.properties` chain below + // is how we pick the right aggregate `TreeType` at each + // branch we materialize on a key-changing update — matching + // exactly what the insert path's + // `add_indices_for_{top_index_,index_}level_for_contract_operations_v1` + // helpers would have chosen. Without this walk, an update + // that moves into a previously-unseen branch under an + // aggregate index would create the branch as `NormalTree` + // beneath a `ProvableCount*` / `ProvableSum*` parent — + // diverging from the insert path (consensus break). + let index_structure = document_type.index_structure(); + // fourth we need to store a reference to the document for each index + for index in document_type.indexes().values() { + // at this point the contract path is to the contract documents + // for each index the top index component will already have been added + // when the contract itself was created + let mut index_path: Vec> = contract_document_type_path + .iter() + .map(|&x| Vec::from(x)) + .collect(); + let top_index_property = index.properties.first().ok_or(Error::Drive( + DriveError::CorruptedContractIndexes("invalid contract indices".to_string()), + ))?; + index_path.push(Vec::from(top_index_property.name.as_bytes())); + + // Mirror the insert path's IndexLevel descent. We + // start at the top-level property's `IndexLevel` node — + // the same node `add_indices_for_top_index_level_..._v1` + // would feed to its `value_tree_type` dispatch — and + // descend one step per property in `index.properties` + // below, so at every branch we materialize the matching + // `IndexLevel` node is in hand. + // + // `index_structure` carries the upgrade across ALL + // indexes that share this path (a level can host both + // `byRecipient` and `byRecipientSentAt`; the aggregate + // type at depth 1 must reflect whichever terminator is + // there). Using `has_index_with_type()` on the descended + // node ensures we pick that upgrade rather than the + // currently-iterated index's own per-level flags. + let mut current_index_level = index_structure + .sub_levels() + .get(&top_index_property.name) + .ok_or(Error::Drive(DriveError::CorruptedContractIndexes(format!( + "index structure missing top property '{}' for index '{}' — \ + doctype's IndexLevel tree must contain every property of every \ + registered index", + top_index_property.name, index.name + ))))?; + + // Per-index reference variant. Mirror of the insert path's + // dispatch in + // `add_reference_for_index_level_for_contract_operations` — + // summable indexes must emit `Element::ReferenceWithSumItem` + // so the per-document sum propagates into ancestor sum trees + // on every update. Without this branch, an update would + // overwrite an existing `ReferenceWithSumItem` with a plain + // `Reference`, silently dropping the doc's contribution + // from ancestor sum aggregates (the document body remains + // queryable but SUM/AVG proofs would exclude it — a soundness + // bug an attacker could trigger with any benign no-op update). + let index_document_reference = if let Some(sum_property_name) = &index.summable { + let sum_value = read_document_sum_contribution(document, sum_property_name)?; + make_document_reference_with_sum_item( + document, + document_and_contract_info.document_type, + sum_value, + storage_flags, + ) + } else { + document_reference.clone() + }; + + // with the example of the dashpay contract's first index + // the index path is now something likeDataContracts/ContractID/Documents(1)/$ownerId + let document_top_field = document + .get_raw_for_document_type( + &top_index_property.name, + document_type, + owner_id, + platform_version, + )? + .unwrap_or_default(); + + let old_document_top_field = old_document_info + .get_raw_for_document_type( + &top_index_property.name, + document_type, + None, // We want to use the old owner id + None, + platform_version, + )? + .unwrap_or_default(); + + // if we are not applying that means we are trying to get worst case costs + // which would entail a change on every index + let mut change_occurred_on_index = match &old_document_top_field { + DriveKeyInfo::Key(k) => &document_top_field != k, + DriveKeyInfo::KeyRef(k) => document_top_field.as_slice() != *k, + DriveKeyInfo::KeySize(_) => { + // we should assume true in this worst case cost scenario + true + } + }; + + // Post-demotion tree types for the top property's level — + // the exact types the v2 insert walkers would derive. The + // value-tree type is tracked as `parent_value_tree_type` + // regardless of whether this level changed: a deeper + // materialization below still needs to know what it hangs + // under. + let top_level_tree_types = + index_level_tree_types_with_continuation_demotion(current_index_level); + let mut parent_value_tree_type = top_level_tree_types.value_tree_type; + + if change_occurred_on_index { + // here we are inserting an empty tree that will have a subtree of all other index properties + let mut qualified_path = index_path.clone(); + qualified_path.push(document_top_field.clone()); + + if !batch_insertion_cache.contains(&qualified_path) { + // Top-level value tree: aggregate variant + // depending on whether any index terminates at + // the top property (e.g., a standalone + // `[recipient]` alongside `[recipient, sentAt]`) + // and what flags that terminator carries. + // Default for pure-prefix levels collapses to + // `NormalTree`, matching pre-v12 behavior. + let value_tree_type = top_level_tree_types.value_tree_type; + let inserted = self.batch_insert_empty_tree_if_not_exists( + PathKeyInfo::PathKeyRef::<0>(( + index_path.clone(), + document_top_field.as_slice(), + )), + value_tree_type, + storage_flags, + BatchInsertTreeApplyType::StatefulBatchInsertTree, + transaction, + previous_batch_operations, + &mut batch_operations, + drive_version, + )?; + if inserted { + batch_insertion_cache.insert(qualified_path); + } + } + } + + let mut all_fields_null = document_top_field.is_empty(); + + let mut old_index_path: Vec = index_path + .iter() + .map(|path_item| DriveKeyInfo::Key(path_item.clone())) + .collect(); + // we push the actual value of the index path + index_path.push(document_top_field); + // the index path is now something likeDataContracts/ContractID/Documents(1)/$ownerId/ + + old_index_path.push(old_document_top_field); + + for i in 1..index.properties.len() { + let index_property = index.properties.get(i).ok_or(Error::Drive( + DriveError::CorruptedContractIndexes("invalid contract indices".to_string()), + ))?; + + // Descend one step in the doctype's `IndexLevel` + // tree, in lockstep with `index.properties`. Failure + // here means the IndexLevel tree was built from a + // different doctype than the one we're iterating — + // a corruption signal, not a user-input error. + current_index_level = current_index_level + .sub_levels() + .get(&index_property.name) + .ok_or(Error::Drive(DriveError::CorruptedContractIndexes(format!( + "index structure missing sub_level '{}' under index '{}' at depth {}", + index_property.name, index.name, i + ))))?; + + let sub_level_tree_types = + index_level_tree_types_with_continuation_demotion(current_index_level); + + let document_index_field = document + .get_raw_for_document_type( + &index_property.name, + document_type, + owner_id, + platform_version, + )? + .unwrap_or_default(); + + let old_document_index_field = old_document_info + .get_raw_for_document_type( + &index_property.name, + document_type, + None, // We want to use the old owner_id + None, + platform_version, + )? + .unwrap_or_default(); + + // if we are not applying that means we are trying to get worst case costs + // which would entail a change on every index + change_occurred_on_index |= match &old_document_index_field { + DriveKeyInfo::Key(k) => &document_index_field != k, + DriveKeyInfo::KeyRef(k) => document_index_field != *k, + DriveKeyInfo::KeySize(_) => { + // we should assume true in this worst case cost scenario + true + } + }; + + if change_occurred_on_index { + // here we are inserting an empty tree that will have a subtree of all other index properties + + let mut qualified_path = index_path.clone(); + qualified_path.push(index_property.name.as_bytes().to_vec()); + + if !batch_insertion_cache.contains(&qualified_path) { + // Inner property-name tree at depth i+1 — a + // continuation hanging inside the previous + // level's value tree. When that parent + // aggregates (count, sum, or both) the + // continuation must contribute zero on every + // aggregated axis, exactly as on the insert + // path; v0 inserted it unwrapped, letting the + // continuation's aggregates leak into the + // per-value count/sum. + let property_name_tree_type = sub_level_tree_types.property_name_tree_type; + let inserted = if matches!(parent_value_tree_type, TreeType::NormalTree) { + self.batch_insert_empty_tree_if_not_exists( + PathKeyInfo::PathKeyRef::<0>(( + index_path.clone(), + index_property.name.as_bytes(), + )), + property_name_tree_type, + storage_flags, + BatchInsertTreeApplyType::StatefulBatchInsertTree, + transaction, + previous_batch_operations, + &mut batch_operations, + drive_version, + )? + } else { + self.batch_insert_empty_tree_contributing_zero_to_aggregating_parent_if_not_exists( + PathKeyInfo::PathKeyRef::<0>(( + index_path.clone(), + index_property.name.as_bytes(), + )), + parent_value_tree_type, + property_name_tree_type, + storage_flags, + BatchInsertTreeApplyType::StatefulBatchInsertTree, + transaction, + previous_batch_operations, + &mut batch_operations, + drive_version, + )? + }; + if inserted { + batch_insertion_cache.insert(qualified_path); + } + } + } + + index_path.push(Vec::from(index_property.name.as_bytes())); + old_index_path.push(DriveKeyInfo::Key(Vec::from(index_property.name.as_bytes()))); + + // Iteration 1. the index path is now something likeDataContracts/ContractID/Documents(1)/$ownerId//toUserId + // Iteration 2. the index path is now something likeDataContracts/ContractID/Documents(1)/$ownerId//toUserId//accountReference + + if change_occurred_on_index { + // here we are inserting an empty tree that will have a subtree of all other index properties + + let mut qualified_path = index_path.clone(); + qualified_path.push(document_index_field.clone()); + + if !batch_insertion_cache.contains(&qualified_path) { + // Inner value tree at depth i+2: same + // dispatch as the top-level value tree + // above — aggregate variant when any index + // terminates at this level (this index or + // another sharing the prefix), post-demotion. + let value_tree_type = sub_level_tree_types.value_tree_type; + let inserted = self.batch_insert_empty_tree_if_not_exists( + PathKeyInfo::PathKeyRef::<0>(( + index_path.clone(), + document_index_field.as_slice(), + )), + value_tree_type, + storage_flags, + BatchInsertTreeApplyType::StatefulBatchInsertTree, + transaction, + previous_batch_operations, + &mut batch_operations, + drive_version, + )?; + if inserted { + batch_insertion_cache.insert(qualified_path); + } + } + } + + all_fields_null &= document_index_field.is_empty(); + + // The next-deeper continuation (if any) hangs inside + // this level's value tree. + parent_value_tree_type = sub_level_tree_types.value_tree_type; + + // we push the actual value of the index path, both for the new and the old + index_path.push(document_index_field); + old_index_path.push(old_document_index_field); + // Iteration 1. the index path is now something likeDataContracts/ContractID/Documents(1)/$ownerId//toUserId// + // Iteration 2. the index path is now something likeDataContracts/ContractID/Documents(1)/$ownerId//toUserId//accountReference/ + } + + if change_occurred_on_index { + // we first need to delete the old values + // unique indexes will be stored under key "0" + // non unique indices should have a tree at key "0" that has all elements based off of primary key + + let mut key_info_path = KeyInfoPath::from_vec( + old_index_path + .into_iter() + .map(|key_info| match key_info { + Key(key) => KnownKey(key), + KeyRef(key_ref) => KnownKey(key_ref.to_vec()), + KeySize(key_info) => key_info, + }) + .collect::>(), + ); + + if !index.unique { + key_info_path.push(KnownKey(vec![0])); + + // here we should return an error if the element already exists + self.batch_delete_up_tree_while_empty( + key_info_path, + document.id().as_slice(), + Some(CONTRACT_DOCUMENTS_PATH_HEIGHT), + BatchDeleteUpTreeApplyType::StatefulBatchDelete { + is_known_to_be_subtree_with_sum: Some(MaybeTree::NotTree), + }, + transaction, + previous_batch_operations, + &mut batch_operations, + drive_version, + )?; + } else { + // here we should return an error if the element already exists + self.batch_delete_up_tree_while_empty( + key_info_path, + &[0], + Some(CONTRACT_DOCUMENTS_PATH_HEIGHT), + BatchDeleteUpTreeApplyType::StatefulBatchDelete { + is_known_to_be_subtree_with_sum: Some(MaybeTree::NotTree), + }, + transaction, + previous_batch_operations, + &mut batch_operations, + drive_version, + )?; + } + + // unique indexes will be stored under key "0" + // non unique indices should have a tree at key "0" that has all elements based off of primary key + if !index.unique || all_fields_null { + // here we are inserting an empty tree that will have a subtree of all other index properties + // + // Terminator `[0]` reference bucket: same + // dispatch as + // `add_reference_for_index_level_for_contract_operations_v0` + // — this is the leaf tree the insert path + // installs under the terminator value, and it + // must carry the index's count + sum aggregates + // so per-value `count_value_or_default()` / + // `sum_value_or_default()` walks at the parent + // value tree resolve to the right totals. + // + // Unlike the value/property-name dispatches + // above, this table distinguishes `Countable` + // (→ `CountTree`) from `CountableAllowingOffset` + // (→ `ProvableCountTree`), matching the insert + // path's terminator bucket exactly. + let reference_tree_type = reference_tree_type_for_index( + index.countable, + &index.summable, + index.range_summable, + ); + self.batch_insert_empty_tree_if_not_exists( + PathKeyInfo::PathKeyRef::<0>((index_path.clone(), &[0])), + reference_tree_type, + storage_flags, + BatchInsertTreeApplyType::StatefulBatchInsertTree, + transaction, + previous_batch_operations, + &mut batch_operations, + drive_version, + )?; + index_path.push(vec![0]); + + // here we should return an error if the element already exists + self.batch_insert( + PathKeyRefElement::<0>(( + index_path, + document.id().as_slice(), + index_document_reference.clone(), + )), + &mut batch_operations, + drive_version, + )?; + } else { + // in one update you can't insert an element twice, so need to check the cache + // here we should return an error if the element already exists + let inserted = self.batch_insert_if_not_exists( + PathKeyRefElement::<0>(( + index_path, + &[0], + index_document_reference.clone(), + )), + BatchInsertApplyType::StatefulBatchInsert, + transaction, + &mut batch_operations, + drive_version, + )?; + if !inserted { + return Err(Error::Drive(DriveError::CorruptedContractIndexes( + "index already exists".to_string(), + ))); + } + } + } else { + // no change occurred on index, we need to refresh the references + + // We can only trust the reference content has not changed if there are no storage flags + let trust_refresh_reference = storage_flags.is_none(); + + // unique indexes will be stored under key "0" + // non unique indices should have a tree at key "0" that has all elements based off of primary key + if !index.unique || all_fields_null { + index_path.push(vec![0]); + + // here we should return an error if the element already exists + self.batch_refresh_reference( + index_path, + document.id().to_vec(), + index_document_reference.clone(), + trust_refresh_reference, + &mut batch_operations, + drive_version, + )?; + } else { + self.batch_refresh_reference( + index_path, + vec![0], + index_document_reference.clone(), + trust_refresh_reference, + &mut batch_operations, + drive_version, + )?; + } + } + } + Ok(batch_operations) + } +} diff --git a/packages/rs-drive/src/fees/op.rs b/packages/rs-drive/src/fees/op.rs index 69d89268e73..65097f1fbd6 100644 --- a/packages/rs-drive/src/fees/op.rs +++ b/packages/rs-drive/src/fees/op.rs @@ -691,6 +691,172 @@ impl LowLevelDriveOperation { )) } + /// Sets `GroveOperation` for inserting an empty continuation tree under an + /// aggregating parent so it contributes **zero to every axis the parent + /// aggregates** — the v2 index walkers' replacement for + /// [`Self::wrap_in_non_aggregated_for_parent_tree_type`]. + /// + /// The v0 dispatcher above covers only the diagonal of the parent×inner + /// matrix (count parent + count-ish inner, sum parent + sum-bearing + /// inner, count+sum parent + sum-bearing inner) and errors on everything + /// else, which made shared-prefix aggregate contracts (e.g. a summable + /// `[a]` next to a plain compound `[a, b]`) reject every document + /// insert. This dispatcher completes the matrix using only combinations + /// grovedb accepts: + /// - `CountTree` parent → `Element::NonCounted(inner)` for any inner + /// tree variant (a `NonCounted` child contributes 0 to the count; the + /// parent has no sum axis). + /// - `CountSumTree` parent → sum-bearing inner: + /// `Element::NotCountedOrSummed(inner)`; non-sum inner: + /// `Element::NonCounted(inner)` (count suppressed by the wrapper, sum + /// contribution of a non-sum inner is 0 by definition — + /// `sum_value_or_default()` returns 0 for it). + /// - `SumTree` / `BigSumTree` / `ProvableSumTree` parent → sum-bearing + /// inner: `Element::NotSummed(inner)`; non-sum inner: **no wrapper at + /// all** — a non-sum child already contributes 0 to a sum-only + /// parent, and grovedb has no `NotSummed(non-sum)` form. + /// - Provable count-bearing parents (`ProvableCountTree` / + /// `ProvableCountSumTree` / `ProvableCountProvableSumTree`) → + /// `NotSupported`. These commit their count into every node hash and + /// reject count-suppressed children at grovedb's insert guards + /// (`TreeType::accepts_non_counted_children` / + /// `accepts_not_counted_or_summed_children`), so callers must demote + /// the parent first — see + /// `crate::drive::document::index_level_tree_types`. + /// - Non-aggregating parents → `NotSupported`; use + /// [`crate::fees::op::LowLevelDriveOperationTreeTypeConverter::empty_tree_operation_for_known_path_key`] + /// directly. + /// + /// Only reachable from the v2 index walkers (platform-version gated); + /// the v0 dispatcher stays byte-identical for the frozen v0/v1 walkers. + pub fn for_known_path_key_empty_tree_contributing_zero_to_parent( + path: Vec>, + key: Vec, + aggregating_parent_tree_type: TreeType, + inner_tree_type: TreeType, + storage_flags: Option<&StorageFlags>, + ) -> Result { + let inner_is_sum_bearing = matches!( + inner_tree_type, + TreeType::SumTree + | TreeType::BigSumTree + | TreeType::ProvableSumTree + | TreeType::CountSumTree + | TreeType::ProvableCountSumTree + | TreeType::ProvableCountProvableSumTree + ); + match aggregating_parent_tree_type { + TreeType::CountTree => Self::for_known_path_key_empty_non_counted_any_tree( + path, + key, + inner_tree_type, + storage_flags, + ), + TreeType::CountSumTree => { + if inner_is_sum_bearing { + Self::for_known_path_key_empty_not_counted_or_summed_tree( + path, + key, + inner_tree_type, + storage_flags, + ) + } else { + Self::for_known_path_key_empty_non_counted_any_tree( + path, + key, + inner_tree_type, + storage_flags, + ) + } + } + TreeType::SumTree | TreeType::BigSumTree | TreeType::ProvableSumTree => { + if inner_is_sum_bearing { + Self::for_known_path_key_empty_not_summed_tree( + path, + key, + inner_tree_type, + storage_flags, + ) + } else { + inner_tree_type.empty_tree_operation_for_known_path_key( + path, + key, + storage_flags, + ) + } + } + TreeType::ProvableCountTree + | TreeType::ProvableCountSumTree + | TreeType::ProvableCountProvableSumTree => { + Err(Error::Drive(DriveError::NotSupported( + "provable count-bearing parents cannot host zero-contributing children — \ + grovedb commits their count into every node hash and rejects NonCounted / \ + NotCountedOrSummed children; the index walker must demote such value trees \ + to CountSumTree before hanging continuations under them (see \ + index_level_tree_types_with_continuation_demotion).", + ))) + } + _ => Err(Error::Drive(DriveError::NotSupported( + "for_known_path_key_empty_tree_contributing_zero_to_parent called with a \ + non-aggregating parent tree type — caller should use the unwrapped \ + `empty_tree_operation_for_known_path_key` path instead.", + ))), + } + } + + /// Sets `GroveOperation` for inserting an empty tree of any of the nine + /// standard merk tree variants wrapped in `Element::NonCounted`. + /// Extends [`Self::for_known_path_key_empty_non_counted_tree`]'s + /// accepted set (`NormalTree` / `CountTree` / `ProvableCountTree`) with + /// the six sum-bearing variants: `Element::new_non_counted` accepts any + /// non-wrapper inner, and under the only parents the v2 walkers use it + /// for (`CountTree`, `CountSumTree` — both without per-node count + /// commitments) the wrapper suppresses the count contribution while a + /// sum-bearing inner's sum still propagates on the parent's sum axis if + /// it has one — which is exactly the v0-diagonal behavior for + /// count-only parents, and unreachable for `CountSumTree` parents (the + /// zero-contribution dispatcher routes their sum-bearing inners through + /// `NotCountedOrSummed` instead). + /// + /// Kept separate from the frozen v0 helper so pre-v14 consensus + /// behavior stays byte-identical. + pub fn for_known_path_key_empty_non_counted_any_tree( + path: Vec>, + key: Vec, + tree_type: TreeType, + storage_flags: Option<&StorageFlags>, + ) -> Result { + let element_flags = storage_flags.map(|s| s.to_element_flags()); + let inner = match tree_type { + TreeType::NormalTree => Element::empty_tree_with_flags(element_flags), + TreeType::SumTree => Element::empty_sum_tree_with_flags(element_flags), + TreeType::BigSumTree => Element::empty_big_sum_tree_with_flags(element_flags), + TreeType::CountTree => Element::empty_count_tree_with_flags(element_flags), + TreeType::CountSumTree => Element::empty_count_sum_tree_with_flags(element_flags), + TreeType::ProvableCountTree => { + Element::empty_provable_count_tree_with_flags(element_flags) + } + TreeType::ProvableCountSumTree => { + Element::empty_provable_count_sum_tree_with_flags(element_flags) + } + TreeType::ProvableSumTree => Element::empty_provable_sum_tree_with_flags(element_flags), + TreeType::ProvableCountProvableSumTree => { + Element::empty_provable_count_provable_sum_tree_with_flags(element_flags) + } + _ => { + return Err(Error::Drive(DriveError::NotSupported( + "NonCounted-wrapping is only supported for the nine standard merk tree \ + variants; special trees (commitment / MMR / bulk-append / dense) are \ + never index continuation trees.", + ))); + } + }; + let tree = Element::new_non_counted(inner)?; + Ok(LowLevelDriveOperation::insert_for_known_path_key_element( + path, key, tree, + )) + } + /// Sets `GroveOperation` for inserting an empty provable count tree at the given path and key pub fn for_known_path_key_empty_provable_count_tree( path: Vec>, @@ -1957,6 +2123,155 @@ mod tests { } } + /// Table-driven pin of the v14 zero-contribution dispatcher: every + /// accepted parent × inner cell must produce exactly the specified + /// wrapper (or an unwrapped tree), and every rejected parent must + /// error for every inner. This decides consensus-relevant element + /// shapes for v14 continuation inserts, so a regression here (or a + /// demotion-helper change routing a provable parent in) must fail + /// loudly. + #[test] + fn zero_contribution_dispatcher_full_matrix() { + use grovedb::batch::GroveOp; + + const ALL_INNERS: [TreeType; 9] = [ + TreeType::NormalTree, + TreeType::SumTree, + TreeType::BigSumTree, + TreeType::CountTree, + TreeType::CountSumTree, + TreeType::ProvableCountTree, + TreeType::ProvableCountSumTree, + TreeType::ProvableSumTree, + TreeType::ProvableCountProvableSumTree, + ]; + + fn is_sum_bearing(tree_type: TreeType) -> bool { + matches!( + tree_type, + TreeType::SumTree + | TreeType::BigSumTree + | TreeType::CountSumTree + | TreeType::ProvableCountSumTree + | TreeType::ProvableSumTree + | TreeType::ProvableCountProvableSumTree + ) + } + + fn element_tree_type(element: &Element) -> TreeType { + match element { + Element::Tree(..) => TreeType::NormalTree, + Element::SumTree(..) => TreeType::SumTree, + Element::BigSumTree(..) => TreeType::BigSumTree, + Element::CountTree(..) => TreeType::CountTree, + Element::CountSumTree(..) => TreeType::CountSumTree, + Element::ProvableCountTree(..) => TreeType::ProvableCountTree, + Element::ProvableCountSumTree(..) => TreeType::ProvableCountSumTree, + Element::ProvableSumTree(..) => TreeType::ProvableSumTree, + Element::ProvableCountProvableSumTree(..) => TreeType::ProvableCountProvableSumTree, + other => panic!("unexpected inner element: {other:?}"), + } + } + + #[derive(Debug, PartialEq)] + enum Expected { + NonCounted, + NotSummed, + NotCountedOrSummed, + Unwrapped, + } + + let dispatch = |parent: TreeType, inner: TreeType| { + LowLevelDriveOperation::for_known_path_key_empty_tree_contributing_zero_to_parent( + vec![b"root".to_vec()], + b"key".to_vec(), + parent, + inner, + None, + ) + }; + + let assert_cell = |parent: TreeType, inner: TreeType, expected: Expected| { + let op = dispatch(parent, inner).unwrap_or_else(|error| { + panic!("parent {parent:?} inner {inner:?} must be accepted: {error}") + }); + let element = match op { + LowLevelDriveOperation::GroveOperation(grove_op) => match grove_op.op { + GroveOp::InsertOrReplace { element } => element, + other => panic!("expected InsertOrReplace, got {other:?}"), + }, + other => panic!("expected GroveOperation, got {other:?}"), + }; + let (wrapper, produced_inner) = match &element { + Element::NonCounted(inner_element) => { + (Expected::NonCounted, inner_element.as_ref()) + } + Element::NotSummed(inner_element) => (Expected::NotSummed, inner_element.as_ref()), + Element::NotCountedOrSummed(inner_element) => { + (Expected::NotCountedOrSummed, inner_element.as_ref()) + } + plain => (Expected::Unwrapped, plain), + }; + assert_eq!( + wrapper, expected, + "parent {parent:?} inner {inner:?}: wrong wrapper" + ); + assert_eq!( + element_tree_type(produced_inner), + inner, + "parent {parent:?} inner {inner:?}: wrong inner tree type" + ); + }; + + // Count-only parents wrap every inner NonCounted. + for inner in ALL_INNERS { + assert_cell(TreeType::CountTree, inner, Expected::NonCounted); + } + // Count-sum parents: sum-bearing inners get NotCountedOrSummed, + // non-sum inners get NonCounted. + for inner in ALL_INNERS { + let expected = if is_sum_bearing(inner) { + Expected::NotCountedOrSummed + } else { + Expected::NonCounted + }; + assert_cell(TreeType::CountSumTree, inner, expected); + } + // Sum-only parents: sum-bearing inners get NotSummed, non-sum + // inners are inserted unwrapped (they contribute 0 naturally). + for parent in [ + TreeType::SumTree, + TreeType::BigSumTree, + TreeType::ProvableSumTree, + ] { + for inner in ALL_INNERS { + let expected = if is_sum_bearing(inner) { + Expected::NotSummed + } else { + Expected::Unwrapped + }; + assert_cell(parent, inner, expected); + } + } + // Provable count-bearing parents can't host zero-contributing + // children (the walkers demote them first); non-aggregating + // parents should use the plain path. Both must error for every + // inner. + for parent in [ + TreeType::NormalTree, + TreeType::ProvableCountTree, + TreeType::ProvableCountSumTree, + TreeType::ProvableCountProvableSumTree, + ] { + for inner in ALL_INNERS { + assert!( + dispatch(parent, inner).is_err(), + "parent {parent:?} inner {inner:?} must be rejected" + ); + } + } + } + #[test] fn ephemeral_cost_overflow_in_addition_chain() { // Use values that individually do not overflow but whose sum does. diff --git a/packages/rs-drive/src/util/grove_operations/batch_insert_empty_tree_if_not_exists/mod.rs b/packages/rs-drive/src/util/grove_operations/batch_insert_empty_tree_if_not_exists/mod.rs index 971ca95982c..3ac6a47ee02 100644 --- a/packages/rs-drive/src/util/grove_operations/batch_insert_empty_tree_if_not_exists/mod.rs +++ b/packages/rs-drive/src/util/grove_operations/batch_insert_empty_tree_if_not_exists/mod.rs @@ -12,6 +12,24 @@ use crate::fees::op::LowLevelDriveOperation; use dpp::version::drive_versions::DriveVersion; use grovedb::{TransactionArg, TreeType}; +/// How the underlying v0 body builds the empty-tree operation. +#[derive(Clone, Copy)] +enum EmptyTreeInsertMode { + /// Plain empty tree of the requested type (non-aggregating parent). + NotWrapped, + /// v0 wrapper dispatch keyed on the aggregating parent's tree type — + /// the diagonal-only matrix + /// ([`LowLevelDriveOperation::wrap_in_non_aggregated_for_parent_tree_type`]), + /// consensus-frozen for the pre-v14 index walkers. + NonAggregatedForParent(TreeType), + /// v2 zero-contribution dispatch keyed on the aggregating parent's + /// tree type — the full parent×inner matrix + /// ([`LowLevelDriveOperation::for_known_path_key_empty_tree_contributing_zero_to_parent`]), + /// which may emit an unwrapped op when the child contributes zero + /// naturally. Reachable only from the v2 index walkers. + ContributingZeroToParent(TreeType), +} + impl Drive { /// Pushes an "insert empty tree where path key does not yet exist" operation to `drive_operations`. /// Will also check the current drive operations @@ -36,7 +54,7 @@ impl Drive { 0 => self.batch_insert_empty_tree_if_not_exists_v0( path_key_info, tree_type, - None, // wrap_in_non_aggregated_for_parent_tree_type — non-aggregating insert, no wrap + EmptyTreeInsertMode::NotWrapped, // non-aggregating insert, no wrap storage_flags, apply_type, transaction, @@ -94,7 +112,7 @@ impl Drive { 0 => self.batch_insert_empty_tree_if_not_exists_v0( path_key_info, tree_type, - Some(aggregating_parent_tree_type), + EmptyTreeInsertMode::NonAggregatedForParent(aggregating_parent_tree_type), storage_flags, apply_type, transaction, @@ -111,6 +129,69 @@ impl Drive { } } + /// Pushes an "insert empty `tree_type` contributing zero to every axis + /// its aggregating parent tracks" operation to `drive_operations`, but + /// only if the path/key doesn't already exist (in current state OR in + /// pending operations). + /// + /// The v2 index walkers' replacement for + /// [`Self::batch_insert_empty_tree_under_aggregating_parent_if_not_exists`]: + /// where that helper's v0 wrapper dispatch covers only the diagonal of + /// the parent×inner matrix (and errors on shared-prefix aggregate + /// layouts like a summable `[a]` next to a plain compound `[a, b]`), + /// this one completes the matrix — see + /// [`LowLevelDriveOperation::for_known_path_key_empty_tree_contributing_zero_to_parent`] + /// for the full dispatch (including the unwrapped fallback for children + /// that contribute zero naturally, e.g. a plain continuation under a + /// sum-only value tree). + /// + /// The platform gate is carried by its only callers — the v14+ v2 + /// index walkers and v1 update walker — so pre-v14 behavior can't + /// reach this path. Crate-private for the same reason: exposing it + /// would let downstream crates bypass that gate. The grove feature + /// version is still dispatched like the sibling helpers so a future + /// v1 of the batch-dedup semantics can't silently diverge here. + #[allow(clippy::too_many_arguments)] + pub(crate) fn batch_insert_empty_tree_contributing_zero_to_aggregating_parent_if_not_exists< + const N: usize, + >( + &self, + path_key_info: PathKeyInfo, + aggregating_parent_tree_type: TreeType, + tree_type: TreeType, + storage_flags: Option<&StorageFlags>, + apply_type: BatchInsertTreeApplyType, + transaction: TransactionArg, + check_existing_operations: &mut Option<&mut Vec>, + drive_operations: &mut Vec, + drive_version: &DriveVersion, + ) -> Result { + match drive_version + .grove_methods + .batch + .batch_insert_empty_tree_if_not_exists + { + 0 => self.batch_insert_empty_tree_if_not_exists_v0( + path_key_info, + tree_type, + EmptyTreeInsertMode::ContributingZeroToParent(aggregating_parent_tree_type), + storage_flags, + apply_type, + transaction, + check_existing_operations, + drive_operations, + drive_version, + ), + version => Err(Error::Drive(DriveError::UnknownVersionMismatch { + method: + "batch_insert_empty_tree_contributing_zero_to_aggregating_parent_if_not_exists" + .to_string(), + known_versions: vec![0], + received: version, + })), + } + } + /// Count-only specialization of /// [`Self::batch_insert_empty_tree_under_aggregating_parent_if_not_exists`] /// preserved for [`Drive::add_indices_for_index_level_for_contract_operations_v0`]'s diff --git a/packages/rs-drive/src/util/grove_operations/batch_insert_empty_tree_if_not_exists/v0/mod.rs b/packages/rs-drive/src/util/grove_operations/batch_insert_empty_tree_if_not_exists/v0/mod.rs index 0038c5cfe8e..a601b3f4495 100644 --- a/packages/rs-drive/src/util/grove_operations/batch_insert_empty_tree_if_not_exists/v0/mod.rs +++ b/packages/rs-drive/src/util/grove_operations/batch_insert_empty_tree_if_not_exists/v0/mod.rs @@ -1,3 +1,4 @@ +use super::EmptyTreeInsertMode; use crate::drive::Drive; use crate::error::drive::DriveError; use crate::error::Error; @@ -22,7 +23,7 @@ impl Drive { &self, path_key_info: PathKeyInfo, tree_type: TreeType, - wrap_in_non_aggregated_for_parent_tree_type: Option, + insert_mode: EmptyTreeInsertMode, storage_flags: Option<&StorageFlags>, apply_type: BatchInsertTreeApplyType, transaction: TransactionArg, @@ -31,27 +32,40 @@ impl Drive { drive_version: &DriveVersion, ) -> Result { // The index walker passes the parent value tree's TreeType when - // the parent aggregates count, sum, or both. The - // `wrap_in_non_aggregated_for_parent_tree_type` dispatcher - // then picks the right wrapper variant - // (NonCounted / NotSummed / NotCountedOrSummed) based on what - // axes the parent aggregates. For non-aggregating parents - // (`wrap_in_non_aggregated_for_parent_tree_type: None`), no wrapping is - // needed and we fall through to the plain empty-tree op. - let build_op = - |path: Vec>, key: Vec| -> Result { - if let Some(parent_tt) = wrap_in_non_aggregated_for_parent_tree_type { - LowLevelDriveOperation::wrap_in_non_aggregated_for_parent_tree_type( - path, - key, - parent_tt, - tree_type, - storage_flags, - ) - } else { - tree_type.empty_tree_operation_for_known_path_key(path, key, storage_flags) + // the parent aggregates count, sum, or both, along with which + // wrapper-dispatch generation to use: `NonAggregatedForParent` + // (the frozen diagonal-only v0 matrix) or + // `ContributingZeroToParent` (the complete matrix, v2 walkers + // only). For non-aggregating parents (`NotWrapped`), no + // wrapping is needed and we fall through to the plain + // empty-tree op. + let build_op = |path: Vec>, + key: Vec| + -> Result { + match insert_mode { + EmptyTreeInsertMode::NotWrapped => { + tree_type.empty_tree_operation_for_known_path_key(path, key, storage_flags) + } + EmptyTreeInsertMode::NonAggregatedForParent(parent_tt) => { + LowLevelDriveOperation::wrap_in_non_aggregated_for_parent_tree_type( + path, + key, + parent_tt, + tree_type, + storage_flags, + ) + } + EmptyTreeInsertMode::ContributingZeroToParent(parent_tt) => { + LowLevelDriveOperation::for_known_path_key_empty_tree_contributing_zero_to_parent( + path, + key, + parent_tt, + tree_type, + storage_flags, + ) + } } - }; + }; //todo: clean up the duplication match path_key_info { PathKeyRef((path, key)) => { @@ -338,7 +352,7 @@ mod tests { .batch_insert_empty_tree_if_not_exists_v0( info, TreeType::NormalTree, - None, + super::EmptyTreeInsertMode::NotWrapped, None, BatchInsertTreeApplyType::StatefulBatchInsertTree, Some(&tx), @@ -389,7 +403,7 @@ mod tests { .batch_insert_empty_tree_if_not_exists_v0( info, TreeType::NormalTree, - None, + super::EmptyTreeInsertMode::NotWrapped, None, BatchInsertTreeApplyType::StatefulBatchInsertTree, Some(&tx), @@ -430,7 +444,7 @@ mod tests { .batch_insert_empty_tree_if_not_exists_v0( info, TreeType::NormalTree, - None, + super::EmptyTreeInsertMode::NotWrapped, None, BatchInsertTreeApplyType::StatefulBatchInsertTree, Some(&tx), @@ -458,7 +472,7 @@ mod tests { let result = drive.batch_insert_empty_tree_if_not_exists_v0( info, TreeType::NormalTree, - None, + super::EmptyTreeInsertMode::NotWrapped, None, BatchInsertTreeApplyType::StatefulBatchInsertTree, None, @@ -496,7 +510,7 @@ mod tests { .batch_insert_empty_tree_if_not_exists_v0( info, TreeType::NormalTree, - None, + super::EmptyTreeInsertMode::NotWrapped, None, BatchInsertTreeApplyType::StatefulBatchInsertTree, Some(&tx), @@ -536,7 +550,7 @@ mod tests { .batch_insert_empty_tree_if_not_exists_v0( info, TreeType::NormalTree, - None, + super::EmptyTreeInsertMode::NotWrapped, None, BatchInsertTreeApplyType::StatefulBatchInsertTree, Some(&tx), @@ -576,7 +590,7 @@ mod tests { .batch_insert_empty_tree_if_not_exists_v0( info, TreeType::NormalTree, - None, + super::EmptyTreeInsertMode::NotWrapped, None, BatchInsertTreeApplyType::StatefulBatchInsertTree, Some(&tx), @@ -588,4 +602,121 @@ mod tests { assert!(inserted); } + + /// The two wrapping modes must emit the exact wrapper element the + /// corresponding dispatcher specifies. `ContributingZeroToParent` + /// is the v14 consensus-affecting path; `NonAggregatedForParent` + /// is the frozen diagonal. + #[test] + fn test_batch_insert_empty_tree_if_not_exists_wrapping_modes() { + use crate::fees::op::LowLevelDriveOperation; + use grovedb::batch::GroveOp; + use grovedb::Element; + + let drive = setup_drive(None); + let pv = PlatformVersion::latest(); + let tx = drive.grove.start_transaction(); + + drive + .grove_insert_empty_tree( + SubtreePath::empty(), + b"root", + TreeType::NormalTree, + Some(&tx), + None, + &mut vec![], + &pv.drive, + ) + .expect("expected to insert root tree"); + + // (mode, parent, inner, check on the produced element) + let cases: Vec<( + super::EmptyTreeInsertMode, + fn(&Element) -> bool, + &'static str, + )> = vec![ + ( + super::EmptyTreeInsertMode::ContributingZeroToParent(TreeType::CountTree), + |element| matches!(element, Element::NonCounted(inner) if matches!(inner.as_ref(), Element::Tree(..))), + "CountTree parent + NormalTree inner → NonCounted(Tree)", + ), + ( + super::EmptyTreeInsertMode::ContributingZeroToParent(TreeType::CountSumTree), + |element| matches!(element, Element::NonCounted(inner) if matches!(inner.as_ref(), Element::Tree(..))), + "CountSumTree parent + NormalTree inner → NonCounted(Tree)", + ), + ( + super::EmptyTreeInsertMode::ContributingZeroToParent(TreeType::SumTree), + |element| matches!(element, Element::Tree(..)), + "SumTree parent + NormalTree inner → unwrapped Tree", + ), + ( + super::EmptyTreeInsertMode::NonAggregatedForParent(TreeType::CountTree), + |element| matches!(element, Element::NonCounted(inner) if matches!(inner.as_ref(), Element::Tree(..))), + "frozen diagonal: CountTree parent + NormalTree inner → NonCounted(Tree)", + ), + ]; + + for (index, (mode, element_matches, description)) in cases.into_iter().enumerate() { + let mut ops = vec![]; + let key = format!("child-{index}"); + let info = PathKeyInfo::<0>::PathKeyRef((vec![b"root".to_vec()], key.as_bytes())); + + let inserted = drive + .batch_insert_empty_tree_if_not_exists_v0( + info, + TreeType::NormalTree, + mode, + None, + BatchInsertTreeApplyType::StatefulBatchInsertTree, + Some(&tx), + &mut None, + &mut ops, + &pv.drive, + ) + .unwrap_or_else(|error| panic!("{description}: must succeed: {error}")); + assert!(inserted, "{description}: must insert"); + + let element = match ops.pop().expect("one operation must be pushed") { + LowLevelDriveOperation::GroveOperation(grove_op) => match grove_op.op { + GroveOp::InsertOrReplace { element } => element, + other => panic!("{description}: expected InsertOrReplace, got {other:?}"), + }, + other => panic!("{description}: expected GroveOperation, got {other:?}"), + }; + assert!(element_matches(&element), "{description}: got {element:?}"); + } + + // A sum-bearing inner under a CountSumTree parent takes the + // NotCountedOrSummed wrapper. + let mut ops = vec![]; + let inserted = drive + .batch_insert_empty_tree_if_not_exists_v0( + PathKeyInfo::<0>::PathKeyRef((vec![b"root".to_vec()], b"child-sum")), + TreeType::SumTree, + super::EmptyTreeInsertMode::ContributingZeroToParent(TreeType::CountSumTree), + None, + BatchInsertTreeApplyType::StatefulBatchInsertTree, + Some(&tx), + &mut None, + &mut ops, + &pv.drive, + ) + .expect("CountSumTree parent + SumTree inner must succeed"); + assert!(inserted); + let element = match ops.pop().expect("one operation must be pushed") { + LowLevelDriveOperation::GroveOperation(grove_op) => match grove_op.op { + GroveOp::InsertOrReplace { element } => element, + other => panic!("expected InsertOrReplace, got {other:?}"), + }, + other => panic!("expected GroveOperation, got {other:?}"), + }; + assert!( + matches!( + &element, + Element::NotCountedOrSummed(inner) if matches!(inner.as_ref(), Element::SumTree(..)) + ), + "CountSumTree parent + SumTree inner → NotCountedOrSummed(SumTree), got {element:?}" + ); + } } 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 0358d14dac4..04b75c6761e 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 @@ -3,6 +3,7 @@ use versioned_feature_core::FeatureVersion; pub mod v1; pub mod v2; pub mod v3; +pub mod v4; #[derive(Clone, Debug, Default)] pub struct DriveDocumentMethodVersions { 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 new file mode 100644 index 00000000000..6ecd4d4f9d7 --- /dev/null +++ b/packages/rs-platform-version/src/version/drive_versions/drive_document_method_versions/v4.rs @@ -0,0 +1,115 @@ +use crate::version::drive_versions::drive_document_method_versions::{ + DriveDocumentDeleteMethodVersions, DriveDocumentEstimationCostsMethodVersions, + DriveDocumentIndexUniquenessMethodVersions, DriveDocumentInsertContestedMethodVersions, + DriveDocumentInsertMethodVersions, DriveDocumentMethodVersions, + DriveDocumentQueryMethodVersions, DriveDocumentUpdateMethodVersions, +}; + +/// V4 differs from V3 in four method-version bumps that fix the +/// shared-prefix aggregate index defect: a contract declaring an +/// aggregating (countable / summable) index terminating at a property +/// that is also the prefix of a compound index (e.g. summable `[a]` +/// next to `[a, b]`) registered fine but rejected every document +/// insert for most flag combinations, because the continuation +/// property-name tree could not be legally hung under the aggregating +/// value tree. +/// +/// - `insert.add_indices_for_index_level_for_contract_operations: 1 → 2` +/// - `insert.add_indices_for_top_index_level_for_contract_operations: 1 → 2` +/// - `delete.remove_indices_for_index_level_for_contract_operations: 1 → 2` +/// - `delete.remove_indices_for_top_index_level_for_contract_operations: 1 → 2` +/// +/// The v2 walkers derive tree types through the shared +/// continuation-demotion helper (provable count-bearing value trees +/// with compound continuations demote to `CountSumTree`, since grovedb +/// rejects count-suppressed children under provable count parents by +/// design) and route continuation inserts through the completed +/// zero-contribution wrapper matrix. No migration is needed: shapes +/// without compound continuations produce bit-identical operations, +/// the broken shapes could never hold documents, and the one +/// previously-insertable shape the demotion changes (a provable +/// count-bearing value tree whose continuations were all sum-bearing — +/// insertable pre-v14 only through an unenforced grovedb batch guard) +/// simply gets `CountSumTree` value trees for values first seen at +/// v14+, which readers treat identically. Insert and delete bump +/// together because the delete walkers' estimation layer info must +/// describe the exact on-disk shape the insert walkers write. +/// +/// v1 walkers stay consensus-locked for protocol v12/v13. +pub const DRIVE_DOCUMENT_METHOD_VERSIONS_V4: DriveDocumentMethodVersions = + DriveDocumentMethodVersions { + query: DriveDocumentQueryMethodVersions { + query_documents: 0, + query_contested_documents: 0, + query_contested_documents_vote_state: 0, + query_documents_with_flags: 0, + fetch_document_history_query: 0, + fetch_document_history: 0, + prove_document_history: 0, + detect_count_mode: 0, + detect_sum_mode: 0, + }, + delete: DriveDocumentDeleteMethodVersions { + add_estimation_costs_for_remove_document_to_primary_storage: 0, + delete_document_for_contract: 0, + delete_document_for_contract_id: 0, + delete_document_for_contract_apply_and_add_to_operations: 0, + remove_document_from_primary_storage: 0, + remove_reference_for_index_level_for_contract_operations: 0, + remove_indices_for_index_level_for_contract_operations: 2, + remove_indices_for_top_index_level_for_contract_operations: 2, + delete_document_for_contract_id_with_named_type_operations: 0, + delete_document_for_contract_with_named_type_operations: 0, + delete_document_for_contract_operations: 0, + }, + insert: DriveDocumentInsertMethodVersions { + add_document: 0, + add_history_operations: 0, + add_document_for_contract: 0, + add_document_for_contract_apply_and_add_to_operations: 0, + add_document_for_contract_operations: 0, + add_document_to_primary_storage: 0, + add_indices_for_index_level_for_contract_operations: 2, + add_indices_for_top_index_level_for_contract_operations: 2, + add_reference_for_index_level_for_contract_operations: 0, + }, + insert_contested: DriveDocumentInsertContestedMethodVersions { + add_contested_document: 0, + add_contested_document_for_contract: 0, + add_contested_document_for_contract_apply_and_add_to_operations: 0, + add_contested_document_for_contract_operations: 0, + add_contested_document_to_primary_storage: 0, + add_contested_indices_for_contract_operations: 0, + add_contested_reference_and_vote_subtree_to_document_operations: 0, + add_contested_vote_subtree_for_non_identities_operations: 0, + }, + update: DriveDocumentUpdateMethodVersions { + add_update_multiple_documents_operations: 0, + update_document_for_contract: 0, + update_document_for_contract_apply_and_add_to_operations: 0, + update_document_for_contract_id: 0, + // Bumped alongside the four walkers: a key-changing update + // materializes index branches itself, so it must derive the + // same post-demotion tree types and zero-contribution + // wrappers as the v2 insert walkers or the shapes (and the + // per-value aggregates) diverge. + update_document_for_contract_operations: 1, + update_document_with_serialization_for_contract: 0, + update_serialized_document_for_contract: 0, + }, + estimation_costs: DriveDocumentEstimationCostsMethodVersions { + add_estimation_costs_for_add_document_to_primary_storage: 0, + add_estimation_costs_for_add_contested_document_to_primary_storage: 0, + stateless_delete_of_non_tree_for_costs: 0, + }, + index_uniqueness: DriveDocumentIndexUniquenessMethodVersions { + validate_document_create_transition_action_uniqueness: 1, + validate_document_replace_transition_action_uniqueness: 1, + validate_document_transfer_transition_action_uniqueness: 1, + validate_document_purchase_transition_action_uniqueness: 1, + validate_document_update_price_transition_action_uniqueness: 1, + }, + // Unchanged from V3 — see V3's comment for the v12-gated + // count/sum composition rationale. + primary_key_tree_type: 1, + }; diff --git a/packages/rs-platform-version/src/version/drive_versions/mod.rs b/packages/rs-platform-version/src/version/drive_versions/mod.rs index b5b7efa103c..ca7c22c6e3f 100644 --- a/packages/rs-platform-version/src/version/drive_versions/mod.rs +++ b/packages/rs-platform-version/src/version/drive_versions/mod.rs @@ -35,6 +35,7 @@ pub mod v5; pub mod v6; pub mod v7; pub mod v8; +pub mod v9; #[derive(Clone, Debug, Default)] pub struct DriveVersion { diff --git a/packages/rs-platform-version/src/version/drive_versions/v9.rs b/packages/rs-platform-version/src/version/drive_versions/v9.rs new file mode 100644 index 00000000000..94159644ee5 --- /dev/null +++ b/packages/rs-platform-version/src/version/drive_versions/v9.rs @@ -0,0 +1,137 @@ +use crate::version::drive_versions::drive_address_funds_method_versions::v2::DRIVE_ADDRESS_FUNDS_METHOD_VERSIONS_V2; +use crate::version::drive_versions::drive_contract_method_versions::v3::DRIVE_CONTRACT_METHOD_VERSIONS_V3; +use crate::version::drive_versions::drive_credit_pool_method_versions::v1::CREDIT_POOL_METHOD_VERSIONS_V1; +use crate::version::drive_versions::drive_document_method_versions::v4::DRIVE_DOCUMENT_METHOD_VERSIONS_V4; +use crate::version::drive_versions::drive_group_method_versions::v1::DRIVE_GROUP_METHOD_VERSIONS_V1; +use crate::version::drive_versions::drive_group_method_versions::DriveShieldedMethodVersions; +use crate::version::drive_versions::drive_grove_method_versions::v1::DRIVE_GROVE_METHOD_VERSIONS_V1; +use crate::version::drive_versions::drive_identity_method_versions::v1::DRIVE_IDENTITY_METHOD_VERSIONS_V1; +use crate::version::drive_versions::drive_state_transition_method_versions::v3::DRIVE_STATE_TRANSITION_METHOD_VERSIONS_V3; +use crate::version::drive_versions::drive_structure_version::v1::DRIVE_STRUCTURE_V1; +use crate::version::drive_versions::drive_token_method_versions::v1::DRIVE_TOKEN_METHOD_VERSIONS_V1; +use crate::version::drive_versions::drive_verify_method_versions::v2::DRIVE_VERIFY_METHOD_VERSIONS_V2; +use crate::version::drive_versions::drive_vote_method_versions::v2::DRIVE_VOTE_METHOD_VERSIONS_V2; +use crate::version::drive_versions::{ + DriveAssetLockMethodVersions, DriveBalancesMethodVersions, DriveBatchOperationsMethodVersion, + DriveEstimatedCostsMethodVersions, DriveFeesMethodVersions, DriveFetchMethodVersions, + DriveInitializationMethodVersions, DriveMethodVersions, DriveOperationsMethodVersion, + DrivePlatformStateMethodVersions, DrivePlatformSystemMethodVersions, + DrivePrefundedSpecializedMethodVersions, DriveProtocolUpgradeVersions, + DriveProveMethodVersions, DriveSavedBlockTransactionsMethodVersions, + DriveSystemEstimationCostsMethodVersions, DriveVersion, +}; +use grovedb_version::version::v3::GROVE_V3; + +/// Drive version 9. +/// Introduced in protocol v14 for the shared-prefix aggregate index fix: +/// `DRIVE_DOCUMENT_METHOD_VERSIONS_V4` bumps the four index walkers to v2 +/// (and the document update walker to v1) so contracts that pair an +/// aggregating (countable / summable) index with a compound index sharing +/// its leading property can insert, update, and delete documents. +/// Everything else matches `DRIVE_VERSION_V8`. +pub const DRIVE_VERSION_V9: DriveVersion = DriveVersion { + structure: DRIVE_STRUCTURE_V1, + methods: DriveMethodVersions { + initialization: DriveInitializationMethodVersions { + create_initial_state_structure: 3, // changed in v8: adds shielded pool trees (commitment tree, nullifiers, anchors) + }, + credit_pools: CREDIT_POOL_METHOD_VERSIONS_V1, + protocol_upgrade: DriveProtocolUpgradeVersions { + clear_version_information: 0, + fetch_versions_with_counter: 0, + fetch_proved_versions_with_counter: 0, + fetch_validator_version_votes: 0, + fetch_proved_validator_version_votes: 0, + remove_validators_proposed_app_versions: 0, + update_validator_proposed_app_version: 0, + }, + prove: DriveProveMethodVersions { + prove_elements: 0, + prove_multiple_state_transition_results: 0, + prove_state_transition: 0, + }, + balances: DriveBalancesMethodVersions { + add_to_system_credits: 0, + add_to_system_credits_operations: 0, + remove_from_system_credits: 0, + remove_from_system_credits_operations: 0, + calculate_total_credits_balance: 2, // ShieldedBalances root tree adds a fifth term to the equation + }, + document: DRIVE_DOCUMENT_METHOD_VERSIONS_V4, // changed in v9: v2 index walkers + v1 update walker — shared-prefix aggregate indexes become insertable + vote: DRIVE_VOTE_METHOD_VERSIONS_V2, + contract: DRIVE_CONTRACT_METHOD_VERSIONS_V3, // changed in v8: count-tree-aware contract-insertion cost estimation (v12+ countable/range_countable doctypes) + fees: DriveFeesMethodVersions { calculate_fee: 0 }, + estimated_costs: DriveEstimatedCostsMethodVersions { + add_estimation_costs_for_levels_up_to_contract: 0, + add_estimation_costs_for_levels_up_to_contract_document_type_excluded: 0, + add_estimation_costs_for_contested_document_tree_levels_up_to_contract: 0, + add_estimation_costs_for_contested_document_tree_levels_up_to_contract_document_type_excluded: 0, + }, + asset_lock: DriveAssetLockMethodVersions { + add_asset_lock_outpoint: 0, + add_estimation_costs_for_adding_asset_lock: 0, + fetch_asset_lock_outpoint_info: 0, + }, + verify: DRIVE_VERIFY_METHOD_VERSIONS_V2, // changed in v8: compacted address-balance proof envelope (verify v1) + identity: DRIVE_IDENTITY_METHOD_VERSIONS_V1, + token: DRIVE_TOKEN_METHOD_VERSIONS_V1, + platform_system: DrivePlatformSystemMethodVersions { + estimation_costs: DriveSystemEstimationCostsMethodVersions { + for_total_system_credits_update: 0, + }, + }, + operations: DriveOperationsMethodVersion { + rollback_transaction: 0, + drop_cache: 0, + commit_transaction: 0, + apply_partial_batch_low_level_drive_operations: 0, + apply_partial_batch_grovedb_operations: 0, + apply_batch_low_level_drive_operations: 0, + apply_batch_grovedb_operations: 0, + }, + state_transitions: DRIVE_STATE_TRANSITION_METHOD_VERSIONS_V3, // changed in v8: DPNS domain records.identity rewrite on transfer/purchase + batch_operations: DriveBatchOperationsMethodVersion { + convert_drive_operations_to_grove_operations: 0, + apply_drive_operations: 0, + }, + platform_state: DrivePlatformStateMethodVersions { + fetch_platform_state_bytes: 0, + store_platform_state_bytes: 0, + }, + fetch: DriveFetchMethodVersions { fetch_elements: 0 }, + prefunded_specialized_balances: DrivePrefundedSpecializedMethodVersions { + fetch_single: 0, + prove_single: 0, + add_prefunded_specialized_balance: 0, + add_prefunded_specialized_balance_operations: 1, + deduct_from_prefunded_specialized_balance: 1, + deduct_from_prefunded_specialized_balance_operations: 0, + estimated_cost_for_prefunded_specialized_balance_update: 0, + empty_prefunded_specialized_balance: 0, + }, + group: DRIVE_GROUP_METHOD_VERSIONS_V1, + address_funds: DRIVE_ADDRESS_FUNDS_METHOD_VERSIONS_V2, + shielded: DriveShieldedMethodVersions { + insert_note: 0, + insert_nullifiers: 0, + update_total_balance: 0, + record_anchor_if_changed: 0, + prune_anchors: 0, + has_anchor: 0, + has_nullifier: 0, + read_total_balance: 0, + notes_count: 0, + }, + saved_block_transactions: DriveSavedBlockTransactionsMethodVersions { + store_address_balances: 0, + fetch_address_balances: 0, + prove_compacted_address_balance_changes: 1, + compact_address_balances: 0, + cleanup_expired_address_balances: 0, + max_blocks_before_compaction: 64, + max_addresses_before_compaction: 2048, + }, + }, + grove_methods: DRIVE_GROVE_METHOD_VERSIONS_V1, + grove_version: GROVE_V3, // changed in v7: upgraded for shielded transaction support +}; diff --git a/packages/rs-platform-version/src/version/v14.rs b/packages/rs-platform-version/src/version/v14.rs index 34cf0c2b98f..a6c307f3d41 100644 --- a/packages/rs-platform-version/src/version/v14.rs +++ b/packages/rs-platform-version/src/version/v14.rs @@ -21,7 +21,7 @@ use crate::version::drive_abci_versions::drive_abci_structure_versions::v1::DRIV use crate::version::drive_abci_versions::drive_abci_validation_versions::v9::DRIVE_ABCI_VALIDATION_VERSIONS_V9; use crate::version::drive_abci_versions::drive_abci_withdrawal_constants::v2::DRIVE_ABCI_WITHDRAWAL_CONSTANTS_V2; use crate::version::drive_abci_versions::DriveAbciVersion; -use crate::version::drive_versions::v8::DRIVE_VERSION_V8; +use crate::version::drive_versions::v9::DRIVE_VERSION_V9; use crate::version::fee::v2::FEE_VERSION2; use crate::version::protocol_version::PlatformVersion; use crate::version::system_data_contract_versions::v2::SYSTEM_DATA_CONTRACT_VERSIONS_V2; @@ -30,17 +30,34 @@ use crate::version::ProtocolVersion; pub const PROTOCOL_VERSION_14: ProtocolVersion = 14; -/// Introduced as the activation gate for the shared-prefix aggregate index -/// fix (v2 document index walkers: an aggregating countable / summable index -/// whose terminal property also prefixes a compound index registers today -/// but rejects document inserts). Functionally identical to v13 at -/// introduction — the same component version structs, no behavior change. -/// The consensus change that consumes this gate (a bumped drive document -/// methods struct) lands in a follow-up; keeping v14 == v13 here lets -/// mixed-version validators agree until that change activates. +/// v14 fixes the shared-prefix aggregate index defect: a data contract +/// declaring an aggregating (countable / summable) index that terminates at +/// a property which is also the prefix of a compound index (e.g. summable +/// `[a]` next to `[a, b]`) registered successfully but rejected every +/// document insert for most flag combinations, because Drive could not +/// legally hang the compound continuation tree under the aggregating +/// per-value tree. +/// +/// `DRIVE_VERSION_V9` (via `DRIVE_DOCUMENT_METHOD_VERSIONS_V4`) bumps the +/// four document index walkers (insert/delete x top-level/recursive) to v2: +/// tree types derive through a shared continuation-demotion helper (provable +/// count-bearing value trees with compound continuations demote to +/// `CountSumTree`, since grovedb rejects count-suppressed children under +/// provable count parents by design) and continuation inserts route through +/// the completed zero-contribution wrapper matrix (`NonCounted` for non-sum +/// continuations under count-sum parents, unwrapped inserts under sum-only +/// parents, and so on). No state migration is needed: shapes without +/// compound continuations produce bit-identical operations, the broken +/// shapes could never hold documents, and the one previously-insertable +/// shape the demotion changes (a provable count-bearing value tree whose +/// continuations were all sum-bearing — insertable pre-v14 only through an +/// unenforced grovedb batch guard) simply gets `CountSumTree` value trees +/// for values first seen at v14+, which readers treat identically. +/// +/// Everything else matches v13. pub const PLATFORM_V14: PlatformVersion = PlatformVersion { protocol_version: PROTOCOL_VERSION_14, - drive: DRIVE_VERSION_V8, + drive: DRIVE_VERSION_V9, // changed: v2 index walkers — shared-prefix aggregate indexes become insertable drive_abci: DriveAbciVersion { structs: DRIVE_ABCI_STRUCTURE_VERSIONS_V1, methods: DRIVE_ABCI_METHOD_VERSIONS_V9,