From f210ae9aa129516276bfc65b8ddf5560ff8d49cb Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Tue, 4 Aug 2026 20:27:40 +0700 Subject: [PATCH 1/5] feat: count-provable sum-axis secondaries + count-bound offset proofs for all indexed axes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Part 1 — the sum axis's secondary Merk (PSIT, and PCPSIT's sum axis) is now a ProvableCountProvableSumTree instead of a ProvableSumTree. Each SumItem row contributes (count = 1, sum), so every node commitment binds a subtree count alongside the sum (~8 extra feature bytes per node). The primary stays lean (ProvableSummedMerkNode, unchanged). The change is a single arm in axis_secondary_tree_type; open/mirror/batch/ verify_grovedb/estimators all derive from it, and the proof node family for sum-axis secondary proofs moves to the KVCountSum/KVHashCountSum/ KVDigestCountSum/KVRefValueHashCountSum/HashWithCountAndSum set automatically via feature-type-driven dispatch. Storage format amended in place — the indexed-tree family is unreleased (PV14 unshipped). Part 2 — with every axis's secondary now count-bearing, the sum axis's offset-paginated proof rides Merk::prove_count_offset_on_range like count and avg already did: the skipped prefix is attested by counted subtree commitments (O(log n + k) regardless of offset) instead of the old O(offset + k) enumeration fallback, whose u16 offset+k ceiling is gone. The verifier's skipped count is now cryptographically attested for all axes; offset-past-end verifies as an empty page with skipped < offset, which is itself a proof the total population equals skipped. The gate is structural (count-bearing secondary tree type), not per-axis. Also: - prove/verify_indexed_axis_rank_of_key: "key X is at rank R" as a paginated window (offset = R, k = 1) whose yielded entry binds X; rank computed O(log n) from the secondary's count aggregates. - Envelope decoders now reject trailing bytes (proof byte-malleability). - proof_node_type family sets gain the PSIT/PCPSIT primary arms, mirroring the existing PCIT arm. - New test suite indexed_axis_offset_proof_tests: offset 0 == top-k, mid-walk offsets, windows spanning/past the end, single-entry rank windows, ties straddling the offset boundary, both directions, bit-flip/truncation/parameter-mismatch rejection, rank-of-key. Co-Authored-By: Claude Fable 5 --- grovedb-element/src/element/mod.rs | 14 +- grovedb-element/src/element_type.rs | 16 +- grovedb/src/lib.rs | 3 +- grovedb/src/operations/indexed_tree.rs | 23 +- .../operations/proof/indexed_axis/axis_api.rs | 8 +- .../operations/proof/indexed_axis/envelope.rs | 48 +- .../operations/proof/indexed_axis/generate.rs | 258 +++++-- .../operations/proof/indexed_axis/verify.rs | 211 +++-- .../indexed_axis_nested_and_bounds_tests.rs | 52 +- .../tests/indexed_axis_offset_proof_tests.rs | 729 ++++++++++++++++++ grovedb/src/tests/indexed_axis_proof_tests.rs | 7 +- grovedb/src/tests/mod.rs | 1 + .../src/tests/verify_grovedb_indexed_tests.rs | 2 +- merk/src/tree_type/mod.rs | 21 +- 14 files changed, 1168 insertions(+), 225 deletions(-) create mode 100644 grovedb/src/tests/indexed_axis_offset_proof_tests.rs diff --git a/grovedb-element/src/element/mod.rs b/grovedb-element/src/element/mod.rs index b46a9e039..6cf2cde95 100644 --- a/grovedb-element/src/element/mod.rs +++ b/grovedb-element/src/element/mod.rs @@ -249,7 +249,11 @@ pub enum Element { ProvableCountProvableSumTree(Option>, CountValue, SumValue, Option), /// Provable sum-indexed tree: a `ProvableSumTree`-style primary Merk /// paired with a single secondary Merk keyed by - /// `(sum_sortable_be ‖ original_key)`. Both Merks contribute to the + /// `(sum_sortable_be ‖ original_key)`. The secondary is a + /// `ProvableCountProvableSumTree` — each row is a `SumItem` + /// contributing `(count = 1, sum)` — so positional queries against + /// the sum ranking (offset pagination) are provable in O(log n) via + /// counted subtree commitments. Both Merks contribute to the /// element's `combined_value_hash` via the three-input hash composition /// `combine_hash_three(value_hash, primary_root_hash, /// secondary_root_hash)`. @@ -288,9 +292,11 @@ pub enum Element { /// `ProvableCountedAndProvableSummedMerkNode` (both count AND sum /// baked into node hash) and carries a TLV list of 1..=3 secondary /// Merks — one per selected axis (count, sum, avg). Each secondary - /// lives at its own derived storage prefix and is itself a - /// `ProvableCountProvableSumTree` so any axis can produce both - /// count-on-range and sum-on-range proofs. + /// lives at its own derived storage prefix; the count axis is a + /// `ProvableCountTree` while the sum and avg axes are + /// `ProvableCountProvableSumTree`s, so every axis carries a + /// hash-bound count (enabling count-bound offset pagination) and + /// the sum/avg axes can additionally produce sum-on-range proofs. /// /// Fields: `(primary_root_key, count_value, sum_value, axes, flags)` /// - `primary_root_key`: root key of the primary diff --git a/grovedb-element/src/element_type.rs b/grovedb-element/src/element_type.rs index a799366cd..e7d0b9c0e 100644 --- a/grovedb-element/src/element_type.rs +++ b/grovedb-element/src/element_type.rs @@ -625,15 +625,25 @@ impl ElementType { // `KvValueHashFeatureType` — the embedded `TreeFeatureType` carries // the per-node aggregate(s) so a single proof-node variant suffices // for the subtree case in every family. + // Indexed-tree primaries dispatch with their own node family: + // PCIT primaries use count-only nodes, PSIT primaries use + // sum-only nodes, and PCPSIT primaries use count-and-sum nodes + // (mirroring `TreeType::inner_node_type`). let is_provable_count_only_tree = matches!( parent_base, Some(ElementType::ProvableCountTree) | Some(ElementType::ProvableCountSumTree) | Some(ElementType::ProvableCountIndexedTree) ); - let is_provable_sum_only_tree = matches!(parent_base, Some(ElementType::ProvableSumTree)); - let is_provable_count_and_provable_sum_tree = - matches!(parent_base, Some(ElementType::ProvableCountProvableSumTree)); + let is_provable_sum_only_tree = matches!( + parent_base, + Some(ElementType::ProvableSumTree) | Some(ElementType::ProvableSumIndexedTree) + ); + let is_provable_count_and_provable_sum_tree = matches!( + parent_base, + Some(ElementType::ProvableCountProvableSumTree) + | Some(ElementType::ProvableCountProvableSumIndexedTree) + ); let is_provable_aggregate_tree = is_provable_count_only_tree || is_provable_sum_only_tree || is_provable_count_and_provable_sum_tree; diff --git a/grovedb/src/lib.rs b/grovedb/src/lib.rs index a3f509a8a..79e02e394 100644 --- a/grovedb/src/lib.rs +++ b/grovedb/src/lib.rs @@ -2166,7 +2166,8 @@ impl GroveDb { } } // ProvableSumIndexedTree integrity: identical shape to - // PCIT but the secondary is a `ProvableSumTree`. Open + // PCIT but the secondary is a + // `ProvableCountProvableSumTree`. Open // both Merks, recompute `combine_hash_three(value_hash, // primary_root_hash, secondary_root_hash)`, compare // to the parent's stored combined value hash, and diff --git a/grovedb/src/operations/indexed_tree.rs b/grovedb/src/operations/indexed_tree.rs index 88bbfe777..1da2cc91c 100644 --- a/grovedb/src/operations/indexed_tree.rs +++ b/grovedb/src/operations/indexed_tree.rs @@ -58,8 +58,12 @@ pub(crate) fn axis_secondary_tree_type(axis: IndexAxis) -> TreeType { match axis { // Each count entry contributes count = 1. IndexAxis::Count => TreeType::ProvableCountTree, - // Each sum entry contributes its own SumValue. - IndexAxis::Sum => TreeType::ProvableSumTree, + // Each sum entry contributes (count = 1, sum = its own SumValue). + // The count half is what makes positional queries against the sum + // ranking provable in O(log n): the count-offset proof primitive + // skips whole subtrees via counted node commitments, which needs + // every secondary node to carry a hash-bound count aggregate. + IndexAxis::Sum => TreeType::ProvableCountProvableSumTree, // Each avg entry contributes (count = 1, sum = item's SumValue). IndexAxis::Avg => TreeType::ProvableCountProvableSumTree, } @@ -2198,11 +2202,13 @@ impl GroveDb { /// /// The secondary entry is a no-payload `Item` whose own sum / count /// contribution comes from its position in a sum/count-bearing tree. -/// For the sum axis the secondary entry is a `SumItem(sum)`; for the -/// avg axis the secondary entry is an `ItemWithSumItem(empty, sum)` so -/// both count (= 1) and sum (= the entry's sum_value) propagate to the -/// secondary's `ProvableCountProvableSumTree`. For the count axis the -/// secondary entry is a plain `Item` (count = 1, no sum). +/// For the sum axis the secondary entry is a `SumItem(sum)`, which in +/// the secondary's `ProvableCountProvableSumTree` contributes +/// (count = 1, sum); for the avg axis the secondary entry is an +/// `ItemWithSumItem(empty, sum)` so both count (= 1) and sum (= the +/// entry's sum_value) propagate to the secondary's +/// `ProvableCountProvableSumTree`. For the count axis the secondary +/// entry is a plain `Item` (count = 1, no sum). #[allow(clippy::too_many_arguments)] pub(crate) fn mirror_indexed_axis_to_secondary<'db, S: StorageContext<'db>>( secondary: &mut Merk, @@ -2284,7 +2290,8 @@ pub(crate) fn mirror_indexed_axis_to_secondary<'db, S: StorageContext<'db>>( // equality check above uses, so the two stay in lockstep: // - Count → empty Item (secondary is a ProvableCountTree; every // entry contributes count = 1) - // - Sum → SumItem(sum) (secondary is a ProvableSumTree) + // - Sum → SumItem(sum) (secondary is a + // ProvableCountProvableSumTree; contributes (1, sum)) // - Avg → ItemWithSumItem(empty, sum) (secondary is a // ProvableCountProvableSumTree; contributes (1, sum)) let entry = axis_payload(new_sum_val); diff --git a/grovedb/src/operations/proof/indexed_axis/axis_api.rs b/grovedb/src/operations/proof/indexed_axis/axis_api.rs index cc22bba15..3fad8e904 100644 --- a/grovedb/src/operations/proof/indexed_axis/axis_api.rs +++ b/grovedb/src/operations/proof/indexed_axis/axis_api.rs @@ -213,10 +213,10 @@ impl GroveDb { ) } - /// Prove an offset-paginated top-`k` window on the sum axis. - /// Note: the secondary is a `ProvableSumTree`, which has no - /// count-bound offset primitive, so the proof size is - /// O(offset + k). Use sparingly with large offsets. + /// Prove an offset-paginated top-`k` window on the sum axis. The + /// secondary is a `ProvableCountProvableSumTree`, so the skipped + /// prefix is attested by counted subtree commitments and the proof + /// size is O(log n + k) regardless of `offset`. #[cfg(feature = "minimal")] pub fn prove_indexed_sum_top_k_paginated<'b, B, P>( &self, diff --git a/grovedb/src/operations/proof/indexed_axis/envelope.rs b/grovedb/src/operations/proof/indexed_axis/envelope.rs index f8db0f047..7ed405206 100644 --- a/grovedb/src/operations/proof/indexed_axis/envelope.rs +++ b/grovedb/src/operations/proof/indexed_axis/envelope.rs @@ -89,15 +89,13 @@ pub struct IndexedAxisRangeProof { /// Wire-format envelope for an offset-paginated top-k proof over an /// indexed-tree's per-axis secondary. /// -/// For count and avg axes (`ProvableCountTree` / dual-axis -/// `ProvableCountProvableSumTree` secondaries) the secondary proof is -/// produced by `Merk::prove_count_offset_on_range`, giving -/// `O(log n + k)` proof size regardless of `offset`. For the sum axis -/// (`ProvableSumTree` secondary) there is no count-bound offset -/// primitive, so the prover instead emits a regular range proof with -/// `limit = offset + k` and the verifier discards the first `offset` -/// items independently. The `axis_tag` field disambiguates the two -/// shapes. +/// Every axis's secondary binds a count aggregate into its node hashes +/// (count axis: `ProvableCountTree`; sum and avg axes: dual-axis +/// `ProvableCountProvableSumTree`), so the secondary proof is always +/// produced by `Merk::prove_count_offset_on_range`: the skipped prefix +/// is attested by counted subtree commitments (`HashWithCount` / +/// `HashWithCountAndSum`), giving `O(log n + k)` proof size regardless +/// of `offset`. #[derive(Encode, Decode, Debug)] pub struct IndexedAxisPaginatedProof { /// Echoed [`IndexAxis::tag`] of the queried axis. The verifier @@ -113,12 +111,9 @@ pub struct IndexedAxisPaginatedProof { pub other_axes_root_hashes: Vec<(u8, [u8; 32])>, /// Same as [`IndexedAxisRangeProof::target_is_pcpsit`]. pub target_is_pcpsit: bool, - /// Encoded paginated proof bytes for the per-axis secondary. - /// - /// For count/avg axes this is the - /// `prove_count_offset_on_range`-produced `Vec` stream. For - /// the sum axis this is a regular `Merk::prove`-produced range - /// proof bound by `limit = offset + k`. + /// Encoded paginated proof bytes for the per-axis secondary: the + /// `prove_count_offset_on_range`-produced `Vec` stream (every + /// axis's secondary carries a provable count). pub secondary_proof: Vec, /// Echoed pagination parameters. pub requested_k: u16, @@ -204,16 +199,19 @@ pub struct IndexedAxisPaginatedResult { pub root_hash: CryptoHash, /// Per-axis decoded entries (after the `skipped` offset region). pub entries: AxisEntries, - /// Number of secondary entries the proof committed as skipped. - /// For count/avg axes this is independently re-derived by the - /// verifier from `HashWithCount` commitments in the proof bytes - /// (i.e. *cryptographically* committed). For the sum axis this - /// is the verifier-side count of items returned by the regular - /// range proof up to the `offset` cutoff — also independently - /// derived from the proof bytes, but constrained only by the - /// merk's regular range-walk discipline (NOT a count - /// commitment). The caller must cross-check - /// `skipped == expected_offset` if exact-page semantics matter. + /// Number of secondary entries the proof committed as skipped, + /// independently re-derived by the verifier from the counted + /// subtree commitments (`HashWithCount` / `HashWithCountAndSum`) + /// in the proof bytes — i.e. *cryptographically* attested for + /// every axis. + /// + /// `skipped == requested_offset` unless the walk was exhausted + /// first, in which case `skipped < requested_offset` and + /// `entries` is empty — that shape is itself a proof that the + /// secondary's total population is exactly `skipped` (the counted + /// commitments cover the whole walk). Callers wanting strict + /// "page exists" semantics should cross-check + /// `skipped == expected_offset`. pub skipped: u64, } diff --git a/grovedb/src/operations/proof/indexed_axis/generate.rs b/grovedb/src/operations/proof/indexed_axis/generate.rs index ac807ded7..96a541df7 100644 --- a/grovedb/src/operations/proof/indexed_axis/generate.rs +++ b/grovedb/src/operations/proof/indexed_axis/generate.rs @@ -394,11 +394,24 @@ impl GroveDb { /// indexed-tree on a specific axis, starting after `offset` entries /// in the directional walk. /// - /// For count and avg axes the secondary proof uses - /// `Merk::prove_count_offset_on_range` (O(log n + k)); for the sum - /// axis it uses a regular range proof with `limit = offset + k` - /// (O(offset + k)) — no count-bound offset primitive exists for - /// `ProvableSumTree` hosts. + /// Every axis's secondary carries a hash-bound count aggregate + /// (count axis: `ProvableCountTree`; sum and avg axes: + /// `ProvableCountProvableSumTree`), so the secondary proof always + /// uses `Merk::prove_count_offset_on_range` — the skipped prefix is + /// attested by counted subtree commitments (`HashWithCount` / + /// `HashWithCountAndSum`) instead of enumeration, giving + /// O(log n + k) proof size regardless of `offset`. + /// + /// Ties (equal axis values) break by `original_key` in walk + /// direction, in both the skipped prefix and the yielded window — + /// the secondary is keyed `(axis_sort_key ‖ original_key)` so the + /// walk order is total and deterministic. + /// + /// `offset` past the end of the walk is provable: the prover skips + /// everything it can and yields nothing; the verifier reports the + /// attested `skipped < offset`, which together with the root-bound + /// count commitments is a proof that the total population is + /// exactly `skipped`. pub fn prove_indexed_axis_top_k_paginated<'b, B, P>( &self, path: P, @@ -443,6 +456,151 @@ impl GroveDb { Ok(bytes).wrap_with_cost(cost) } + /// Prove that `item_key` sits at a specific rank in the directional + /// walk of an indexed axis: rank `R` (0-based) means exactly `R` + /// entries come strictly before it in the walk. Ties (equal axis + /// values) are broken by `original_key` in walk direction — the + /// same total order every other axis proof uses — so the rank is + /// well-defined even inside a tie group. + /// + /// Returns `(proof_bytes, rank)`. The proof is an ordinary + /// offset-paginated envelope with `offset = rank, k = 1`: the count + /// commitments attest that exactly `rank` entries precede the + /// single yielded entry, and the yielded entry's key binds the + /// claim to `item_key`. Verify with + /// [`Self::verify_indexed_axis_rank_of_key`], which additionally + /// checks the yielded entry is `item_key` and the attested skip is + /// exactly `rank`. + /// + /// Errors if `item_key` is not present in the indexed tree's + /// primary, or if the axis is not indexed at this path. + pub fn prove_indexed_axis_rank_of_key<'b, B, P>( + &self, + path: P, + axis: IndexAxis, + item_key: &[u8], + descending: bool, + transaction: TransactionArg, + grove_version: &GroveVersion, + ) -> CostResult<(Vec, u64), Error> + where + B: AsRef<[u8]> + 'b, + P: Into>, + { + use crate::operations::indexed_tree::make_axis_secondary_key; + + let mut cost = OperationCost::default(); + let path: SubtreePath = path.into(); + let batch = StorageBatch::new(); + let tx = TxRef::new(&self.db, transaction); + let tx_ref = tx.as_ref(); + + let path_keys: Vec> = path.to_vec(); + if path_keys.is_empty() { + return Err(Error::InvalidPath( + "cannot prove indexed-axis rank at root path".to_string(), + )) + .wrap_with_cost(cost); + } + + // 1. Read the item's element from the primary to derive its + // secondary sort key (the walk position is a pure function of + // the entry's (count, sum) aggregates plus its key). + let primary_merk = cost_return_on_error!( + &mut cost, + self.open_transactional_merk_at_path(path.clone(), tx_ref, Some(&batch), grove_version) + ); + if !primary_merk.tree_type.is_indexed_primary() { + return Err(Error::InvalidPath( + "prove_indexed_axis_rank_of_key requires the path's last segment to be an \ + indexed-tree element" + .to_string(), + )) + .wrap_with_cost(cost); + } + let item_element = cost_return_on_error!( + &mut cost, + Element::get(&primary_merk, item_key, true, grove_version).map_err(|e| { + Error::CorruptedData(format!( + "indexed-axis rank proof: item key not found in primary: {e}" + )) + }) + ); + let (count, sum) = item_element.count_sum_value_or_default(); + let secondary_key = make_axis_secondary_key(axis, count, sum, item_key); + + // 2. Compute the rank: the count of entries strictly before the + // item in the directional walk, read O(log n) off the + // secondary's count aggregates. + let (secondary_root_key, _, _) = cost_return_on_error!( + &mut cost, + read_queried_axis_info_with_path_keys( + self, + &path_keys, + axis, + tx_ref, + &batch, + grove_version, + "indexed-axis rank proof", + ) + ); + let secondary_merk = cost_return_on_error!( + &mut cost, + self.open_indexed_secondary_at_path( + path.clone(), + axis, + secondary_root_key, + tx_ref, + Some(&batch), + grove_version, + ) + ); + let before_range = if descending { + // Descending walk: everything with a strictly GREATER + // secondary key comes first. + MerkQueryItemForRange::RangeAfter(secondary_key.clone()..) + } else { + // Ascending walk: everything with a strictly SMALLER + // secondary key comes first. + MerkQueryItemForRange::RangeTo(..secondary_key.clone()) + }; + let rank = cost_return_on_error!( + &mut cost, + secondary_merk + .count_aggregate_on_range(&before_range, grove_version) + .map_err(|e| Error::CorruptedData(format!( + "indexed-axis rank proof: counting entries before the item: {e}" + ))) + ); + drop(secondary_merk); + drop(primary_merk); + + // 3. The rank proof IS the paginated proof at (offset = rank, + // k = 1): its counted commitments attest the skipped prefix + // and its single yielded entry binds the item. + let envelope = cost_return_on_error!( + &mut cost, + self.build_indexed_axis_paginated_proof( + path, + axis, + 1, + rank, + descending, + tx_ref, + &batch, + grove_version, + ) + ); + let bytes = cost_return_on_error_no_add!( + cost, + bincode::encode_to_vec(&envelope, bincode::config::standard()).map_err(|e| { + Error::CorruptedData(format!("encoding indexed-axis rank proof: {e}")) + }) + ); + + Ok((bytes, rank)).wrap_with_cost(cost) + } + /// Generate an aggregate proof over a value-range against an /// indexed-tree's per-axis secondary. /// @@ -717,10 +875,12 @@ impl GroveDb { }) ); - // 4. Open the per-axis secondary; emit the appropriate paginated - // proof per axis. Count/Avg axes have a HashWithCount-based - // primitive; Sum axis does not, so we fall back to a regular - // range proof with `limit = offset + k`. + // 4. Open the per-axis secondary and emit the count-offset + // paginated proof. The gate is structural, not per-axis: + // `Merk::prove_count_offset_on_range` rejects any host whose + // tree type does not bind a count aggregate into node hashes, + // so an axis whose secondary somehow lacked counts would fail + // here rather than silently degrade to enumeration. let secondary_merk = cost_return_on_error!( &mut cost, self.open_indexed_secondary_at_path( @@ -732,61 +892,31 @@ impl GroveDb { grove_version, ) ); - let serialized = match axis { - IndexAxis::Count | IndexAxis::Avg => { - let inner_range = MerkQueryItemForRange::RangeFull(std::ops::RangeFull); - let prove_result = cost_return_on_error!( - &mut cost, - secondary_merk - .prove_count_offset_on_range( - &inner_range, - offset, - Some(k as u64), - !descending, - grove_version, - ) - .map_err(|e| Error::CorruptedData(format!( - "indexed-axis paginated proof: secondary count-offset proof: {e}" - ))) - ); - let mut serialized = Vec::with_capacity(128); - encode_into(prove_result.ops.iter(), &mut serialized); - serialized - } - IndexAxis::Sum => { - // Sum axis: ProvableSumTree has no count-offset - // primitive. Emit a plain `prove` with limit = offset+k; - // the verifier discards the leading `offset` items. - let mut full_range = MerkQuery::new(); - full_range.insert_all(); - full_range.left_to_right = !descending; - // `offset + k` must fit a u16 Merk limit. Clamping instead - // would silently prove a SHORT page while the verifier's - // documented `skipped == expected_offset` cross-check still - // passed, so the caller would receive fewer rows than asked - // for with no error anywhere. Reject instead. - let combined = (offset as u128).saturating_add(k as u128); - if combined > u16::MAX as u128 { - return Err(Error::NotSupported(format!( - "indexed-axis paginated proof (sum): offset + k = {combined} exceeds the \ - {} entry limit a single page can prove; request a smaller page or a \ - smaller offset", - u16::MAX - ))) - .wrap_with_cost(cost); - } - let combined_limit = combined as u16; - let sec_result = cost_return_on_error!( - &mut cost, - secondary_merk - .prove(full_range, Some(combined_limit), grove_version) - .map_err(|e| Error::CorruptedData(format!( - "indexed-axis paginated proof: secondary regular proof (sum): {e}" - ))) - ); - sec_result.proof - } - }; + if !secondary_merk.tree_type.is_count_bearing() { + return Err(Error::NotSupported(format!( + "indexed-axis paginated proof: the {axis:?} axis secondary ({:?}) does not carry \ + a provable count aggregate, so offset pagination cannot be attested", + secondary_merk.tree_type + ))) + .wrap_with_cost(cost); + } + let inner_range = MerkQueryItemForRange::RangeFull(std::ops::RangeFull); + let prove_result = cost_return_on_error!( + &mut cost, + secondary_merk + .prove_count_offset_on_range( + &inner_range, + offset, + Some(k as u64), + !descending, + grove_version, + ) + .map_err(|e| Error::CorruptedData(format!( + "indexed-axis paginated proof: secondary count-offset proof: {e}" + ))) + ); + let mut serialized = Vec::with_capacity(128); + encode_into(prove_result.ops.iter(), &mut serialized); Ok(IndexedAxisPaginatedProof { axis_tag: axis.tag(), diff --git a/grovedb/src/operations/proof/indexed_axis/verify.rs b/grovedb/src/operations/proof/indexed_axis/verify.rs index e66d61b19..c6d731454 100644 --- a/grovedb/src/operations/proof/indexed_axis/verify.rs +++ b/grovedb/src/operations/proof/indexed_axis/verify.rs @@ -351,10 +351,11 @@ impl GroveDb { expected_descending: bool, ) -> Result { let config = bincode::config::standard().with_limit::<{ 16 * 1024 * 1024 }>(); - let (envelope, _): (IndexedAxisPaginatedProof, _) = + let (envelope, consumed): (IndexedAxisPaginatedProof, _) = bincode::decode_from_slice(proof_bytes, config).map_err(|e| { Error::CorruptedData(format!("decoding indexed-axis paginated proof: {e}")) })?; + reject_trailing_envelope_bytes(consumed, proof_bytes.len(), "paginated")?; if envelope.axis_tag != expected_axis.tag() { return Err(Error::CorruptedData(format!( "indexed-axis paginated proof axis mismatch: expected {:?} (tag={}), envelope \ @@ -386,6 +387,70 @@ impl GroveDb { verify_indexed_axis_paginated_inner(envelope, expected_axis, path) } + /// Verify a rank-of-key proof produced by + /// `prove_indexed_axis_rank_of_key`: the claim "exactly + /// `expected_rank` entries come strictly before `item_key` in the + /// directional walk of this axis". + /// + /// The proof is an offset-paginated envelope with + /// `offset = expected_rank, k = 1`. On top of the paginated + /// verification this additionally requires: + /// - the attested skipped count equals `expected_rank` exactly (a + /// truncated skip would mean the walk has fewer than + /// `expected_rank` entries, so no entry can sit at that rank), + /// - exactly one entry was yielded, and its original key is + /// `item_key` (binding the rank to the claimed key rather than + /// whatever happens to sit at that position). + /// + /// Returns the paginated result whose single entry carries the + /// item's axis value; `root_hash` must be compared against the + /// trusted GroveDB root as usual. + pub fn verify_indexed_axis_rank_of_key( + proof_bytes: &[u8], + path: &[&[u8]], + expected_axis: IndexAxis, + item_key: &[u8], + expected_rank: u64, + expected_descending: bool, + ) -> Result { + let result = Self::verify_indexed_axis_top_k_paginated( + proof_bytes, + path, + expected_axis, + 1, + expected_rank, + expected_descending, + )?; + if result.skipped != expected_rank { + return Err(Error::CorruptedData(format!( + "indexed-axis rank proof: the walk attests only {} entries before the window, \ + but rank {} was claimed — the walk is shorter than the claimed rank", + result.skipped, expected_rank + ))); + } + let yielded_key: &[u8] = match &result.entries { + AxisEntries::Count(v) if v.len() == 1 => &v[0].1, + AxisEntries::Sum(v) if v.len() == 1 => &v[0].1, + AxisEntries::Avg(v) if v.len() == 1 => &v[0].1, + other => { + return Err(Error::CorruptedData(format!( + "indexed-axis rank proof: expected exactly one yielded entry at the rank \ + window, got {}", + other.len() + ))); + } + }; + if yielded_key != item_key { + return Err(Error::CorruptedData(format!( + "indexed-axis rank proof: the entry at rank {} is {}, not the claimed key {}", + expected_rank, + hex::encode(yielded_key), + hex::encode(item_key) + ))); + } + Ok(result) + } + /// Verify an `IndexedAxisAggregateProof`-shaped aggregate proof. pub fn verify_indexed_axis_range_aggregate( proof_bytes: &[u8], @@ -395,10 +460,11 @@ impl GroveDb { expected_hi: i128, ) -> Result { let config = bincode::config::standard().with_limit::<{ 16 * 1024 * 1024 }>(); - let (envelope, _): (IndexedAxisAggregateProof, _) = + let (envelope, consumed): (IndexedAxisAggregateProof, _) = bincode::decode_from_slice(proof_bytes, config).map_err(|e| { Error::CorruptedData(format!("decoding indexed-axis aggregate proof: {e}")) })?; + reject_trailing_envelope_bytes(consumed, proof_bytes.len(), "aggregate")?; if envelope.axis_tag != expected_axis.tag() { return Err(Error::CorruptedData(format!( "indexed-axis aggregate proof axis mismatch: expected {:?} (tag={}), envelope \ @@ -431,11 +497,32 @@ impl GroveDb { fn decode_range_envelope(proof_bytes: &[u8]) -> Result { let config = bincode::config::standard().with_limit::<{ 16 * 1024 * 1024 }>(); - let (envelope, _): (IndexedAxisRangeProof, _) = bincode::decode_from_slice(proof_bytes, config) - .map_err(|e| Error::CorruptedData(format!("decoding indexed-axis range proof: {e}")))?; + let (envelope, consumed): (IndexedAxisRangeProof, _) = + bincode::decode_from_slice(proof_bytes, config) + .map_err(|e| Error::CorruptedData(format!("decoding indexed-axis range proof: {e}")))?; + reject_trailing_envelope_bytes(consumed, proof_bytes.len(), "range")?; Ok(envelope) } +/// Reject an envelope whose decode did not consume the whole buffer. +/// Trailing bytes never change the verified content, but tolerating +/// them makes the proof byte-malleable — two distinct byte strings +/// would verify as the same proof, which breaks any caller that +/// dedups, caches, or consensus-compares proofs by their bytes. +fn reject_trailing_envelope_bytes( + consumed: usize, + total: usize, + shape: &'static str, +) -> Result<(), Error> { + if consumed != total { + return Err(Error::CorruptedData(format!( + "indexed-axis {shape} proof has {} trailing byte(s) after the envelope", + total - consumed + ))); + } + Ok(()) +} + fn verify_indexed_axis_range_inner( envelope: IndexedAxisRangeProof, secondary_query: MerkQuery, @@ -513,84 +600,30 @@ fn verify_indexed_axis_paginated_inner( )); } - let (secondary_root_hash, entries, skipped) = match axis { - IndexAxis::Count | IndexAxis::Avg => { - let inner_range = MerkQueryItemForRange::RangeFull(std::ops::RangeFull); - let count_offset_result = verify_count_offset_on_range_proof( - &envelope.secondary_proof, - &inner_range, - envelope.requested_offset, - Some(envelope.requested_k as u64), - !envelope.descending, - ) - .unwrap() - .map_err(|e| { - Error::CorruptedData(format!( - "indexed-axis paginated proof: secondary count-offset proof failed to \ - verify: {e}" - )) - })?; - let entries = decode_axis_entries_from_count_offset_items( - axis, - &count_offset_result.returned_items, - )?; - ( - count_offset_result.root_hash, - entries, - count_offset_result.skipped, - ) - } - IndexAxis::Sum => { - // Sum-axis paginated: regular range proof with limit = offset+k. - // Verifier reconstructs same range proof, then post-skips the - // first `requested_offset` items in directional order. - let mut full_range = MerkQuery::new(); - full_range.insert_all(); - full_range.left_to_right = !envelope.descending; - // Mirror the prover: an envelope whose offset + k overflows u16 - // cannot be honestly produced, so reject rather than verify a - // silently-short page. - let combined = - (envelope.requested_offset as u128).saturating_add(envelope.requested_k as u128); - if combined > u16::MAX as u128 { - return Err(Error::CorruptedData(format!( - "indexed-axis paginated proof (sum): offset + k = {combined} exceeds the {} \ - entry limit a single page can prove", - u16::MAX - ))); - } - let combined_limit = combined as u16; - let (secondary_root_hash, sec_result) = full_range - .execute_proof( - &envelope.secondary_proof, - Some(combined_limit), - !envelope.descending, - 0, - ) - .unwrap() - .map_err(|e| { - Error::CorruptedData(format!( - "indexed-axis paginated proof: secondary regular proof (sum) failed to \ - verify: {e}" - )) - })?; - let mut all_entries = - decode_axis_entries_from_result_set(IndexAxis::Sum, &sec_result.result_set)?; - let total_returned = all_entries.len() as u64; - let skip = envelope.requested_offset.min(total_returned); - // Trim the first `skip` items off the front of the result set. - match &mut all_entries { - AxisEntries::Sum(v) => { - v.drain(0..skip as usize); - if v.len() > envelope.requested_k as usize { - v.truncate(envelope.requested_k as usize); - } - } - _ => unreachable!("axis is Sum here"), - } - (secondary_root_hash, all_entries, skip) - } - }; + // Every axis's secondary binds a count aggregate into its node + // hashes (count axis: ProvableCountTree; sum and avg axes: + // ProvableCountProvableSumTree), so all three verify through the + // count-offset primitive: the skipped prefix is independently + // re-derived from the counted subtree commitments, making `skipped` + // cryptographically attested for every axis. + let inner_range = MerkQueryItemForRange::RangeFull(std::ops::RangeFull); + let count_offset_result = verify_count_offset_on_range_proof( + &envelope.secondary_proof, + &inner_range, + envelope.requested_offset, + Some(envelope.requested_k as u64), + !envelope.descending, + ) + .unwrap() + .map_err(|e| { + Error::CorruptedData(format!( + "indexed-axis paginated proof: secondary count-offset proof failed to verify: {e}" + )) + })?; + let entries = + decode_axis_entries_from_count_offset_items(axis, &count_offset_result.returned_items)?; + let (secondary_root_hash, skipped) = + (count_offset_result.root_hash, count_offset_result.skipped); let initial_root = verify_deepest_layer( &envelope.layer_proofs, @@ -794,7 +827,19 @@ fn decode_axis_entries_from_count_offset_items( Ok(AxisEntries::Avg(entries)) } IndexAxis::Sum => { - unreachable!("count-offset proof does not apply to the sum axis") + let mut entries: Vec<(i64, Vec)> = Vec::with_capacity(items.len()); + for it in items { + if it.key.len() < 8 { + return Err(Error::CorruptedData(format!( + "indexed-axis (sum) paginated secondary key shorter than 8 bytes: {:?}", + it.key + ))); + } + let mut sum_bytes = [0u8; 8]; + sum_bytes.copy_from_slice(&it.key[..8]); + entries.push((decode_sum_sort_key(&sum_bytes), it.key[8..].to_vec())); + } + Ok(AxisEntries::Sum(entries)) } } } diff --git a/grovedb/src/tests/indexed_axis_nested_and_bounds_tests.rs b/grovedb/src/tests/indexed_axis_nested_and_bounds_tests.rs index 4ba699dc5..7d85bdaf1 100644 --- a/grovedb/src/tests/indexed_axis_nested_and_bounds_tests.rs +++ b/grovedb/src/tests/indexed_axis_nested_and_bounds_tests.rs @@ -479,13 +479,15 @@ mod tests { ); } - /// The sum axis has no count-bound offset primitive, so its paginated proof - /// is a plain range proof limited to `offset + k`. That limit is a `u16`, and - /// clamping an overflowing request would silently prove a short page while - /// the documented `skipped == expected_offset` cross-check still passed — so - /// the prover refuses instead. + /// The sum axis's secondary is a `ProvableCountProvableSumTree`, so its + /// paginated proof rides the count-bound offset primitive: the old u16 + /// `offset + k` page ceiling (an artifact of the enumeration fallback) + /// is gone, and an offset past the end of the walk is provable — the + /// verifier reports the attested `skipped` (= the total population) with + /// an empty page, which is itself a proof that the population is ≤ the + /// requested offset. #[test] - fn a_sum_axis_page_beyond_the_u16_proof_limit_is_refused_rather_than_truncated() { + fn a_sum_axis_page_beyond_the_old_u16_limit_proves_an_attested_empty_page() { let gv = GroveVersion::latest(); let db = make_test_grovedb(gv); db.insert( @@ -509,24 +511,28 @@ mod tests { .expect("entry"); let path: &[&[u8]] = &[TEST_LEAF, b"psit"]; - let err = db - .prove_indexed_sum_top_k_paginated(path, 10, u16::MAX as u64, false, None, gv) - .unwrap() - .expect_err("offset + k overflows the u16 page limit"); - match err { - Error::NotSupported(message) => assert!( - message.starts_with("indexed-axis paginated proof (sum): offset + k = 65545") - && message.contains("65535 entry limit"), - "unexpected message: {message}" - ), - other => panic!("expected NotSupported, got {other:?}"), - } - - // One less is exactly at the limit and is accepted, which is what makes - // the refusal above about the overflow rather than about large offsets. - db.prove_indexed_sum_top_k_paginated(path, 10, u16::MAX as u64 - 10, false, None, gv) + // An offset that would have overflowed the old u16 page limit now + // proves fine: the walk has 1 entry, so the count commitments attest + // exactly 1 skipped and the page is empty. + let offset = u16::MAX as u64; + let proof = db + .prove_indexed_sum_top_k_paginated(path, 10, offset, false, None, gv) .unwrap() - .expect("offset + k == u16::MAX must still be provable"); + .expect("offset far past the end must be provable via count commitments"); + let result = GroveDb::verify_indexed_sum_top_k_paginated(&proof, path, 10, offset, false) + .expect("verify offset-past-end page"); + assert_eq!( + result.skipped, 1, + "the whole 1-entry walk is attested as skipped" + ); + assert!( + result.entries.is_empty(), + "a page past the end of the walk is empty" + ); + assert!( + result.skipped < offset, + "skipped < requested offset proves the total population is exactly `skipped`" + ); } // ----------------------------------------------------------------- diff --git a/grovedb/src/tests/indexed_axis_offset_proof_tests.rs b/grovedb/src/tests/indexed_axis_offset_proof_tests.rs new file mode 100644 index 000000000..18697d442 --- /dev/null +++ b/grovedb/src/tests/indexed_axis_offset_proof_tests.rs @@ -0,0 +1,729 @@ +//! Offset-pagination proof tests for the indexed-axis proof primitive +//! (`prove_indexed_axis_top_k_paginated` / +//! `verify_indexed_axis_top_k_paginated`). +//! +//! Every indexed family's secondary carries a hash-bound count +//! aggregate (PCIT: `ProvableCountTree`; PSIT and PCPSIT sum axis: +//! `ProvableCountProvableSumTree`; PCPSIT avg axis: +//! `ProvableCountProvableSumTree`), so "skip the first M entries of the +//! walk, then yield K" is attested by counted subtree commitments +//! instead of enumeration for all of them. These tests pin: +//! +//! - offset 0 yielding the same window as plain top-k, +//! - offsets in the middle of the walk, +//! - offset + k spanning the end of the walk, +//! - offset past the end (an attested proof that the total population +//! is exactly `skipped` ≤ the requested offset), +//! - single-entry windows ("4th biggest" = offset 3, k 1), +//! - ties straddling the offset boundary (tie-break by original key in +//! walk direction, in both the skipped prefix and the yielded +//! window), +//! - both walk directions, +//! - proof-mutation rejection (bit flips either fail verification or +//! bind a different root hash). + +#[cfg(test)] +mod tests { + use grovedb_element::indexed::IndexAxis; + use grovedb_version::version::GroveVersion; + + use crate::{ + operations::proof::indexed_axis::AxisEntries, + tests::{make_test_grovedb, TEST_LEAF}, + Element, GroveDb, + }; + + // ----------------------------------------------------------------- + // Helpers (mirroring indexed_axis_proof_tests.rs builders) + // ----------------------------------------------------------------- + + fn build_psit(db: &GroveDb, grove_version: &GroveVersion, entries: &[(&[u8], i64)]) { + db.insert( + [TEST_LEAF].as_ref(), + b"psit", + Element::empty_provable_sum_indexed_tree(), + None, + None, + grove_version, + ) + .unwrap() + .expect("create PSIT"); + for (k, s) in entries { + db.insert_into_provable_sum_indexed_tree( + [TEST_LEAF, b"psit"].as_ref(), + k, + Element::new_sum_item(*s), + None, + grove_version, + ) + .unwrap() + .expect("insert PSIT entry"); + } + } + + fn build_pcpsit_sum(db: &GroveDb, grove_version: &GroveVersion, entries: &[(&[u8], i64)]) { + let axes: Vec<(u8, Option>)> = vec![(IndexAxis::Sum.tag(), None)]; + let elem = + Element::empty_provable_count_provable_sum_indexed_tree(axes).expect("axes canonical"); + db.insert( + [TEST_LEAF].as_ref(), + b"pcpsit", + elem, + None, + None, + grove_version, + ) + .unwrap() + .expect("create PCPSIT"); + for (k, sum) in entries { + db.insert_into_provable_count_provable_sum_indexed_tree( + [TEST_LEAF, b"pcpsit"].as_ref(), + k, + Element::new_item_with_sum_item(b"v".to_vec(), *sum), + None, + grove_version, + ) + .unwrap() + .expect("insert PCPSIT entry"); + } + } + + fn entries_as_sum(entries: &AxisEntries) -> &[(i64, Vec)] { + match entries { + AxisEntries::Sum(v) => v.as_slice(), + other => panic!("expected sum entries, got {:?}", other), + } + } + + fn root_hash(db: &GroveDb, grove_version: &GroveVersion) -> [u8; 32] { + db.root_hash(None, grove_version).unwrap().expect("root") + } + + /// The ten-entry fixture used by most tests: distinct sums 10..100. + /// Ascending walk: a(10) b(20) ... j(100); descending the reverse. + const TEN: &[(&[u8], i64)] = &[ + (b"a", 10), + (b"b", 20), + (b"c", 30), + (b"d", 40), + (b"e", 50), + (b"f", 60), + (b"g", 70), + (b"h", 80), + (b"i", 90), + (b"j", 100), + ]; + + // ----------------------------------------------------------------- + // offset 0 == top-k + // ----------------------------------------------------------------- + + /// A paginated proof at offset 0 must yield exactly the plain top-k + /// window (the envelopes differ — range vs paginated — so the + /// comparison is on the verified entries and root hash, not bytes). + #[test] + fn sum_axis_offset_zero_matches_top_k_both_directions() { + let gv = GroveVersion::latest(); + let db = make_test_grovedb(gv); + build_psit(&db, gv, TEN); + let path: &[&[u8]] = &[TEST_LEAF, b"psit"]; + + for descending in [false, true] { + let top_k = db + .prove_indexed_sum_top_k(path, 3, descending, None, gv) + .unwrap() + .expect("prove top-k"); + let top_k_result = GroveDb::verify_indexed_sum_top_k(&top_k, path, 3, descending) + .expect("verify top-k"); + + let paginated = db + .prove_indexed_sum_top_k_paginated(path, 3, 0, descending, None, gv) + .unwrap() + .expect("prove paginated offset 0"); + let paginated_result = + GroveDb::verify_indexed_sum_top_k_paginated(&paginated, path, 3, 0, descending) + .expect("verify paginated offset 0"); + + assert_eq!(paginated_result.skipped, 0); + assert_eq!( + entries_as_sum(&paginated_result.entries), + entries_as_sum(&top_k_result.entries), + "offset 0 (descending={descending}) must equal plain top-k" + ); + assert_eq!(paginated_result.root_hash, top_k_result.root_hash); + assert_eq!(paginated_result.root_hash, root_hash(&db, gv)); + } + } + + // ----------------------------------------------------------------- + // offset in the middle + // ----------------------------------------------------------------- + + #[test] + fn sum_axis_offset_in_the_middle_both_directions() { + let gv = GroveVersion::latest(); + let db = make_test_grovedb(gv); + build_psit(&db, gv, TEN); + let path: &[&[u8]] = &[TEST_LEAF, b"psit"]; + + // Descending walk: j i h | g f e ... — skip 3 take 3 → g f e. + let proof = db + .prove_indexed_sum_top_k_paginated(path, 3, 3, true, None, gv) + .unwrap() + .expect("prove"); + let result = + GroveDb::verify_indexed_sum_top_k_paginated(&proof, path, 3, 3, true).expect("verify"); + assert_eq!(result.skipped, 3); + assert_eq!( + entries_as_sum(&result.entries), + &[ + (70i64, b"g".to_vec()), + (60, b"f".to_vec()), + (50, b"e".to_vec()) + ] + ); + assert_eq!(result.root_hash, root_hash(&db, gv)); + + // Ascending walk: a b c d | e f ... — skip 4 take 2 → e f. + let proof = db + .prove_indexed_sum_top_k_paginated(path, 2, 4, false, None, gv) + .unwrap() + .expect("prove"); + let result = + GroveDb::verify_indexed_sum_top_k_paginated(&proof, path, 2, 4, false).expect("verify"); + assert_eq!(result.skipped, 4); + assert_eq!( + entries_as_sum(&result.entries), + &[(50i64, b"e".to_vec()), (60, b"f".to_vec())] + ); + } + + // ----------------------------------------------------------------- + // offset + k spanning the end + // ----------------------------------------------------------------- + + /// A window that starts inside the walk but extends past its end + /// yields the tail (< k entries) with the full offset attested. + #[test] + fn sum_axis_offset_plus_k_spanning_the_end_yields_short_page() { + let gv = GroveVersion::latest(); + let db = make_test_grovedb(gv); + build_psit(&db, gv, TEN); + let path: &[&[u8]] = &[TEST_LEAF, b"psit"]; + + // Descending: skip 8 (j..c), ask for 5 → only b, a remain. + let proof = db + .prove_indexed_sum_top_k_paginated(path, 5, 8, true, None, gv) + .unwrap() + .expect("prove"); + let result = + GroveDb::verify_indexed_sum_top_k_paginated(&proof, path, 5, 8, true).expect("verify"); + assert_eq!(result.skipped, 8); + assert_eq!( + entries_as_sum(&result.entries), + &[(20i64, b"b".to_vec()), (10, b"a".to_vec())], + "the page is the walk's tail, shorter than k" + ); + assert_eq!(result.root_hash, root_hash(&db, gv)); + } + + // ----------------------------------------------------------------- + // offset past the end: proof that total count <= M + // ----------------------------------------------------------------- + + /// An offset past the end is provable: the counted commitments + /// cover the entire walk, so `skipped` equals the total population + /// and the empty page is a proof that the population is ≤ the + /// requested offset. This is the "prove total count ≤ M" shape. + #[test] + fn sum_axis_offset_past_end_attests_total_population() { + let gv = GroveVersion::latest(); + let db = make_test_grovedb(gv); + build_psit(&db, gv, TEN); + let path: &[&[u8]] = &[TEST_LEAF, b"psit"]; + + for (offset, descending) in [(11u64, false), (11, true), (1_000_000, false)] { + let proof = db + .prove_indexed_sum_top_k_paginated(path, 3, offset, descending, None, gv) + .unwrap() + .expect("prove offset past end"); + let result = + GroveDb::verify_indexed_sum_top_k_paginated(&proof, path, 3, offset, descending) + .expect("verify offset past end"); + assert_eq!( + result.skipped, 10, + "the attested skipped count is the total population" + ); + assert!(result.entries.is_empty()); + assert!(result.skipped < offset); + assert_eq!(result.root_hash, root_hash(&db, gv)); + } + + // Offset exactly at the population boundary: everything is + // skipped, the page is empty, and skipped == offset — so this + // shape alone does NOT prove the population equals the offset + // (it proves ≥). skipped < offset is the strict "population == + // skipped" witness, tested above. + let proof = db + .prove_indexed_sum_top_k_paginated(path, 3, 10, false, None, gv) + .unwrap() + .expect("prove offset == population"); + let result = GroveDb::verify_indexed_sum_top_k_paginated(&proof, path, 3, 10, false) + .expect("verify offset == population"); + assert_eq!(result.skipped, 10); + assert!(result.entries.is_empty()); + } + + /// Empty tree: any offset yields skipped = 0, empty page — a proof + /// that the population is 0. + #[test] + fn sum_axis_offset_on_empty_tree_attests_zero_population() { + let gv = GroveVersion::latest(); + let db = make_test_grovedb(gv); + build_psit(&db, gv, &[]); + let path: &[&[u8]] = &[TEST_LEAF, b"psit"]; + + let proof = db + .prove_indexed_sum_top_k_paginated(path, 3, 5, false, None, gv) + .unwrap() + .expect("prove on empty"); + let result = GroveDb::verify_indexed_sum_top_k_paginated(&proof, path, 3, 5, false) + .expect("verify on empty"); + assert_eq!(result.skipped, 0); + assert!(result.entries.is_empty()); + assert_eq!(result.root_hash, root_hash(&db, gv)); + } + + // ----------------------------------------------------------------- + // single-entry windows + // ----------------------------------------------------------------- + + /// "The 4th biggest" = descending walk, offset 3, k 1. + #[test] + fn sum_axis_fourth_biggest_is_offset_three_k_one() { + let gv = GroveVersion::latest(); + let db = make_test_grovedb(gv); + build_psit(&db, gv, TEN); + let path: &[&[u8]] = &[TEST_LEAF, b"psit"]; + + let proof = db + .prove_indexed_sum_top_k_paginated(path, 1, 3, true, None, gv) + .unwrap() + .expect("prove 4th biggest"); + let result = GroveDb::verify_indexed_sum_top_k_paginated(&proof, path, 1, 3, true) + .expect("verify 4th biggest"); + assert_eq!(result.skipped, 3); + assert_eq!( + entries_as_sum(&result.entries), + &[(70i64, b"g".to_vec())], + "rank 4 descending of sums 10..100 is g(70)" + ); + + // Every rank is individually addressable: walk them all. + let expect_desc: &[(i64, &[u8])] = &[ + (100, b"j"), + (90, b"i"), + (80, b"h"), + (70, b"g"), + (60, b"f"), + (50, b"e"), + (40, b"d"), + (30, b"c"), + (20, b"b"), + (10, b"a"), + ]; + for (rank_zero_based, (sum, key)) in expect_desc.iter().enumerate() { + let proof = db + .prove_indexed_sum_top_k_paginated(path, 1, rank_zero_based as u64, true, None, gv) + .unwrap() + .expect("prove rank window"); + let result = GroveDb::verify_indexed_sum_top_k_paginated( + &proof, + path, + 1, + rank_zero_based as u64, + true, + ) + .expect("verify rank window"); + assert_eq!(result.skipped, rank_zero_based as u64); + assert_eq!(entries_as_sum(&result.entries), &[(*sum, key.to_vec())]); + } + } + + // ----------------------------------------------------------------- + // ties straddling the offset boundary + // ----------------------------------------------------------------- + + /// Equal sums tie-break by original key in walk direction — in both + /// the skipped prefix and the yielded window. The offset boundary + /// falls INSIDE the tie group, pinning that the split is + /// deterministic and attested. + #[test] + fn sum_axis_ties_straddling_the_offset_boundary() { + let gv = GroveVersion::latest(); + let db = make_test_grovedb(gv); + // Five entries share sum 50; two below, two above. + build_psit( + &db, + gv, + &[ + (b"lo1", 10), + (b"lo2", 20), + (b"t_a", 50), + (b"t_b", 50), + (b"t_c", 50), + (b"t_d", 50), + (b"t_e", 50), + (b"hi1", 90), + (b"hi2", 100), + ], + ); + let path: &[&[u8]] = &[TEST_LEAF, b"psit"]; + + // Ascending walk: lo1 lo2 t_a t_b t_c t_d t_e hi1 hi2 (ties in + // ascending lex order of key). Offset 4 lands mid-tie: skip + // lo1 lo2 t_a t_b → yield t_c t_d t_e. + let proof = db + .prove_indexed_sum_top_k_paginated(path, 3, 4, false, None, gv) + .unwrap() + .expect("prove mid-tie ascending"); + let result = GroveDb::verify_indexed_sum_top_k_paginated(&proof, path, 3, 4, false) + .expect("verify mid-tie ascending"); + assert_eq!(result.skipped, 4); + assert_eq!( + entries_as_sum(&result.entries), + &[ + (50i64, b"t_c".to_vec()), + (50, b"t_d".to_vec()), + (50, b"t_e".to_vec()) + ] + ); + + // Descending walk: hi2 hi1 t_e t_d t_c t_b t_a lo2 lo1 (ties in + // DESCENDING lex order of key). Offset 3 lands mid-tie: skip + // hi2 hi1 t_e → yield t_d t_c t_b. + let proof = db + .prove_indexed_sum_top_k_paginated(path, 3, 3, true, None, gv) + .unwrap() + .expect("prove mid-tie descending"); + let result = GroveDb::verify_indexed_sum_top_k_paginated(&proof, path, 3, 3, true) + .expect("verify mid-tie descending"); + assert_eq!(result.skipped, 3); + assert_eq!( + entries_as_sum(&result.entries), + &[ + (50i64, b"t_d".to_vec()), + (50, b"t_c".to_vec()), + (50, b"t_b".to_vec()) + ] + ); + + // The two directions' windows at the same offset partition the + // tie group consistently: ascending offset 4 k 3 and descending + // offset 3 k 3 both contain t_c and t_d — the walk order is a + // total order, not a per-proof choice. + } + + // ----------------------------------------------------------------- + // PCPSIT sum axis (multi-axis family, same primitive) + // ----------------------------------------------------------------- + + /// The PCPSIT sum axis pages through the same count-offset + /// primitive, including mid-tie offsets and offset-past-end. + #[test] + fn pcpsit_sum_axis_offset_pagination_round_trips() { + let gv = GroveVersion::latest(); + let db = make_test_grovedb(gv); + build_pcpsit_sum( + &db, + gv, + &[ + (b"a", 5), + (b"b", 5), + (b"c", 5), + (b"d", 40), + (b"e", 50), + (b"f", 60), + ], + ); + let path: &[&[u8]] = &[TEST_LEAF, b"pcpsit"]; + + // Ascending, offset 1 lands mid-tie (skip a → yield b c d). + let proof = db + .prove_indexed_sum_top_k_paginated(path, 3, 1, false, None, gv) + .unwrap() + .expect("prove"); + let result = + GroveDb::verify_indexed_sum_top_k_paginated(&proof, path, 3, 1, false).expect("verify"); + assert_eq!(result.skipped, 1); + assert_eq!( + entries_as_sum(&result.entries), + &[ + (5i64, b"b".to_vec()), + (5, b"c".to_vec()), + (40, b"d".to_vec()) + ] + ); + assert_eq!(result.root_hash, root_hash(&db, gv)); + + // Offset past end. + let proof = db + .prove_indexed_sum_top_k_paginated(path, 3, 99, true, None, gv) + .unwrap() + .expect("prove past end"); + let result = GroveDb::verify_indexed_sum_top_k_paginated(&proof, path, 3, 99, true) + .expect("verify past end"); + assert_eq!(result.skipped, 6, "total population attested"); + assert!(result.entries.is_empty()); + } + + // ----------------------------------------------------------------- + // proof mutation rejection + // ----------------------------------------------------------------- + + /// Flipping any single bit of a paginated proof must either fail + /// verification outright or reconstruct a root hash that no longer + /// matches the database's — a mutated proof can never verify + /// against the authentic root with different contents. + #[test] + fn sum_axis_paginated_proof_bit_flips_are_rejected_or_rebound() { + let gv = GroveVersion::latest(); + let db = make_test_grovedb(gv); + build_psit(&db, gv, TEN); + let path: &[&[u8]] = &[TEST_LEAF, b"psit"]; + let expected_root = root_hash(&db, gv); + + let proof = db + .prove_indexed_sum_top_k_paginated(path, 3, 3, true, None, gv) + .unwrap() + .expect("prove"); + let baseline = GroveDb::verify_indexed_sum_top_k_paginated(&proof, path, 3, 3, true) + .expect("baseline verifies"); + assert_eq!(baseline.root_hash, expected_root); + let baseline_entries = entries_as_sum(&baseline.entries).to_vec(); + + let mut accepted_with_same_meaning = 0usize; + for byte_idx in 0..proof.len() { + for bit in 0..8u8 { + let mut mutated = proof.clone(); + mutated[byte_idx] ^= 1 << bit; + match GroveDb::verify_indexed_sum_top_k_paginated(&mutated, path, 3, 3, true) { + Err(_) => {} + Ok(result) => { + if result.root_hash == expected_root + && result.skipped == baseline.skipped + && entries_as_sum(&result.entries) == baseline_entries.as_slice() + { + // A flip that decodes to the identical verified + // meaning (e.g. inside bincode slack) is not a + // forgery. Anything else under the authentic + // root would be. + accepted_with_same_meaning += 1; + } else { + assert_ne!( + result.root_hash, expected_root, + "bit flip at byte {byte_idx} bit {bit} verified DIFFERENT \ + content under the authentic root hash" + ); + } + } + } + } + } + // Sanity: the loop exercised real mutations (the proof is not + // somehow all slack bytes). + assert!( + accepted_with_same_meaning < proof.len() * 8, + "every mutation decoded identically — mutation harness is broken" + ); + } + + /// Truncated and garbage-extended proofs are rejected. + #[test] + fn sum_axis_paginated_proof_truncation_and_garbage_are_rejected() { + let gv = GroveVersion::latest(); + let db = make_test_grovedb(gv); + build_psit(&db, gv, TEN); + let path: &[&[u8]] = &[TEST_LEAF, b"psit"]; + + let proof = db + .prove_indexed_sum_top_k_paginated(path, 2, 2, false, None, gv) + .unwrap() + .expect("prove"); + + let truncated = &proof[..proof.len() - 1]; + assert!( + GroveDb::verify_indexed_sum_top_k_paginated(truncated, path, 2, 2, false).is_err(), + "truncated proof must not verify" + ); + + let mut extended = proof.clone(); + extended.extend_from_slice(b"garbage"); + assert!( + GroveDb::verify_indexed_sum_top_k_paginated(&extended, path, 2, 2, false).is_err(), + "garbage-extended proof must not verify" + ); + } + + /// Parameter mismatches (k / offset / direction) are rejected even + /// against an honest proof. + #[test] + fn sum_axis_paginated_parameter_mismatches_are_rejected() { + let gv = GroveVersion::latest(); + let db = make_test_grovedb(gv); + build_psit(&db, gv, TEN); + let path: &[&[u8]] = &[TEST_LEAF, b"psit"]; + + let proof = db + .prove_indexed_sum_top_k_paginated(path, 3, 2, true, None, gv) + .unwrap() + .expect("prove"); + + assert!( + GroveDb::verify_indexed_sum_top_k_paginated(&proof, path, 4, 2, true).is_err(), + "wrong k must be rejected" + ); + assert!( + GroveDb::verify_indexed_sum_top_k_paginated(&proof, path, 3, 3, true).is_err(), + "wrong offset must be rejected" + ); + assert!( + GroveDb::verify_indexed_sum_top_k_paginated(&proof, path, 3, 2, false).is_err(), + "wrong direction must be rejected" + ); + } + + // ----------------------------------------------------------------- + // rank-of-key + // ----------------------------------------------------------------- + + /// `prove_indexed_axis_rank_of_key` proves "exactly R entries come + /// strictly before X in the walk" and binds it to X: the proof is a + /// paginated window (offset = R, k = 1) whose yielded entry is X. + #[test] + fn sum_axis_rank_of_key_round_trips_for_every_key_both_directions() { + let gv = GroveVersion::latest(); + let db = make_test_grovedb(gv); + build_psit(&db, gv, TEN); + let path: &[&[u8]] = &[TEST_LEAF, b"psit"]; + let expected_root = root_hash(&db, gv); + + // Descending: j has rank 0, i rank 1, ... a rank 9. Ascending + // is the reverse. + let desc_order: &[&[u8]] = &[b"j", b"i", b"h", b"g", b"f", b"e", b"d", b"c", b"b", b"a"]; + for descending in [true, false] { + for (expected_rank, key) in desc_order.iter().enumerate() { + let expected_rank = if descending { + expected_rank as u64 + } else { + (desc_order.len() - 1 - expected_rank) as u64 + }; + let (proof, rank) = db + .prove_indexed_axis_rank_of_key(path, IndexAxis::Sum, key, descending, None, gv) + .unwrap() + .expect("prove rank of key"); + assert_eq!(rank, expected_rank, "prover-reported rank"); + let result = GroveDb::verify_indexed_axis_rank_of_key( + &proof, + path, + IndexAxis::Sum, + key, + expected_rank, + descending, + ) + .expect("verify rank of key"); + assert_eq!(result.root_hash, expected_root); + assert_eq!(result.skipped, expected_rank); + } + } + } + + /// Rank inside a tie group follows the walk's total order (tie-break + /// by original key in walk direction). + #[test] + fn sum_axis_rank_of_key_inside_a_tie_group() { + let gv = GroveVersion::latest(); + let db = make_test_grovedb(gv); + build_psit( + &db, + gv, + &[ + (b"lo", 10), + (b"t_a", 50), + (b"t_b", 50), + (b"t_c", 50), + (b"hi", 90), + ], + ); + let path: &[&[u8]] = &[TEST_LEAF, b"psit"]; + + // Ascending walk: lo t_a t_b t_c hi → t_b has rank 2. + let (proof, rank) = db + .prove_indexed_axis_rank_of_key(path, IndexAxis::Sum, b"t_b", false, None, gv) + .unwrap() + .expect("prove"); + assert_eq!(rank, 2); + GroveDb::verify_indexed_axis_rank_of_key(&proof, path, IndexAxis::Sum, b"t_b", 2, false) + .expect("verify ascending mid-tie rank"); + + // Descending walk: hi t_c t_b t_a lo → t_b has rank 2 there too + // (symmetric fixture), t_c has rank 1. + let (proof, rank) = db + .prove_indexed_axis_rank_of_key(path, IndexAxis::Sum, b"t_c", true, None, gv) + .unwrap() + .expect("prove"); + assert_eq!(rank, 1); + GroveDb::verify_indexed_axis_rank_of_key(&proof, path, IndexAxis::Sum, b"t_c", 1, true) + .expect("verify descending mid-tie rank"); + } + + /// A rank proof only verifies for the exact (key, rank) pair it was + /// generated for; a wrong rank or a different key is rejected. + #[test] + fn sum_axis_rank_of_key_wrong_claims_are_rejected() { + let gv = GroveVersion::latest(); + let db = make_test_grovedb(gv); + build_psit(&db, gv, TEN); + let path: &[&[u8]] = &[TEST_LEAF, b"psit"]; + + // g(70) has descending rank 3. + let (proof, rank) = db + .prove_indexed_axis_rank_of_key(path, IndexAxis::Sum, b"g", true, None, gv) + .unwrap() + .expect("prove"); + assert_eq!(rank, 3); + + assert!( + GroveDb::verify_indexed_axis_rank_of_key(&proof, path, IndexAxis::Sum, b"g", 4, true) + .is_err(), + "a different rank claim must be rejected (offset echo mismatch)" + ); + assert!( + GroveDb::verify_indexed_axis_rank_of_key(&proof, path, IndexAxis::Sum, b"h", 3, true) + .is_err(), + "a different key claim must be rejected (the entry at rank 3 is g)" + ); + assert!( + GroveDb::verify_indexed_axis_rank_of_key(&proof, path, IndexAxis::Sum, b"g", 3, false) + .is_err(), + "a different direction must be rejected" + ); + } + + /// Proving the rank of a key that is not in the primary fails at + /// prove time. + #[test] + fn sum_axis_rank_of_absent_key_fails_to_prove() { + let gv = GroveVersion::latest(); + let db = make_test_grovedb(gv); + build_psit(&db, gv, TEN); + let path: &[&[u8]] = &[TEST_LEAF, b"psit"]; + + assert!( + db.prove_indexed_axis_rank_of_key(path, IndexAxis::Sum, b"nope", true, None, gv) + .unwrap() + .is_err(), + "rank of an absent key is unprovable" + ); + } +} diff --git a/grovedb/src/tests/indexed_axis_proof_tests.rs b/grovedb/src/tests/indexed_axis_proof_tests.rs index b7d3b0186..ee7a103a6 100644 --- a/grovedb/src/tests/indexed_axis_proof_tests.rs +++ b/grovedb/src/tests/indexed_axis_proof_tests.rs @@ -327,9 +327,10 @@ mod tests { } #[test] - fn psit_indexed_axis_paginated_round_trip_uses_fallback() { - // Sum axis has no count-offset primitive — verify the fallback - // (regular range proof + post-skip) round-trips correctly. + fn psit_indexed_axis_paginated_round_trip_uses_count_offset() { + // The sum-axis secondary is a ProvableCountProvableSumTree, so + // pagination rides the count-offset primitive — the skipped + // prefix is attested by counted subtree commitments. let grove_version = GroveVersion::latest(); let db = make_test_grovedb(grove_version); build_psit( diff --git a/grovedb/src/tests/mod.rs b/grovedb/src/tests/mod.rs index b835bedc6..7f130cb0a 100644 --- a/grovedb/src/tests/mod.rs +++ b/grovedb/src/tests/mod.rs @@ -55,6 +55,7 @@ mod estimated_costs_worst_case_tests; mod get_cost_estimator_tests; mod grove_query_result_tests; mod indexed_axis_nested_and_bounds_tests; +mod indexed_axis_offset_proof_tests; mod indexed_axis_proof_tests; mod indexed_tree_secondary_drift_tests; mod indexed_tree_security_regression_tests; diff --git a/grovedb/src/tests/verify_grovedb_indexed_tests.rs b/grovedb/src/tests/verify_grovedb_indexed_tests.rs index 97968d15c..6d36324ac 100644 --- a/grovedb/src/tests/verify_grovedb_indexed_tests.rs +++ b/grovedb/src/tests/verify_grovedb_indexed_tests.rs @@ -1287,7 +1287,7 @@ mod tests { secondary_key, None, false, - TreeType::ProvableSumTree, + TreeType::ProvableCountProvableSumTree, grove_version, ) .unwrap() diff --git a/merk/src/tree_type/mod.rs b/merk/src/tree_type/mod.rs index db60b7d94..32aea1cb5 100644 --- a/merk/src/tree_type/mod.rs +++ b/merk/src/tree_type/mod.rs @@ -64,23 +64,32 @@ pub enum TreeType { ProvableCountProvableSumTree, /// A provable sum-indexed tree's primary Merk: same node shape as /// `ProvableSumTree` (uses `ProvableSummedMerkNode` aggregation). The - /// secondary Merk pointed to by the parent element is a regular - /// `ProvableSumTree` opened at a derived storage prefix, keyed by - /// `(sum_sortable_be ‖ original_key)`. + /// secondary Merk pointed to by the parent element is a + /// `ProvableCountProvableSumTree` opened at a derived storage prefix, + /// keyed by `(sum_sortable_be ‖ original_key)`. The primary stays + /// lean (sum-only nodes); the secondary carries BOTH aggregates so + /// positional queries against the sum ranking (offset pagination, + /// rank-of-key) are provable in O(log n) via count-bound + /// subtree-skip commitments. ProvableSumIndexedTree, /// A provable count-indexed tree's primary Merk: same node shape as /// `ProvableCountTree` (uses `ProvableCountedMerkNode` aggregation). /// The secondary Merk pointed to by the parent element is a regular /// `ProvableCountTree` opened at a derived storage prefix, keyed by - /// `(count_be ‖ original_key)`. + /// `(count_be ‖ original_key)`. Count-provable already, so offset + /// pagination over the count ranking is O(log n) out of the box. ProvableCountIndexedTree, /// A provable count + provable sum indexed tree's primary Merk: same /// node shape as `ProvableCountProvableSumTree` (uses /// `ProvableCountedAndProvableSummedMerkNode` aggregation). The parent /// element carries a TLV list of 1..=3 secondary Merks, one per /// selected axis (count, sum, avg). Each secondary lives at its own - /// derived storage prefix and is itself a `ProvableCountProvableSumTree` - /// so any axis can produce both count-on-range and sum-on-range proofs. + /// derived storage prefix; the count axis opens as a + /// `ProvableCountTree` while the sum and avg axes open as + /// `ProvableCountProvableSumTree`s — every axis therefore carries a + /// hash-bound count aggregate, so all of them support count-bound + /// offset pagination, and the sum/avg axes additionally support + /// sum-on-range proofs. ProvableCountProvableSumIndexedTree, } From 13218d917bbc58c784001f992f417875f4dd3ba1 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Tue, 4 Aug 2026 20:42:32 +0700 Subject: [PATCH 2/5] =?UTF-8?q?chore:=20address=20CodeRabbit=20review=20?= =?UTF-8?q?=E2=80=94=20canonical=20secondary=20tree=20types=20in=20test=20?= =?UTF-8?q?helpers,=20full=20O(log=20n=20+=20k)=20pagination=20bound=20in?= =?UTF-8?q?=20docs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The verify_grovedb corruption helpers now derive the secondary's TreeType from axis_secondary_tree_type instead of hardcoding it (two sites still carried the pre-change ProvableSumTree, and the avg arm had always carried the wrong ProvableCountSumTree), and the pagination proof-size claims in the TreeType / Element docs state the full O(log n + k) bound instead of O(log n). Co-Authored-By: Claude Fable 5 --- grovedb-element/src/element/mod.rs | 5 +++-- grovedb/src/tests/verify_grovedb_indexed_tests.rs | 10 +++------- merk/src/tree_type/mod.rs | 10 ++++++---- 3 files changed, 12 insertions(+), 13 deletions(-) diff --git a/grovedb-element/src/element/mod.rs b/grovedb-element/src/element/mod.rs index 6cf2cde95..3fc5e9022 100644 --- a/grovedb-element/src/element/mod.rs +++ b/grovedb-element/src/element/mod.rs @@ -252,8 +252,9 @@ pub enum Element { /// `(sum_sortable_be ‖ original_key)`. The secondary is a /// `ProvableCountProvableSumTree` — each row is a `SumItem` /// contributing `(count = 1, sum)` — so positional queries against - /// the sum ranking (offset pagination) are provable in O(log n) via - /// counted subtree commitments. Both Merks contribute to the + /// the sum ranking are provable via counted subtree commitments: + /// offset pagination in O(log n + k) proof size (k = page size), + /// rank-of-key in O(log n). Both Merks contribute to the /// element's `combined_value_hash` via the three-input hash composition /// `combine_hash_three(value_hash, primary_root_hash, /// secondary_root_hash)`. diff --git a/grovedb/src/tests/verify_grovedb_indexed_tests.rs b/grovedb/src/tests/verify_grovedb_indexed_tests.rs index 6d36324ac..4bce7d0f5 100644 --- a/grovedb/src/tests/verify_grovedb_indexed_tests.rs +++ b/grovedb/src/tests/verify_grovedb_indexed_tests.rs @@ -1236,7 +1236,7 @@ mod tests { secondary_key, None, false, - TreeType::ProvableSumTree, + crate::operations::indexed_tree::axis_secondary_tree_type(IndexAxis::Sum), grove_version, ) .unwrap() @@ -1287,7 +1287,7 @@ mod tests { secondary_key, None, false, - TreeType::ProvableCountProvableSumTree, + crate::operations::indexed_tree::axis_secondary_tree_type(IndexAxis::Sum), grove_version, ) .unwrap() @@ -1473,11 +1473,7 @@ mod tests { _ => panic!("not PCPSIT"), } }; - let tree_type = match axis { - IndexAxis::Count => TreeType::ProvableCountTree, - IndexAxis::Sum => TreeType::ProvableSumTree, - IndexAxis::Avg => TreeType::ProvableCountSumTree, - }; + let tree_type = crate::operations::indexed_tree::axis_secondary_tree_type(axis); { let mut secondary_merk = db .open_indexed_secondary_at_path( diff --git a/merk/src/tree_type/mod.rs b/merk/src/tree_type/mod.rs index 32aea1cb5..f069f7a5a 100644 --- a/merk/src/tree_type/mod.rs +++ b/merk/src/tree_type/mod.rs @@ -68,16 +68,18 @@ pub enum TreeType { /// `ProvableCountProvableSumTree` opened at a derived storage prefix, /// keyed by `(sum_sortable_be ‖ original_key)`. The primary stays /// lean (sum-only nodes); the secondary carries BOTH aggregates so - /// positional queries against the sum ranking (offset pagination, - /// rank-of-key) are provable in O(log n) via count-bound - /// subtree-skip commitments. + /// positional queries against the sum ranking are provable via + /// count-bound subtree-skip commitments — offset pagination in + /// O(log n + k) proof size (k = page size), rank-of-key in + /// O(log n). ProvableSumIndexedTree, /// A provable count-indexed tree's primary Merk: same node shape as /// `ProvableCountTree` (uses `ProvableCountedMerkNode` aggregation). /// The secondary Merk pointed to by the parent element is a regular /// `ProvableCountTree` opened at a derived storage prefix, keyed by /// `(count_be ‖ original_key)`. Count-provable already, so offset - /// pagination over the count ranking is O(log n) out of the box. + /// pagination over the count ranking is O(log n + k) proof size + /// (k = page size) out of the box. ProvableCountIndexedTree, /// A provable count + provable sum indexed tree's primary Merk: same /// node shape as `ProvableCountProvableSumTree` (uses From 7c1e5e18060417d7860ff7a5876165f2f6eee529 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Tue, 4 Aug 2026 20:57:31 +0700 Subject: [PATCH 3/5] test: cover the rank-of-key and trailing-byte error branches Codecov flagged the new error paths: rank proving at the root path or against a non-indexed target, the rank verifier's skipped-vs-rank and empty-window rejections (rank past / at the population), and the trailing-byte rejection on the range and aggregate envelope decoders. Co-Authored-By: Claude Fable 5 --- .../tests/indexed_axis_offset_proof_tests.rs | 103 ++++++++++++++++++ 1 file changed, 103 insertions(+) diff --git a/grovedb/src/tests/indexed_axis_offset_proof_tests.rs b/grovedb/src/tests/indexed_axis_offset_proof_tests.rs index 18697d442..1efd9a473 100644 --- a/grovedb/src/tests/indexed_axis_offset_proof_tests.rs +++ b/grovedb/src/tests/indexed_axis_offset_proof_tests.rs @@ -726,4 +726,107 @@ mod tests { "rank of an absent key is unprovable" ); } + + /// Rank proving rejects invalid targets before touching the + /// secondary: the root path has no indexed element, and a + /// non-indexed tree is not an indexed primary. + #[test] + fn rank_of_key_rejects_root_path_and_non_indexed_targets() { + let gv = GroveVersion::latest(); + let db = make_test_grovedb(gv); + build_psit(&db, gv, TEN); + + let root: &[&[u8]] = &[]; + assert!( + db.prove_indexed_axis_rank_of_key(root, IndexAxis::Sum, b"a", false, None, gv) + .unwrap() + .is_err(), + "rank at the root path is rejected" + ); + + // TEST_LEAF itself is a plain tree, not an indexed primary. + let plain: &[&[u8]] = &[TEST_LEAF]; + assert!( + db.prove_indexed_axis_rank_of_key(plain, IndexAxis::Sum, b"psit", false, None, gv) + .unwrap() + .is_err(), + "rank against a non-indexed tree is rejected" + ); + } + + /// The rank verifier's own checks, beyond the paginated echoes: + /// a claimed rank past the walk's end fails the + /// `skipped == expected_rank` requirement, and a rank exactly at + /// the population boundary (empty window, fully satisfied skip) + /// fails the single-yielded-entry requirement — no key sits at a + /// rank equal to the population. + #[test] + fn rank_verifier_rejects_ranks_at_or_past_the_population() { + let gv = GroveVersion::latest(); + let db = make_test_grovedb(gv); + build_psit(&db, gv, TEN); + let path: &[&[u8]] = &[TEST_LEAF, b"psit"]; + + // Rank 99 on a 10-entry walk: the paginated proof itself is + // honest (skipped = 10 < 99, empty page), so the paginated + // verifier accepts it — the RANK verifier must reject it. + let proof = db + .prove_indexed_sum_top_k_paginated(path, 1, 99, false, None, gv) + .unwrap() + .expect("prove offset past end"); + GroveDb::verify_indexed_sum_top_k_paginated(&proof, path, 1, 99, false) + .expect("paginated shape verifies"); + assert!( + GroveDb::verify_indexed_axis_rank_of_key(&proof, path, IndexAxis::Sum, b"a", 99, false) + .is_err(), + "a rank claim past the population must be rejected (skipped < rank)" + ); + + // Rank 10 == population: skip fully satisfied but the window is + // empty, so there is no entry to bind the key to. + let proof = db + .prove_indexed_sum_top_k_paginated(path, 1, 10, false, None, gv) + .unwrap() + .expect("prove offset == population"); + assert!( + GroveDb::verify_indexed_axis_rank_of_key(&proof, path, IndexAxis::Sum, b"a", 10, false) + .is_err(), + "a rank claim equal to the population must be rejected (no yielded entry)" + ); + } + + /// Trailing bytes after the envelope are rejected on the range + /// (top-k) and aggregate envelope decoders too, mirroring the + /// paginated case tested above — the three shapes share the + /// anti-malleability rule. + #[test] + fn range_and_aggregate_envelopes_reject_trailing_bytes() { + let gv = GroveVersion::latest(); + let db = make_test_grovedb(gv); + build_psit(&db, gv, TEN); + let path: &[&[u8]] = &[TEST_LEAF, b"psit"]; + + let mut top_k = db + .prove_indexed_sum_top_k(path, 3, true, None, gv) + .unwrap() + .expect("prove top-k"); + GroveDb::verify_indexed_sum_top_k(&top_k, path, 3, true).expect("clean top-k verifies"); + top_k.push(0); + assert!( + GroveDb::verify_indexed_sum_top_k(&top_k, path, 3, true).is_err(), + "trailing byte after the range envelope must be rejected" + ); + + let mut aggregate = db + .prove_indexed_sum_range_aggregate(path, 0, 100, None, gv) + .unwrap() + .expect("prove aggregate"); + GroveDb::verify_indexed_sum_range_aggregate(&aggregate, path, 0, 100) + .expect("clean aggregate verifies"); + aggregate.push(0); + assert!( + GroveDb::verify_indexed_sum_range_aggregate(&aggregate, path, 0, 100).is_err(), + "trailing byte after the aggregate envelope must be rejected" + ); + } } From 8752090fb2a7eb91c2510c733d2dc44319dd7e23 Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Tue, 4 Aug 2026 22:24:00 +0700 Subject: [PATCH 4/5] fix: PathKeyNotFound for a missing rank-of-key item, per CodeRabbit An absent item_key is a caller input error, not data corruption; prove_indexed_axis_rank_of_key now reports Error::PathKeyNotFound with the hex key, and the absent-key test pins the error kind. Co-Authored-By: Claude Fable 5 --- grovedb/src/operations/proof/indexed_axis/generate.rs | 5 +++-- grovedb/src/tests/indexed_axis_offset_proof_tests.rs | 10 ++++++---- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/grovedb/src/operations/proof/indexed_axis/generate.rs b/grovedb/src/operations/proof/indexed_axis/generate.rs index 96a541df7..98b07ff91 100644 --- a/grovedb/src/operations/proof/indexed_axis/generate.rs +++ b/grovedb/src/operations/proof/indexed_axis/generate.rs @@ -521,8 +521,9 @@ impl GroveDb { let item_element = cost_return_on_error!( &mut cost, Element::get(&primary_merk, item_key, true, grove_version).map_err(|e| { - Error::CorruptedData(format!( - "indexed-axis rank proof: item key not found in primary: {e}" + Error::PathKeyNotFound(format!( + "indexed-axis rank proof: item key {} not found in the indexed primary: {e}", + hex::encode(item_key) )) }) ); diff --git a/grovedb/src/tests/indexed_axis_offset_proof_tests.rs b/grovedb/src/tests/indexed_axis_offset_proof_tests.rs index 1efd9a473..933fc1df7 100644 --- a/grovedb/src/tests/indexed_axis_offset_proof_tests.rs +++ b/grovedb/src/tests/indexed_axis_offset_proof_tests.rs @@ -719,11 +719,13 @@ mod tests { build_psit(&db, gv, TEN); let path: &[&[u8]] = &[TEST_LEAF, b"psit"]; + let err = db + .prove_indexed_axis_rank_of_key(path, IndexAxis::Sum, b"nope", true, None, gv) + .unwrap() + .expect_err("rank of an absent key is unprovable"); assert!( - db.prove_indexed_axis_rank_of_key(path, IndexAxis::Sum, b"nope", true, None, gv) - .unwrap() - .is_err(), - "rank of an absent key is unprovable" + matches!(err, crate::Error::PathKeyNotFound(_)), + "an absent key is a not-found error, not corruption: {err:?}" ); } From f05dbc19085be82866f4dccc40572471c2ce703e Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Tue, 4 Aug 2026 22:31:18 +0700 Subject: [PATCH 5/5] test: direct Avg-axis PCPSIT drift coverage, per CodeRabbit Mirrors the count/sum axis-drift tests: delete one avg-secondary row (key derived via the canonical make_axis_secondary_key builder) and assert verify_grovedb reports it under the __pcpsit_avg_*__ sentinel. Co-Authored-By: Claude Fable 5 --- .../src/tests/verify_grovedb_indexed_tests.rs | 65 +++++++++++++++++++ 1 file changed, 65 insertions(+) diff --git a/grovedb/src/tests/verify_grovedb_indexed_tests.rs b/grovedb/src/tests/verify_grovedb_indexed_tests.rs index 4bce7d0f5..192f6c698 100644 --- a/grovedb/src/tests/verify_grovedb_indexed_tests.rs +++ b/grovedb/src/tests/verify_grovedb_indexed_tests.rs @@ -1604,6 +1604,71 @@ mod tests { assert!(!issues.is_empty(), "expected PCPSIT sum-axis drift"); } + #[test] + fn verify_grovedb_pcpsit_detects_avg_axis_drift() { + // Same shape as the count/sum drift tests but for the Avg + // axis, whose secondary key carries a 16-byte fixed-point + // sort prefix. Deleting one avg-secondary row must surface + // through the per-axis content walk with the avg-axis + // sentinel prefix. + let grove_version = GroveVersion::latest(); + let db = make_test_grovedb(grove_version); + let axes = vec![(IndexAxis::Avg.tag(), None)]; + db.insert( + [TEST_LEAF].as_ref(), + b"pcpsit", + Element::empty_provable_count_provable_sum_indexed_tree(axes).unwrap(), + None, + None, + grove_version, + ) + .unwrap() + .expect("create"); + for (k, v) in [(b"a".as_ref(), 7i64), (b"b", 12)] { + db.insert_into_provable_count_provable_sum_indexed_tree( + [TEST_LEAF, b"pcpsit"].as_ref(), + k, + Element::new_item_with_sum_item(k.to_vec(), v), + None, + grove_version, + ) + .unwrap() + .expect("insert"); + } + assert_verify_passes(&db, grove_version); + + // Each entry contributes (count = 1, sum = v) to the avg axis; + // derive the row key with the canonical builder so the test + // cannot drift from the mirror's encoding. + let sec_key = + crate::operations::indexed_tree::make_axis_secondary_key(IndexAxis::Avg, 1, 7, b"a"); + corrupt_pcpsit_axis_secondary_delete( + &db, + &[TEST_LEAF, b"pcpsit"], + IndexAxis::Avg, + &sec_key, + grove_version, + ); + + let issues = db + .verify_grovedb(None, true, true, grove_version) + .expect("verify"); + assert!(!issues.is_empty(), "expected PCPSIT avg-axis drift"); + // The content walk labels avg-axis issues with the + // `__pcpsit_avg___` sentinel; the deleted row surfaces + // its primary entry as an avg-axis orphan. + let has_avg_sentinel = issues.keys().any(|p| { + p.iter().any(|seg| { + seg.windows(b"__pcpsit_avg_".len()) + .any(|w| w == b"__pcpsit_avg_") + }) + }); + assert!( + has_avg_sentinel, + "expected an __pcpsit_avg_*__ sentinel among issues: {issues:?}" + ); + } + #[test] fn verify_grovedb_pcit_empty_secondary_detects_orphan_insert() { // Insert a bogus orphan into the PCIT secondary at a key that