diff --git a/grovedb-version/src/version/grovedb_versions.rs b/grovedb-version/src/version/grovedb_versions.rs index 70b884f93..b63af3694 100644 --- a/grovedb-version/src/version/grovedb_versions.rs +++ b/grovedb-version/src/version/grovedb_versions.rs @@ -139,6 +139,33 @@ pub struct GroveDBOperationsProofVersions { pub verify_subset_query_with_absence_proof: FeatureVersion, pub verify_query_with_chained_path_queries: FeatureVersion, pub verify_query_get_parent_tree_info_with_options: FeatureVersion, + /// Whether a V1 proof binds the element bytes of a **terminally-reported + /// non-Merk tree** — `CommitmentTree`, `MmrTree`, `BulkAppendTree`, + /// `DenseAppendOnlyFixedSizeTree` — to the `value_hash` its parent Merk + /// commits to. "Terminal" means the query targets the tree element itself + /// and the prover emits no lower layer. + /// + /// - `0` (V1..V3): the prover emits a bare `KVValueHash` node and the + /// verifier does not require a child hash. That node hashes only + /// `(key, value_hash)`, so the serialized element bytes are unbound: a + /// prover can serve a forged entry count (an inflated or deflated + /// `CommitmentTree` `total_count`, a different MMR size) alongside the + /// genuine `value_hash` and still reconstruct the correct root hash. + /// - `1` (V4+): the prover emits + /// `KVValueHashFeatureTypeWithChildHash` carrying the tree's own state + /// root, and the verifier requires it, so the merk-level + /// `combine_hash(H(value), child_hash) == value_hash` check closes the + /// loop. This is exactly the composition the parent commits, since these + /// types are written through `insert_subtree`. + /// + /// Gated rather than applied unconditionally on two counts. It flips an + /// accepted/rejected outcome — an upgraded verifier rejects proofs a + /// released one accepts — and computing the state root costs the prover + /// extra storage reads and hash calls on a released path. The + /// non-Merk tree types this covers are the only elements affected; + /// non-empty **Merk** trees have required the child hash since V3 and + /// stay bound at every version. + pub terminal_non_merk_tree_child_hash: FeatureVersion, } #[derive(Clone, Debug, Default)] diff --git a/grovedb-version/src/version/v1.rs b/grovedb-version/src/version/v1.rs index 8d44fd30a..be2b1d472 100644 --- a/grovedb-version/src/version/v1.rs +++ b/grovedb-version/src/version/v1.rs @@ -166,6 +166,7 @@ pub const GROVE_V1: GroveVersion = GroveVersion { verify_subset_query_with_absence_proof: 0, verify_query_with_chained_path_queries: 0, verify_query_get_parent_tree_info_with_options: 0, + terminal_non_merk_tree_child_hash: 0, }, average_case: GroveDBOperationsAverageCaseVersions { add_average_case_get_merk_at_path: 0, diff --git a/grovedb-version/src/version/v2.rs b/grovedb-version/src/version/v2.rs index d7f15c9b3..14f8d0936 100644 --- a/grovedb-version/src/version/v2.rs +++ b/grovedb-version/src/version/v2.rs @@ -166,6 +166,7 @@ pub const GROVE_V2: GroveVersion = GroveVersion { verify_subset_query_with_absence_proof: 0, verify_query_with_chained_path_queries: 0, verify_query_get_parent_tree_info_with_options: 0, + terminal_non_merk_tree_child_hash: 0, }, average_case: GroveDBOperationsAverageCaseVersions { add_average_case_get_merk_at_path: 0, diff --git a/grovedb-version/src/version/v3.rs b/grovedb-version/src/version/v3.rs index 7f42fb351..c65b8518f 100644 --- a/grovedb-version/src/version/v3.rs +++ b/grovedb-version/src/version/v3.rs @@ -170,6 +170,7 @@ pub const GROVE_V3: GroveVersion = GroveVersion { verify_subset_query_with_absence_proof: 0, verify_query_with_chained_path_queries: 0, verify_query_get_parent_tree_info_with_options: 0, + terminal_non_merk_tree_child_hash: 0, }, average_case: GroveDBOperationsAverageCaseVersions { add_average_case_get_merk_at_path: 0, diff --git a/grovedb-version/src/version/v4.rs b/grovedb-version/src/version/v4.rs index d701a17c2..7b53a2033 100644 --- a/grovedb-version/src/version/v4.rs +++ b/grovedb-version/src/version/v4.rs @@ -17,6 +17,17 @@ //! case. Same shape as the gate above: one extra stored-element read per //! overwrite-capable op, so V1..V3 keep their released cost shape. //! +//! - `proof.terminal_non_merk_tree_child_hash: 1` — a V1 proof that reports a +//! `CommitmentTree` / `MmrTree` / `BulkAppendTree` / +//! `DenseAppendOnlyFixedSizeTree` as a terminal result (query targets the +//! tree element itself, no lower layer) carries the tree's state root in a +//! `KVValueHashFeatureTypeWithChildHash` node, and the verifier requires it. +//! V1..V3 emit a bare `KVValueHash`, which hashes only `(key, value_hash)` +//! and so leaves the element bytes — including the entry count callers read +//! — free for a prover to forge under a genuine root hash. Gated because it +//! flips a rejected/accepted outcome and because deriving the state root +//! costs the prover extra storage reads and hash calls. +//! //! Note that `GroveVersion::latest()` resolves to this version, so anything //! defaulting to "latest" — tests, benchmarks, tools — exercises every gate //! listed above rather than V3 behaviour. @@ -205,6 +216,7 @@ pub const GROVE_V4: GroveVersion = GroveVersion { verify_subset_query_with_absence_proof: 0, verify_query_with_chained_path_queries: 0, verify_query_get_parent_tree_info_with_options: 0, + terminal_non_merk_tree_child_hash: 1, // bind terminal non-Merk tree element bytes to the parent value_hash }, average_case: GroveDBOperationsAverageCaseVersions { add_average_case_get_merk_at_path: 0, diff --git a/grovedb/src/operations/proof/bind_terminal_non_merk_tree/mod.rs b/grovedb/src/operations/proof/bind_terminal_non_merk_tree/mod.rs new file mode 100644 index 000000000..33e584c02 --- /dev/null +++ b/grovedb/src/operations/proof/bind_terminal_non_merk_tree/mod.rs @@ -0,0 +1,84 @@ +//! `bind_terminal_non_merk_tree` — versioned dispatch. +//! +//! Binds the serialized element bytes of a **terminally-reported non-Merk +//! tree** — `CommitmentTree`, `MmrTree`, `BulkAppendTree`, +//! `DenseAppendOnlyFixedSizeTree` — to the `value_hash` its parent Merk +//! commits to. "Terminal" means the query targets the tree element itself and +//! the prover emits no lower layer, so there is no child layer to chain +//! through. +//! +//! These four types have no child Merk. Their parent entry is written by +//! `insert_subtree`, which commits `combine_hash(H(value), state_root)` — the +//! same two-input form that `Node::KVValueHashFeatureTypeWithChildHash` is +//! verified with. Carrying the state root in the node is therefore enough for +//! the merk verifier to close the loop; no new proof node type is needed. +//! +//! Whether it is carried is **consensus-critical** and version-gated on +//! `proof.terminal_non_merk_tree_child_hash`: +//! +//! * **[v0]** — released behaviour, `GROVE_V1`..`GROVE_V3`. The node is left +//! exactly as the prover emitted it (a bare `Node::KVValueHash`), which +//! hashes only `(key, value_hash)`. The element bytes are unbound: a prover +//! can serve a forged entry count — an inflated or deflated `CommitmentTree` +//! `total_count`, a different MMR size — alongside the genuine `value_hash` +//! and still reconstruct the correct root hash. +//! * **[v1]** — `GROVE_V4`+. The tree's state root is derived from storage and +//! the node is rewritten to `KVValueHashFeatureTypeWithChildHash`, so the +//! merk verifier's `combine_hash(H(value), child_hash) == value_hash` check +//! catches forged bytes. The matching verifier gate in +//! [`verify`](super::verify) requires the node from the same version. +//! +//! The split cannot be applied unconditionally on two counts: it flips an +//! accepted/rejected outcome, and deriving the state root costs the prover +//! storage reads and hash calls that the released versions never paid — cost +//! feeds fees. See `grovedb-version`'s `v4.rs` for the landing-zone rationale. +//! +//! [v0]: self::v0 +//! [v1]: self::v1 + +mod v0; +mod v1; + +use grovedb_costs::{CostResult, CostsExt, OperationCost}; +use grovedb_merk::proofs::Node; +use grovedb_version::version::GroveVersion; + +use crate::{Element, Error, GroveDb, Transaction}; + +impl GroveDb { + /// Bind a terminally-reported non-Merk tree's element bytes to the + /// parent-committed `value_hash`, if the grove version calls for it. + /// + /// `node` is the proof node standing for the tree element; `element` is + /// that node's already-deserialized (and `NonCounted`-unwrapped) value, and + /// must be one of the four non-Merk tree types. `parent_path` is the path + /// of the Merk holding the element — the tree's own data lives one level + /// below, under the node's key, which the versioned implementations append + /// themselves. + pub(crate) fn bind_terminal_non_merk_tree( + &self, + node: &mut Node, + element: &Element, + parent_path: &[&[u8]], + tx: &Transaction, + grove_version: &GroveVersion, + ) -> CostResult<(), Error> { + match grove_version + .grovedb_versions + .operations + .proof + .terminal_non_merk_tree_child_hash + { + 0 => self.bind_terminal_non_merk_tree_v0(node, element, parent_path, tx), + 1 => self.bind_terminal_non_merk_tree_v1(node, element, parent_path, tx), + version => Err(Error::VersionError( + grovedb_version::error::GroveVersionError::UnknownVersionMismatch { + method: "bind_terminal_non_merk_tree".to_string(), + known_versions: vec![0, 1], + received: version, + }, + )) + .wrap_with_cost(OperationCost::default()), + } + } +} diff --git a/grovedb/src/operations/proof/bind_terminal_non_merk_tree/v0.rs b/grovedb/src/operations/proof/bind_terminal_non_merk_tree/v0.rs new file mode 100644 index 000000000..7463f7b26 --- /dev/null +++ b/grovedb/src/operations/proof/bind_terminal_non_merk_tree/v0.rs @@ -0,0 +1,35 @@ +//! `bind_terminal_non_merk_tree` — **v0** (released behaviour, +//! `GROVE_V1`..`GROVE_V3`). +//! +//! Does nothing. The proof node for a terminally-reported non-Merk tree is left +//! exactly as the prover emitted it — a bare `Node::KVValueHash`, which hashes +//! only `(key, value_hash)` and leaves the serialized element bytes unbound. +//! +//! This is a **known soundness gap**, not an oversight to fix in place: a +//! prover can serve a forged entry count alongside the genuine `value_hash` and +//! still reconstruct the correct root hash. It is preserved here because +//! `GROVE_V3` is live — closing it changes both an accepted/rejected outcome +//! and the prover's tracked cost, so nodes carrying the fix would diverge from +//! nodes that do not. [`super::v1`] closes it from `GROVE_V4` onward; the hole +//! shuts when that protocol version activates. +//! +//! Deliberately takes the same arguments as [`super::v1`] and ignores them, so +//! the dispatch in [`super`][`mod@super`] stays a plain version match. + +use grovedb_costs::{CostResult, CostsExt, OperationCost}; +use grovedb_merk::proofs::Node; + +use crate::{Element, Error, GroveDb, Transaction}; + +impl GroveDb { + /// `bind_terminal_non_merk_tree` v0 — see the module documentation. + pub(crate) fn bind_terminal_non_merk_tree_v0( + &self, + _node: &mut Node, + _element: &Element, + _parent_path: &[&[u8]], + _tx: &Transaction, + ) -> CostResult<(), Error> { + Ok(()).wrap_with_cost(OperationCost::default()) + } +} diff --git a/grovedb/src/operations/proof/bind_terminal_non_merk_tree/v1.rs b/grovedb/src/operations/proof/bind_terminal_non_merk_tree/v1.rs new file mode 100644 index 000000000..664ccddc8 --- /dev/null +++ b/grovedb/src/operations/proof/bind_terminal_non_merk_tree/v1.rs @@ -0,0 +1,270 @@ +//! `bind_terminal_non_merk_tree` — **v1** (`GROVE_V4`+). +//! +//! Derives the tree's own state root from storage and rewrites the proof node +//! to `Node::KVValueHashFeatureTypeWithChildHash` carrying it. The merk +//! verifier then checks `combine_hash(H(value), child_hash) == value_hash`, +//! which is exactly the composition `insert_subtree` commits for these types — +//! so forged element bytes no longer verify against a genuine root hash. +//! +//! This differs from [`super::v0`] (which leaves the node untouched) in that it +//! both reads storage and mutates the node. Those extra reads are why it cannot +//! apply to the released versions; see the module docs in +//! [`super`][`mod@super`]. +//! +//! Proof serving is latency-sensitive, so the common path does no hashing at +//! all: the `value_hash` the node already carries is the one the parent +//! committed, and is reused as-is. Only a node shape that carries no +//! `value_hash` has to derive one. The correctness of the derived state root is +//! checked by a `debug_assert` rather than at runtime — it can fire only on a +//! prover bug or corrupted storage, and the derivation is pinned by tests. + +use grovedb_costs::{ + cost_return_on_error, cost_return_on_error_no_add, CostResult, CostsExt, OperationCost, +}; +use grovedb_merk::{ + proofs::Node, + tree::{combine_hash, value_hash, NULL_HASH}, + CryptoHash, TreeFeatureType, +}; +use grovedb_storage::{Storage, StorageContext}; + +use crate::{Element, Error, GroveDb, Transaction}; + +impl GroveDb { + /// `bind_terminal_non_merk_tree` v1 — see the module documentation. + pub(crate) fn bind_terminal_non_merk_tree_v1( + &self, + node: &mut Node, + element: &Element, + parent_path: &[&[u8]], + tx: &Transaction, + ) -> CostResult<(), Error> { + let mut cost = OperationCost::default(); + + // Read what we need out of the node before mutating it. The key also + // names the child subtree holding this tree's data. + let (key, value) = match &*node { + Node::KV(key, value) + | Node::KVValueHash(key, value, ..) + | Node::KVValueHashFeatureType(key, value, ..) + | Node::KVValueHashFeatureTypeWithChildHash(key, value, ..) => { + (key.clone(), value.clone()) + } + other => { + return Err(Error::CorruptedData(format!( + "bind_terminal_non_merk_tree called on a non-value-bearing proof node: {}", + other + ))) + .wrap_with_cost(cost); + } + }; + + let mut child_path: Vec<&[u8]> = parent_path.to_vec(); + child_path.push(key.as_slice()); + + let child_hash = cost_return_on_error!( + &mut cost, + self.non_merk_tree_child_hash(element, &child_path, tx) + ); + + // Reuse the value_hash the node already carries — it is the one the + // parent committed, so there is nothing to recompute. Proof serving is + // latency-sensitive and this runs per terminal non-Merk tree, so the + // common path must not hash. + let (vh, ft) = match &*node { + Node::KVValueHashFeatureType(_, _, vh, ft) => (*vh, *ft), + Node::KVValueHash(_, _, vh) => (*vh, TreeFeatureType::BasicMerkNode), + // A node shape that carries no value_hash to reuse (`KV`, + // `KVCount`, `KVSum`, `KVCountSum`). Only here do we have to + // derive it. Trees are proved with a value_hash-bearing node in + // practice, so this is the cold path. + _ => { + let element_vh = value_hash(&value).unwrap_add_cost(&mut cost); + let derived = combine_hash(&element_vh, &child_hash).unwrap_add_cost(&mut cost); + (derived, TreeFeatureType::BasicMerkNode) + } + }; + + // Drift check: the derived state root must reproduce the committed + // value_hash, or the node we are about to emit would be rejected by the + // verifier. Debug-only and deliberately uncosted — it can fire only on + // a prover bug or corrupted storage, never on attacker input, and the + // arms of `non_merk_tree_child_hash` are pinned by tests across all + // four types, empty and populated. Keeping it out of release spares the + // hot path two hashes; keeping it uncosted keeps `OperationCost` + // identical between debug and release builds. + #[cfg(debug_assertions)] + { + let element_vh = value_hash(&value).unwrap(); + let recomputed = combine_hash(&element_vh, &child_hash).unwrap(); + debug_assert_eq!( + recomputed, + vh, + "non-Merk tree at key {} has state root {} which does not reproduce the \ + committed value hash {}", + hex::encode(&key), + hex::encode(child_hash), + hex::encode(vh), + ); + } + + *node = Node::KVValueHashFeatureTypeWithChildHash(key, value, vh, ft, child_hash); + + Ok(()).wrap_with_cost(cost) + } + + /// Compute the child hash that a non-Merk tree element's parent Merk + /// commits to, i.e. the `child_hash` satisfying + /// `combine_hash(H(value), child_hash) == value_hash`. + /// + /// `CommitmentTree`, `MmrTree`, `BulkAppendTree` and + /// `DenseAppendOnlyFixedSizeTree` have no child Merk; their parent entry is + /// written by `insert_subtree` with the tree's own state root as the + /// supplied hash. This reproduces that hash. + /// + /// Each arm must mirror the corresponding write path exactly: + /// - `MmrTree` / `DenseAppendOnlyFixedSizeTree` / `BulkAppendTree` are + /// inserted with `NULL_HASH` while still empty, and only start committing + /// a computed root once the first append lands. Note that an empty + /// `BulkAppendTree`'s `compute_current_state_root()` is *not* `NULL_HASH`, + /// so the zero-count case has to short-circuit. + /// - `CommitmentTree` is inserted with `EMPTY_COMMITMENT_TREE_STATE_ROOT`, + /// which is exactly what the sinsemilla/bulk composition below yields at + /// count 0 — no special case needed. + fn non_merk_tree_child_hash( + &self, + element: &Element, + subtree_path: &[&[u8]], + tx: &Transaction, + ) -> CostResult { + let mut cost = OperationCost::default(); + + let path_vec: Vec> = subtree_path.iter().map(|s| s.to_vec()).collect(); + let path_refs: Vec<&[u8]> = path_vec.iter().map(|v| v.as_slice()).collect(); + let storage_path = grovedb_path::SubtreePath::from(path_refs.as_slice()); + + match element { + Element::MmrTree(mmr_size, _) => { + if *mmr_size == 0 { + return Ok(NULL_HASH).wrap_with_cost(cost); + } + let storage_ctx = self + .db + .get_transactional_storage_context(storage_path, None, tx) + .unwrap_add_cost(&mut cost); + let store = grovedb_merkle_mountain_range::MmrStore::new(&storage_ctx); + let mmr = grovedb_merkle_mountain_range::MMR::new(*mmr_size, &store); + let root = cost_return_on_error!( + &mut cost, + mmr.get_root() + .map_err(|e| Error::CorruptedData(format!("MMR get_root failed: {}", e))) + ); + Ok(root.hash()).wrap_with_cost(cost) + } + Element::DenseAppendOnlyFixedSizeTree(count, height, _) => { + if *count == 0 { + return Ok(NULL_HASH).wrap_with_cost(cost); + } + let storage_ctx = self + .db + .get_transactional_storage_context(storage_path, None, tx) + .unwrap_add_cost(&mut cost); + let tree = cost_return_on_error_no_add!( + cost, + grovedb_dense_fixed_sized_merkle_tree::DenseFixedSizedMerkleTree::from_state( + *height, + *count, + storage_ctx, + ) + .map_err(|e| Error::CorruptedData(format!("dense tree state error: {}", e))) + ); + let root_hash = cost_return_on_error!( + &mut cost, + tree.root_hash().map_err(|e| Error::CorruptedData(format!( + "dense tree root hash error: {}", + e + ))) + ); + Ok(root_hash).wrap_with_cost(cost) + } + Element::BulkAppendTree(total_count, chunk_power, _) => { + if *total_count == 0 { + return Ok(NULL_HASH).wrap_with_cost(cost); + } + let storage_ctx = self + .db + .get_transactional_storage_context(storage_path, None, tx) + .unwrap_add_cost(&mut cost); + let tree = cost_return_on_error_no_add!( + cost, + grovedb_bulk_append_tree::BulkAppendTree::from_state( + *total_count, + *chunk_power, + storage_ctx, + ) + .map_err(|e| Error::CorruptedData(format!( + "failed to create BulkAppendTree: {}", + e + ))) + ); + let state_root = cost_return_on_error_no_add!( + cost, + tree.compute_current_state_root().map_err(|e| { + Error::CorruptedData(format!("bulk append state root failed: {}", e)) + }) + ); + Ok(state_root).wrap_with_cost(cost) + } + Element::CommitmentTree(total_count, chunk_power, _) => { + let storage_ctx = self + .db + .get_transactional_storage_context(storage_path, None, tx) + .unwrap_add_cost(&mut cost); + + let sinsemilla_root = match storage_ctx + .get(grovedb_commitment_tree::COMMITMENT_TREE_DATA_KEY) + .value + { + Ok(Some(frontier_bytes)) => { + match grovedb_commitment_tree::CommitmentFrontier::deserialize( + frontier_bytes.as_ref(), + ) { + Ok(frontier) => frontier.root_hash(), + Err(_) => grovedb_commitment_tree::EMPTY_SINSEMILLA_ROOT, + } + } + _ => grovedb_commitment_tree::EMPTY_SINSEMILLA_ROOT, + }; + + let tree = cost_return_on_error_no_add!( + cost, + grovedb_bulk_append_tree::BulkAppendTree::from_state( + *total_count, + *chunk_power, + storage_ctx, + ) + .map_err(|e| Error::CorruptedData(format!( + "failed to create BulkAppendTree: {}", + e + ))) + ); + let bulk_state_root = cost_return_on_error_no_add!( + cost, + tree.compute_current_state_root().map_err(|e| { + Error::CorruptedData(format!("bulk append state root failed: {}", e)) + }) + ); + + Ok(grovedb_commitment_tree::compute_commitment_tree_state_root( + &sinsemilla_root, + &bulk_state_root, + )) + .wrap_with_cost(cost) + } + _ => Err(Error::CorruptedCodeExecution( + "non_merk_tree_child_hash called on an element that is not a non-Merk tree", + )) + .wrap_with_cost(cost), + } + } +} diff --git a/grovedb/src/operations/proof/generate.rs b/grovedb/src/operations/proof/generate.rs index 64540df78..b095ee0f5 100644 --- a/grovedb/src/operations/proof/generate.rs +++ b/grovedb/src/operations/proof/generate.rs @@ -2339,13 +2339,47 @@ impl GroveDb { lower_layers.insert(key.clone(), layer_proof); } - // MmrTree/BulkAppendTree without subquery (query targets the tree - // itself) - Ok(Element::MmrTree(..)) - | Ok(Element::BulkAppendTree(..)) - | Ok(Element::DenseAppendOnlyFixedSizeTree(..)) + // Non-Merk tree that is itself the result, with + // nothing queried below it. These types have no + // child Merk, so there is no lower layer to bind + // them — a bare `KVValueHash` node hashes only + // (key, value_hash) and would leave the element + // bytes (and with them the entry count a caller + // reads) free for a prover to forge under a + // genuine root hash. Their parent commits + // `combine_hash(H(value), state_root)`, exactly + // the two-input form + // `KVValueHashFeatureTypeWithChildHash` is + // verified with, so carry the state root in the + // node and let the merk verifier close the loop. + // + // Version-gated on + // `proof.terminal_non_merk_tree_child_hash`, so the + // binding itself lives in + // `bind_terminal_non_merk_tree`: deriving the state + // root costs storage reads and hash calls that + // V1..V3 did not pay, and cost feeds fees. Under + // those versions the node is left as the prover has + // always emitted it and only the limit moves. + Ok(ref non_merk_elem @ Element::MmrTree(..)) + | Ok(ref non_merk_elem @ Element::BulkAppendTree(..)) + | Ok( + ref non_merk_elem @ Element::DenseAppendOnlyFixedSizeTree(..), + ) + | Ok(ref non_merk_elem @ Element::CommitmentTree(..)) if !done_with_results => { + cost_return_on_error!( + &mut cost, + self.bind_terminal_non_merk_tree( + node, + non_merk_elem, + &path, + &tx, + grove_version, + ) + ); + if let Some(limit) = overall_limit.as_mut() { *limit -= 1; } @@ -2579,7 +2613,10 @@ impl GroveDb { } lower_layers.insert(key.clone(), layer_proof); } - // Empty trees and CommitmentTree without subquery + // Empty trees without subquery. CommitmentTree is + // NOT here — like the other non-Merk trees it is + // bound by the child-hash arm above, which applies + // whether or not it holds any notes. Ok(Element::Tree(None, _)) | Ok(Element::SumTree(None, ..)) | Ok(Element::BigSumTree(None, ..)) diff --git a/grovedb/src/operations/proof/indexed_axis/axis_api.rs b/grovedb/src/operations/proof/indexed_axis/axis_api.rs index 1ac184bf7..cc22bba15 100644 --- a/grovedb/src/operations/proof/indexed_axis/axis_api.rs +++ b/grovedb/src/operations/proof/indexed_axis/axis_api.rs @@ -3,13 +3,18 @@ //! //! Each wrapper pins the [`IndexAxis`] and forwards; no logic lives here. +#[cfg(feature = "minimal")] use grovedb_costs::CostResult; use grovedb_element::indexed::IndexAxis; use grovedb_merk::proofs::Query as MerkQuery; +#[cfg(feature = "minimal")] use grovedb_path::SubtreePath; +#[cfg(feature = "minimal")] use grovedb_version::version::GroveVersion; -use crate::{Error, GroveDb, TransactionArg}; +#[cfg(feature = "minimal")] +use crate::TransactionArg; +use crate::{Error, GroveDb}; use super::{IndexedAxisAggregateResult, IndexedAxisPaginatedResult, IndexedAxisQueryResult}; @@ -18,6 +23,7 @@ impl GroveDb { /// Prove the top-`k` entries of the count axis. Thin wrapper over /// [`Self::prove_indexed_axis_top_k`] with `axis = Count`. + #[cfg(feature = "minimal")] pub fn prove_indexed_count_top_k<'b, B, P>( &self, path: P, @@ -41,6 +47,7 @@ impl GroveDb { } /// Prove an offset-paginated top-`k` window on the count axis. + #[cfg(feature = "minimal")] pub fn prove_indexed_count_top_k_paginated<'b, B, P>( &self, path: P, @@ -66,6 +73,7 @@ impl GroveDb { } /// Prove an arbitrary query against the count-axis secondary. + #[cfg(feature = "minimal")] pub fn prove_indexed_count_query<'b, B, P>( &self, path: P, @@ -90,6 +98,7 @@ impl GroveDb { /// Prove the aggregate count of entries whose `count_value` is in /// `[lo_count, hi_count]`. + #[cfg(feature = "minimal")] pub fn prove_indexed_count_range_aggregate<'b, B, P>( &self, path: P, @@ -181,6 +190,7 @@ impl GroveDb { // ---------- sum axis ---------- /// Prove the top-`k` entries of the sum axis. + #[cfg(feature = "minimal")] pub fn prove_indexed_sum_top_k<'b, B, P>( &self, path: P, @@ -207,6 +217,7 @@ impl GroveDb { /// 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. + #[cfg(feature = "minimal")] pub fn prove_indexed_sum_top_k_paginated<'b, B, P>( &self, path: P, @@ -232,6 +243,7 @@ impl GroveDb { } /// Prove an arbitrary query against the sum-axis secondary. + #[cfg(feature = "minimal")] pub fn prove_indexed_sum_query<'b, B, P>( &self, path: P, @@ -256,6 +268,7 @@ impl GroveDb { /// Prove the aggregate sum of entries whose `sum_value` is in /// `[lo_sum, hi_sum]`. + #[cfg(feature = "minimal")] pub fn prove_indexed_sum_range_aggregate<'b, B, P>( &self, path: P, @@ -349,6 +362,7 @@ impl GroveDb { /// Prove the top-`k` entries of the avg axis. PCPSIT-only. No /// aggregate variant exists — averaging an average over a range is /// not closed-form. + #[cfg(feature = "minimal")] pub fn prove_indexed_avg_top_k<'b, B, P>( &self, path: P, @@ -372,6 +386,7 @@ impl GroveDb { } /// Prove an offset-paginated top-`k` window on the avg axis. + #[cfg(feature = "minimal")] pub fn prove_indexed_avg_top_k_paginated<'b, B, P>( &self, path: P, @@ -397,6 +412,7 @@ impl GroveDb { } /// Prove an arbitrary query against the avg-axis secondary. + #[cfg(feature = "minimal")] pub fn prove_indexed_avg_query<'b, B, P>( &self, path: P, diff --git a/grovedb/src/operations/proof/indexed_axis/mod.rs b/grovedb/src/operations/proof/indexed_axis/mod.rs index 2eaa252d8..42177527b 100644 --- a/grovedb/src/operations/proof/indexed_axis/mod.rs +++ b/grovedb/src/operations/proof/indexed_axis/mod.rs @@ -63,6 +63,7 @@ mod axis_api; mod envelope; +#[cfg(feature = "minimal")] mod generate; mod verify; diff --git a/grovedb/src/operations/proof/mod.rs b/grovedb/src/operations/proof/mod.rs index 6f6a794d2..65e708338 100644 --- a/grovedb/src/operations/proof/mod.rs +++ b/grovedb/src/operations/proof/mod.rs @@ -8,9 +8,18 @@ mod aggregate_count; mod aggregate_count_and_sum; #[cfg(any(feature = "minimal", feature = "verify"))] mod aggregate_sum; +/// Versioned dispatch for `bind_terminal_non_merk_tree`, which binds a +/// terminally-reported non-Merk tree's element bytes to the parent-committed +/// `value_hash`. Consensus-critical — see the module docs. #[cfg(feature = "minimal")] -mod generate; +mod bind_terminal_non_merk_tree; #[cfg(feature = "minimal")] +mod generate; +// The prover lives in `indexed_axis::generate` and is `minimal`-gated there; +// the envelope types and verification entry points must be reachable from a +// verifier-only build (Dash Platform's `drive` crate compiles its +// proof-verification layer with `--no-default-features --features verify`). +#[cfg(any(feature = "minimal", feature = "verify"))] pub mod indexed_axis; /// Utility functions for proof display and conversion. pub mod util; diff --git a/grovedb/src/operations/proof/verify.rs b/grovedb/src/operations/proof/verify.rs index f133e5014..ab3db34cd 100644 --- a/grovedb/src/operations/proof/verify.rs +++ b/grovedb/src/operations/proof/verify.rs @@ -675,6 +675,35 @@ impl GroveDb { } } + /// Derive a Merk layer's root hash without reporting any of its rows. + /// + /// Used when a subset verification stops at a tree ELEMENT that the proof + /// descended into: the parent still has to bind the element bytes with + /// `combine_hash(H(value), child_root)`, but none of the child layer's rows + /// belong in this query's result set. An empty query walks the proof for + /// its root and matches nothing, so no row is produced and no limit is + /// consumed. + /// + /// The layer's own `lower_layers` are deliberately not descended into. The + /// root returned here is computed from this layer's nodes alone, whose + /// value hashes already commit to everything beneath them; the deeper + /// layers exist only to authenticate rows that are not being reported. + fn merk_layer_root_hash( + merk_proof_bytes: &[u8], + query: &PathQuery, + ) -> Result { + let (root_hash, _) = Query::new() + .execute_proof(merk_proof_bytes, None, true, PROOF_VERSION_LATEST) + .unwrap() + .map_err(|e| { + Error::InvalidProof( + query.clone(), + format!("Invalid V1 lower layer proof (root derivation): {}", e), + ) + })?; + Ok(root_hash) + } + /// Takes the layer's Merk proof BYTES and its lower layers separately /// rather than a `&LayerProof`. /// @@ -1014,23 +1043,44 @@ impl GroveDb { | Element::DenseAppendOnlyFixedSizeTree(..) => { path.push(key); *last_parent_tree_type = element.tree_feature_type(); - if query.query_items_at_path(&path, grove_version)?.is_none() { - // Query targets the tree itself, not its - // contents — but a lower layer was still - // supplied, which an honest prover never - // does (every `lower_layers.insert` site is - // gated on there being a subquery for this - // key). Pushing the value here would skip - // both binding mechanisms: the - // `combine_hash` chain check below (which - // needs a query at the lower path) and the - // `child_hash_verified` requirement applied - // on the no-lower-layer path. Since a - // `KVValueHash` node commits only to - // (key, value_hash), accepting it would let - // a prover attach a dummy lower layer and - // substitute forged element bytes under a - // genuine root hash. Fail closed. + // Does the current query ask for anything BELOW + // this tree, or only for the tree element + // itself? + // + // "Only the element itself" while the proof + // still carries a lower layer is the ordinary + // shape of a SUBSET verification: the proof was + // generated for a wider query that descended + // here, and is now being re-verified against a + // narrower one that stops at the tree. Dash + // Platform does exactly this to read a + // CommitmentTree's total note count out of the + // note-fetch proof it already has. + // + // Either way the element bytes must stay bound + // to the parent-committed `value_hash`: a + // `KVValueHash`-family node hashes only + // (key, value_hash), so reporting `value` + // unchecked would let a prover attach a dummy + // lower layer and substitute forged element + // bytes under a genuine root hash. The + // `combine_hash` chain check below is what binds + // it, and it needs the lower layer's root — so + // the lower layer is consumed for its root hash + // in BOTH cases. Only the reporting differs: + // with no query below, none of the lower layer's + // rows belong in this query's result set, so the + // tree element itself is reported instead. + let has_query_below = + query.query_items_at_path(&path, grove_version)?.is_some(); + + if !has_query_below && options.verify_proof_succinctness { + // Succinct mode demands the proof carry + // nothing beyond what the query needs, and + // an honest prover never emits this layer + // for this query (every `lower_layers.insert` + // site is gated on there being a subquery + // for the key). Reject as extra data. return Err(Error::InvalidProof( query.clone(), format!( @@ -1040,15 +1090,19 @@ impl GroveDb { hex::encode(key), ), )); - } else { + } + + { // Known limitation: this parent tree result // is pushed without decrementing limit_left. // Will be addressed by per-level limits // redesign. - if query.should_add_parent_tree_at_path( - current_path, - grove_version, - )? { + if has_query_below + && query.should_add_parent_tree_at_path( + current_path, + grove_version, + )? + { let path_key_optional_value = ProvedPathKeyOptionalValue::from_proved_key_value( path.iter().map(|p| p.to_vec()).collect(), @@ -1060,23 +1114,34 @@ impl GroveDb { ); } - // Dispatch based on lower layer proof type + // Dispatch based on lower layer proof type. + // `has_query_below == false` derives the + // layer's root WITHOUT reporting any of its + // contents — every lower-layer flavour + // computes its root independently of the + // query, which only ever selects rows. let lower_hash = match &lower_layer.merk_proof { ProofBytes::Merk(_) => { // Standard Merk subtree - recurse - Self::verify_layer_proof_v1( - Self::merk_bytes_of_layer(lower_layer, query)?, - &lower_layer.lower_layers, - prove_options, - query, - limit_left, - &path, - result, - last_parent_tree_type, - options, - current_depth + 1, - grove_version, - )? + let merk_bytes = + Self::merk_bytes_of_layer(lower_layer, query)?; + if has_query_below { + Self::verify_layer_proof_v1( + merk_bytes, + &lower_layer.lower_layers, + prove_options, + query, + limit_left, + &path, + result, + last_parent_tree_type, + options, + current_depth + 1, + grove_version, + )? + } else { + Self::merk_layer_root_hash(merk_bytes, query)? + } } ProofBytes::MMR(mmr_bytes) => Self::verify_mmr_lower_layer( mmr_bytes, @@ -1085,6 +1150,7 @@ impl GroveDb { limit_left, result, query, + has_query_below, grove_version, )?, ProofBytes::BulkAppendTree(bulk_bytes) => { @@ -1095,6 +1161,7 @@ impl GroveDb { limit_left, result, query, + has_query_below, grove_version, )? } @@ -1106,6 +1173,7 @@ impl GroveDb { limit_left, result, query, + has_query_below, grove_version, )? } @@ -1117,6 +1185,7 @@ impl GroveDb { limit_left, result, query, + has_query_below, grove_version, )? } @@ -1154,6 +1223,33 @@ impl GroveDb { ), )); } + + if !has_query_below { + // The tree element itself is the + // result, now bound by the + // `combine_hash` check above. Report it + // under the PARENT path (not the path + // we pushed the key onto for the root + // derivation), matching query_raw and + // the no-lower-layer terminal arm — + // including the key would make + // `(path, key)` lookups miss. + let parent_path: Vec> = + current_path.iter().map(|p| p.to_vec()).collect(); + let path_key_optional_value = + ProvedPathKeyOptionalValue::from_proved_key_value( + parent_path, + proved_key_value, + ); + result.push( + path_key_optional_value + .try_into_versioned(grove_version)?, + ); + limit_left + .iter_mut() + .for_each(|limit| *limit = limit.saturating_sub(1)); + } + if limit_left == &Some(0) { break; } @@ -1275,17 +1371,46 @@ impl GroveDb { } } - // For non-empty Merk trees without a subquery (no - // lower layer proof), the prover must use + // For trees reported without a subquery (no lower layer + // proof), the prover must use // KVValueHashFeatureTypeWithChildHash so the merk // verifier can confirm combine_hash(H(value), // child_hash) == value_hash. If child_hash_verified is // false, an attacker may have downgraded the node type // to hide child hash verification. - // Non-Merk trees (MmrTree, BulkAppendTree, etc.) are - // excluded — they use different proof structures. - if element.is_non_empty_merk_tree() && !proved_key_value.child_hash_verified - { + // + // Non-empty Merk trees (child_hash = child Merk root) + // have required this since V3 and are checked at every + // version. + // + // The four non-Merk trees — CommitmentTree, MmrTree, + // BulkAppendTree, DenseAppendOnlyFixedSizeTree + // (child_hash = the tree's own state root, which their + // parent commits through the same two-input + // combine_hash) — are checked only from V4, under + // `proof.terminal_non_merk_tree_child_hash`. V1..V3 + // provers emit a bare KVValueHash here, so demanding + // the child hash from them would reject honest + // released proofs; the gate moves prover and verifier + // together at the protocol boundary. Until it + // activates, the element bytes of a terminally-reported + // non-Merk tree stay unbound and a prover can forge the + // entry count callers read from them. + // + // `is_non_empty_tree` is true unconditionally for those + // four, so an empty one is bound too — its committed + // child hash is NULL_HASH, or + // EMPTY_COMMITMENT_TREE_STATE_ROOT for a + // CommitmentTree. + let requires_child_hash = element.is_non_empty_merk_tree() + || (grove_version + .grovedb_versions + .operations + .proof + .terminal_non_merk_tree_child_hash + >= 1 + && element.is_non_empty_tree()); + if requires_child_hash && !proved_key_value.child_hash_verified { return Err(Error::InvalidProof( query.clone(), format!( @@ -1346,6 +1471,7 @@ impl GroveDb { /// Returns the computed MMR root hash, which the caller uses as the /// child hash for Merk authentication (`combine_hash(value_hash || /// mmr_root)`). + #[allow(clippy::too_many_arguments)] fn verify_mmr_lower_layer( mmr_bytes: &[u8], element: &Element, @@ -1353,6 +1479,7 @@ impl GroveDb { limit_left: &mut Option, result: &mut Vec, query: &PathQuery, + report_contents: bool, grove_version: &GroveVersion, ) -> Result where @@ -1406,6 +1533,13 @@ impl GroveDb { .verify_and_get_root() .map_err(|e| Error::InvalidProof(query.clone(), format!("{}", e)))?; + // Root only: the caller is binding the parent element and does not + // report this layer's leaves, so there is no query at this path to + // check completeness/succinctness against. + if !report_contents { + return Ok(mmr_root); + } + // Get the sub-query items for this path to enforce succinctness. let sub_query = query @@ -1477,6 +1611,7 @@ impl GroveDb { /// For both `BulkAppendTree` and `CommitmentTree` elements: verifies /// internal consistency and returns the computed state_root as the lower /// hash (authenticated via child Merk hash). + #[allow(clippy::too_many_arguments)] fn verify_bulk_append_lower_layer( bulk_bytes: &[u8], element: &Element, @@ -1484,6 +1619,7 @@ impl GroveDb { limit_left: &mut Option, result: &mut Vec, query: &PathQuery, + report_contents: bool, grove_version: &GroveVersion, ) -> Result where @@ -1509,6 +1645,13 @@ impl GroveDb { .verify_and_compute_root(element_height, element_total_count) .map_err(|e| Error::InvalidProof(query.clone(), format!("{}", e)))?; + // Root only: the caller is binding the parent element and does not + // report this layer's entries, so there is no query at this path to + // extract a position range from. + if !report_contents { + return Ok(bulk_state_root); + } + // Get the query range from the path query to extract matching values let sub_query = query @@ -1589,6 +1732,7 @@ impl GroveDb { /// Verifies the BulkAppendTree proof to get `bulk_state_root`, then returns /// `blake3("ct_state" || sinsemilla_root || bulk_state_root)` as the /// authenticated child hash. + #[allow(clippy::too_many_arguments)] fn verify_commitment_tree_lower_layer( ct_bytes: &[u8], element: &Element, @@ -1596,6 +1740,7 @@ impl GroveDb { limit_left: &mut Option, result: &mut Vec, query: &PathQuery, + report_contents: bool, grove_version: &GroveVersion, ) -> Result where @@ -1622,6 +1767,7 @@ impl GroveDb { limit_left, result, query, + report_contents, grove_version, )?; @@ -1638,6 +1784,7 @@ impl GroveDb { /// Verify a DenseAppendOnlyFixedSizeTree lower layer proof and add results. /// Returns NULL_HASH since DenseTree has no child Merk. + #[allow(clippy::too_many_arguments)] fn verify_dense_tree_lower_layer( dense_bytes: &[u8], element: &Element, @@ -1645,6 +1792,7 @@ impl GroveDb { limit_left: &mut Option, result: &mut Vec, query: &PathQuery, + report_contents: bool, grove_version: &GroveVersion, ) -> Result where @@ -1666,6 +1814,16 @@ impl GroveDb { grovedb_dense_fixed_sized_merkle_tree::DenseTreeProof::decode_from_slice(dense_bytes) .map_err(|e| Error::CorruptedData(format!("{}", e)))?; + // Root only: the caller is binding the parent element and does not + // report this layer's entries, so there is no query at this path to + // check completeness/soundness against. + if !report_contents { + let (computed_root, _entries): ([u8; 32], Vec<(u16, Vec)>) = dense_proof + .verify_and_get_root(element_height, element_count) + .map_err(|e| Error::InvalidProof(query.clone(), format!("{}", e)))?; + return Ok(computed_root); + } + // Get the sub-query items for this path to build a query for // verify_for_query, which enforces both completeness and soundness. let sub_query = diff --git a/grovedb/src/tests/commitment_tree_tests.rs b/grovedb/src/tests/commitment_tree_tests.rs index a62d63e1a..2d76f08b4 100644 --- a/grovedb/src/tests/commitment_tree_tests.rs +++ b/grovedb/src/tests/commitment_tree_tests.rs @@ -2739,3 +2739,122 @@ fn replace_subtree_root_rejects_non_tree_element() { "expected InvalidInput for non-tree element, got {result:?}" ); } + +/// Regression (Dash Platform shielded-notes shape): the note-count query. +/// +/// Platform fetches shielded notes with one proof that subqueries INTO the +/// `CommitmentTree`, then extracts the on-chain total note count from those +/// SAME proof bytes by subset-verifying a single-key `PathQuery` that targets +/// the `CommitmentTree` element itself — no subquery, limit 1 — and reading +/// `Element::CommitmentTree(total_count, ..)`. +/// +/// The proof therefore carries a lower layer at the tree's key while the +/// count query has nothing below it. That must verify, and the element must +/// come back bound to the parent-committed value hash via the lower layer's +/// derived state root. +#[test] +fn test_commitment_tree_element_count_subset_query_against_note_fetch_proof() { + let grove_version = GroveVersion::latest(); + let db = make_empty_grovedb(); + let chunk_power: u8 = 2; + + db.insert( + EMPTY_PATH, + b"root", + Element::empty_tree(), + None, + None, + grove_version, + ) + .unwrap() + .expect("insert root tree"); + + db.insert( + &[b"root"], + b"pool", + Element::empty_commitment_tree(chunk_power).expect("valid chunk_power"), + None, + None, + grove_version, + ) + .unwrap() + .expect("insert commitment tree"); + + // 6 notes: one full chunk (4) plus 2 buffered. + const NOTE_COUNT: u64 = 6; + for i in 0..NOTE_COUNT as u8 { + db.commitment_tree_insert( + &[b"root"], + b"pool", + test_cmx(i), + test_rho(i), + test_cv_net(i), + test_ciphertext(i), + None, + grove_version, + ) + .unwrap() + .expect("commitment tree insert"); + } + + // The note-fetch proof: descends into the CommitmentTree. + let mut inner_query = Query::new(); + inner_query.insert_range_inclusive(0u64.to_be_bytes().to_vec()..=5u64.to_be_bytes().to_vec()); + let notes_query = PathQuery { + path: vec![b"root".to_vec()], + query: SizedQuery { + query: Query { + items: vec![QueryItem::Key(b"pool".to_vec())], + default_subquery_branch: SubqueryBranch { + subquery_path: None, + subquery: Some(inner_query.into()), + }, + left_to_right: true, + conditional_subquery_branches: None, + add_parent_tree_on_subquery: false, + }, + limit: None, + offset: None, + }, + }; + + let proof_bytes = db + .prove_query(¬es_query, None, grove_version) + .unwrap() + .expect("generate note-fetch proof"); + + // The count query: the CommitmentTree element itself, no subquery. + let count_query = PathQuery { + path: vec![b"root".to_vec()], + query: SizedQuery { + query: Query::new_single_key(b"pool".to_vec()), + limit: Some(1), + offset: None, + }, + }; + + let (count_root_hash, count_results) = + GroveDb::verify_subset_query(&proof_bytes, &count_query, grove_version) + .expect("count query must subset-verify against the note-fetch proof"); + + let expected_root = db.grove_db.root_hash(None, grove_version).unwrap().unwrap(); + assert_eq!( + count_root_hash, expected_root, + "the count sub-proof must derive the same root as the note-fetch proof" + ); + assert_eq!(count_results.len(), 1, "exactly the CommitmentTree element"); + + let (path, key, element) = &count_results[0]; + assert_eq!(path, &vec![b"root".to_vec()]); + assert_eq!(key, b"pool"); + match element.as_ref().expect("element present") { + Element::CommitmentTree(total_count, height, _) => { + assert_eq!( + *total_count, NOTE_COUNT, + "total_count must be the on-chain note count" + ); + assert_eq!(*height, chunk_power); + } + other => panic!("expected CommitmentTree element, got {:?}", other), + } +} diff --git a/grovedb/src/tests/proof_coverage_tests.rs b/grovedb/src/tests/proof_coverage_tests.rs index caa4aaebc..386258658 100644 --- a/grovedb/src/tests/proof_coverage_tests.rs +++ b/grovedb/src/tests/proof_coverage_tests.rs @@ -8127,4 +8127,660 @@ mod tests { } false } + + // ========================================================================= + // Terminal non-Merk tree elements must stay bound to the parent value_hash + // + // A CommitmentTree / MmrTree / BulkAppendTree / + // DenseAppendOnlyFixedSizeTree reported as a terminal result (the query + // targets the tree element itself and the prover emits no lower layer) + // used to be proved with a bare `KVValueHash` node. That node hashes only + // (key, value_hash), so the serialized element bytes — which carry the + // entry count a caller reads — were never bound to the value_hash the + // parent Merk commits to. The prover now emits + // `KVValueHashFeatureTypeWithChildHash` carrying the subtree's state root, + // and the verifier requires it. + // ========================================================================= + + /// How a forged terminal non-Merk tree node is dressed up in the proof. + #[derive(Clone, Copy)] + enum TerminalForgery { + /// Keep the `KVValueHashFeatureTypeWithChildHash` node the honest + /// prover emits (value_hash and child_hash untouched) and only swap the + /// element bytes. The merk verifier's + /// `combine_hash(H(value), child_hash) == value_hash` check must catch + /// this. + KeepChildHash, + /// Downgrade to the bare `KVValueHash` node the prover used to emit, + /// dropping the child hash entirely. This is the shape the soundness + /// gap allowed: nothing in the node ties the element bytes to the + /// value_hash, so only the verifier's `child_hash_verified` requirement + /// can catch it. + DowngradeToKvValueHash, + } + + /// Rewrite the terminal proof node for `target_key` in the TEST_LEAF layer, + /// substituting `fake_element_bytes` for the element bytes while keeping + /// the genuine value_hash. Returns the re-encoded proof. + fn forge_terminal_tree_element( + proof_bytes: &[u8], + target_key: &[u8], + fake_element_bytes: &[u8], + forgery: TerminalForgery, + ) -> Vec { + use grovedb_merk::proofs::{encode_into, Decoder, Node, Op}; + + let config = bincode::config::standard() + .with_big_endian() + .with_limit::<{ 256 * 1024 * 1024 }>(); + let (mut grovedb_proof, _): (GroveDBProof, _) = + bincode::decode_from_slice(proof_bytes, config).expect("decode"); + + let GroveDBProof::V1(ref mut v1) = grovedb_proof else { + panic!("expected a V1 envelope"); + }; + let leaf_layer = v1 + .root_layer + .lower_layers + .get_mut(TEST_LEAF) + .expect("TEST_LEAF lower layer"); + let bytes = match leaf_layer.merk_proof { + crate::operations::proof::ProofBytes::Merk(ref mut bytes) => bytes, + _ => panic!("expected Merk proof bytes at the TEST_LEAF layer"), + }; + + let mut ops: Vec = Decoder::new(bytes).map(|r| r.expect("decode op")).collect(); + + let mut forged = false; + for op in ops.iter_mut() { + let Op::Push(Node::KVValueHashFeatureTypeWithChildHash( + key, + _value, + value_hash, + feature_type, + child_hash, + )) = op + else { + continue; + }; + if key.as_slice() != target_key { + continue; + } + *op = match forgery { + TerminalForgery::KeepChildHash => { + Op::Push(Node::KVValueHashFeatureTypeWithChildHash( + key.clone(), + fake_element_bytes.to_vec(), + *value_hash, + *feature_type, + *child_hash, + )) + } + TerminalForgery::DowngradeToKvValueHash => Op::Push(Node::KVValueHash( + key.clone(), + fake_element_bytes.to_vec(), + *value_hash, + )), + }; + forged = true; + break; + } + assert!( + forged, + "honest proof should carry a KVValueHashFeatureTypeWithChildHash node for the \ + terminal tree — the prover must bind its element bytes" + ); + + let mut new_bytes = Vec::new(); + encode_into(ops.iter(), &mut new_bytes); + *bytes = new_bytes; + + bincode::encode_to_vec(&grovedb_proof, config).expect("re-encode") + } + + /// Version-agnostic sibling of [`forge_terminal_tree_element`]: swaps the + /// element bytes of whatever terminal node the prover emitted for + /// `target_key`, keeping its value_hash, and reports whether that node was + /// the child-hash-bearing kind. Used to pin behaviour on both sides of the + /// `terminal_non_merk_tree_child_hash` gate, where the node shape differs. + fn forge_terminal_node_any_shape( + proof_bytes: &[u8], + target_key: &[u8], + fake_element_bytes: &[u8], + ) -> (bool, Vec) { + use grovedb_merk::proofs::{encode_into, Decoder, Node, Op}; + + let config = bincode::config::standard() + .with_big_endian() + .with_limit::<{ 256 * 1024 * 1024 }>(); + let (mut grovedb_proof, _): (GroveDBProof, _) = + bincode::decode_from_slice(proof_bytes, config).expect("decode"); + + let GroveDBProof::V1(ref mut v1) = grovedb_proof else { + panic!("expected a V1 envelope"); + }; + let leaf_layer = v1 + .root_layer + .lower_layers + .get_mut(TEST_LEAF) + .expect("TEST_LEAF lower layer"); + let bytes = match leaf_layer.merk_proof { + crate::operations::proof::ProofBytes::Merk(ref mut bytes) => bytes, + _ => panic!("expected Merk proof bytes at the TEST_LEAF layer"), + }; + + let mut ops: Vec = Decoder::new(bytes).map(|r| r.expect("decode op")).collect(); + + let mut had_child_hash = None; + for op in ops.iter_mut() { + match op { + Op::Push(Node::KVValueHashFeatureTypeWithChildHash( + key, + _v, + value_hash, + feature_type, + child_hash, + )) if key.as_slice() == target_key => { + had_child_hash = Some(true); + *op = Op::Push(Node::KVValueHashFeatureTypeWithChildHash( + key.clone(), + fake_element_bytes.to_vec(), + *value_hash, + *feature_type, + *child_hash, + )); + break; + } + Op::Push(Node::KVValueHash(key, _v, value_hash)) + if key.as_slice() == target_key => + { + had_child_hash = Some(false); + *op = Op::Push(Node::KVValueHash( + key.clone(), + fake_element_bytes.to_vec(), + *value_hash, + )); + break; + } + _ => continue, + } + } + let had_child_hash = + had_child_hash.expect("proof should carry a value-bearing node for the target key"); + + let mut new_bytes = Vec::new(); + encode_into(ops.iter(), &mut new_bytes); + *bytes = new_bytes; + + ( + had_child_hash, + bincode::encode_to_vec(&grovedb_proof, config).expect("re-encode"), + ) + } + + /// Consensus version gate for `proof.terminal_non_merk_tree_child_hash`. + /// + /// A terminal non-Merk tree (here a populated `CommitmentTree`) is proved + /// with a bare `KVValueHash` under **v0** (`GROVE_V1`..`GROVE_V3` — the + /// released shape), which hashes only `(key, value_hash)` and so leaves the + /// element bytes unbound: a forged `total_count` verifies against the real + /// root hash. Under **v1** (`GROVE_V4`+) the prover emits + /// `KVValueHashFeatureTypeWithChildHash` carrying the tree's state root and + /// the verifier requires it, so the same forgery is rejected. + /// + /// This pins both sides so the gate cannot silently collapse to one + /// behaviour — which would either reject honest released proofs (if V3 + /// started demanding the child hash) or silently reopen the forgery (if V4 + /// stopped). + #[test] + fn terminal_non_merk_tree_child_hash_version_gate() { + use grovedb_version::version::v3::GROVE_V3; + + let build_and_forge = |grove_version: &GroveVersion| { + let db = make_test_grovedb(grove_version); + db.insert( + [TEST_LEAF].as_ref(), + b"pool", + Element::empty_commitment_tree(10).expect("valid chunk_power"), + None, + None, + grove_version, + ) + .unwrap() + .expect("insert commitment tree"); + + let mut cmx = [0u8; 32]; + cmx[0] = 1; + cmx[31] &= 0x7f; + let mut rho = [0u8; 32]; + rho[0] = 1; + rho[1] = 0xAA; + let mut cv_net = [0u8; 32]; + cv_net[0] = 1; + cv_net[1] = 0xCC; + let ciphertext = grovedb_commitment_tree::TransmittedNoteCiphertext::< + grovedb_commitment_tree::DashMemo, + >::from_parts( + [7u8; 32], + grovedb_commitment_tree::NoteBytesData([3u8; 104]), + [5u8; 80], + ); + db.commitment_tree_insert( + [TEST_LEAF].as_ref(), + b"pool", + cmx, + rho, + cv_net, + ciphertext, + None, + grove_version, + ) + .unwrap() + .expect("append note"); + + let mut query = Query::new(); + query.insert_key(b"pool".to_vec()); + let path_query = PathQuery::new_unsized(vec![TEST_LEAF.to_vec()], query); + + let proof_bytes = db + .prove_query(&path_query, None, grove_version) + .unwrap() + .expect("prove"); + + // The honest proof must verify at every version. + let (_, results) = GroveDb::verify_query_raw(&proof_bytes, &path_query, grove_version) + .expect("honest proof should verify"); + let element = Element::deserialize(&results[0].value, grove_version).expect("deser"); + let (chunk_power, flags) = match &element { + Element::CommitmentTree(total_count, chunk_power, flags) => { + assert_eq!(*total_count, 1, "honest proof should report 1 note"); + (*chunk_power, flags.clone()) + } + other => panic!("expected CommitmentTree, got {:?}", other), + }; + + let fake_element_bytes = Element::CommitmentTree(999, chunk_power, flags) + .serialize(grove_version) + .expect("serialize"); + let (had_child_hash, tampered) = + forge_terminal_node_any_shape(&proof_bytes, b"pool", &fake_element_bytes); + let accepted = GroveDb::verify_query_raw(&tampered, &path_query, grove_version).is_ok(); + (had_child_hash, accepted) + }; + + // v0 — GROVE_V3, the released shape. Bare KVValueHash, forgery + // accepted. This is the hole; it cannot be closed in place because + // V3 is live. + let (v3_child_hash, v3_accepted) = build_and_forge(&GROVE_V3); + assert!( + !v3_child_hash, + "GROVE_V3 must keep emitting a bare KVValueHash for a terminal non-Merk tree" + ); + assert!( + v3_accepted, + "GROVE_V3 is expected to still accept the forgery — if this now fails, the fix \ + leaked into a released version and changes consensus behaviour" + ); + + // v1 — GROVE_V4, latest. Child-hash node, forgery rejected. + let (v4_child_hash, v4_accepted) = build_and_forge(GroveVersion::latest()); + assert!( + v4_child_hash, + "GROVE_V4 must emit KVValueHashFeatureTypeWithChildHash for a terminal non-Merk tree" + ); + assert!( + !v4_accepted, + "GROVE_V4 must reject a forged CommitmentTree total_count" + ); + } + + #[test] + fn terminal_commitment_tree_count_forgery_is_detected() { + let grove_version = GroveVersion::latest(); + let db = make_test_grovedb(grove_version); + + // A populated CommitmentTree under TEST_LEAF. + db.insert( + [TEST_LEAF].as_ref(), + b"pool", + Element::empty_commitment_tree(10).expect("valid chunk_power"), + None, + None, + grove_version, + ) + .unwrap() + .expect("insert commitment tree"); + + for i in 1..=3u8 { + let mut cmx = [0u8; 32]; + cmx[0] = i; + cmx[31] &= 0x7f; + let mut rho = [0u8; 32]; + rho[0] = i; + rho[1] = 0xAA; + let mut cv_net = [0u8; 32]; + cv_net[0] = i; + cv_net[1] = 0xCC; + + let mut epk_bytes = [0u8; 32]; + epk_bytes[0] = i; + let mut enc_data = [0u8; 104]; + enc_data[0] = i; + let mut out_ciphertext = [0u8; 80]; + out_ciphertext[0] = i; + let ciphertext = grovedb_commitment_tree::TransmittedNoteCiphertext::< + grovedb_commitment_tree::DashMemo, + >::from_parts( + epk_bytes, + grovedb_commitment_tree::NoteBytesData(enc_data), + out_ciphertext, + ); + + db.commitment_tree_insert( + [TEST_LEAF].as_ref(), + b"pool", + cmx, + rho, + cv_net, + ciphertext, + None, + grove_version, + ) + .unwrap() + .expect("append note"); + } + + // Query the tree element itself — no subquery, so the prover reports + // it terminally with no lower layer. + let mut query = Query::new(); + query.insert_key(b"pool".to_vec()); + let path_query = PathQuery::new_unsized(vec![TEST_LEAF.to_vec()], query); + + let proof_bytes = db + .prove_query(&path_query, None, grove_version) + .unwrap() + .expect("prove"); + + let (_, results) = + GroveDb::verify_query_raw(&proof_bytes, &path_query, grove_version).expect("verify"); + let real_element_bytes = results[0].value.clone(); + let element = Element::deserialize(&real_element_bytes, grove_version).expect("deser"); + let (chunk_power, flags) = match &element { + Element::CommitmentTree(total_count, chunk_power, flags) => { + assert_eq!(*total_count, 3, "honest proof should report 3 notes"); + (*chunk_power, flags.clone()) + } + other => panic!("expected CommitmentTree, got {:?}", other), + }; + + // Forge the note count while keeping the genuine value_hash. This is + // the denominator a shielded-balance client reads, so an inflated or + // deflated count under a real root hash is directly exploitable. + let fake_element_bytes = Element::CommitmentTree(999, chunk_power, flags) + .serialize(grove_version) + .expect("serialize"); + + for forgery in [ + TerminalForgery::KeepChildHash, + TerminalForgery::DowngradeToKvValueHash, + ] { + let tampered_proof_bytes = + forge_terminal_tree_element(&proof_bytes, b"pool", &fake_element_bytes, forgery); + + let tampered_result = + GroveDb::verify_query_raw(&tampered_proof_bytes, &path_query, grove_version); + assert!( + tampered_result.is_err(), + "forged CommitmentTree total_count must be rejected, but verification accepted \ + it: {:?}", + tampered_result.map(|(_, r)| r + .iter() + .map(|p| Element::deserialize(&p.value, grove_version)) + .collect::>()) + ); + } + + // The honest proof still verifies and still reports the real count. + let (_, results) = + GroveDb::verify_query_raw(&proof_bytes, &path_query, grove_version).expect("verify"); + assert_eq!(results[0].value, real_element_bytes); + } + + #[test] + fn terminal_mmr_tree_size_forgery_is_detected() { + let grove_version = GroveVersion::latest(); + let db = make_test_grovedb(grove_version); + + db.insert( + [TEST_LEAF].as_ref(), + b"mmr", + Element::empty_mmr_tree(), + None, + None, + grove_version, + ) + .unwrap() + .expect("insert mmr tree"); + + for i in 0..3u8 { + db.mmr_tree_append( + [TEST_LEAF].as_ref(), + b"mmr", + vec![i; 8], + None, + grove_version, + ) + .unwrap() + .expect("append leaf"); + } + + let mut query = Query::new(); + query.insert_key(b"mmr".to_vec()); + let path_query = PathQuery::new_unsized(vec![TEST_LEAF.to_vec()], query); + + let proof_bytes = db + .prove_query(&path_query, None, grove_version) + .unwrap() + .expect("prove"); + + let (_, results) = + GroveDb::verify_query_raw(&proof_bytes, &path_query, grove_version).expect("verify"); + let real_element_bytes = results[0].value.clone(); + let element = Element::deserialize(&real_element_bytes, grove_version).expect("deser"); + let flags = match &element { + Element::MmrTree(mmr_size, flags) => { + assert!(*mmr_size > 0, "honest proof should report a populated MMR"); + flags.clone() + } + other => panic!("expected MmrTree, got {:?}", other), + }; + + let fake_element_bytes = Element::MmrTree(999, flags) + .serialize(grove_version) + .expect("serialize"); + + for forgery in [ + TerminalForgery::KeepChildHash, + TerminalForgery::DowngradeToKvValueHash, + ] { + let tampered_proof_bytes = + forge_terminal_tree_element(&proof_bytes, b"mmr", &fake_element_bytes, forgery); + assert!( + GroveDb::verify_query_raw(&tampered_proof_bytes, &path_query, grove_version) + .is_err(), + "forged MmrTree size must be rejected" + ); + } + } + + #[test] + fn terminal_bulk_append_and_dense_tree_forgeries_are_detected() { + let grove_version = GroveVersion::latest(); + let db = make_test_grovedb(grove_version); + + db.insert( + [TEST_LEAF].as_ref(), + b"bulk", + Element::empty_bulk_append_tree(4).expect("valid chunk_power"), + None, + None, + grove_version, + ) + .unwrap() + .expect("insert bulk append tree"); + db.insert( + [TEST_LEAF].as_ref(), + b"dense", + Element::empty_dense_tree(4), + None, + None, + grove_version, + ) + .unwrap() + .expect("insert dense tree"); + + for i in 0..3u8 { + db.bulk_append( + [TEST_LEAF].as_ref(), + b"bulk", + vec![i; 8], + None, + grove_version, + ) + .unwrap() + .expect("bulk append"); + db.dense_tree_insert( + [TEST_LEAF].as_ref(), + b"dense", + vec![i; 8], + None, + grove_version, + ) + .unwrap() + .expect("dense insert"); + } + + for (key, forge) in [ + ( + b"bulk".as_slice(), + &(|e: &Element| match e { + Element::BulkAppendTree(_, chunk_power, flags) => { + Element::BulkAppendTree(999, *chunk_power, flags.clone()) + } + other => panic!("expected BulkAppendTree, got {:?}", other), + }) as &dyn Fn(&Element) -> Element, + ), + ( + b"dense".as_slice(), + &(|e: &Element| match e { + Element::DenseAppendOnlyFixedSizeTree(_, height, flags) => { + Element::DenseAppendOnlyFixedSizeTree(9, *height, flags.clone()) + } + other => panic!("expected DenseAppendOnlyFixedSizeTree, got {:?}", other), + }) as &dyn Fn(&Element) -> Element, + ), + ] { + let mut query = Query::new(); + query.insert_key(key.to_vec()); + let path_query = PathQuery::new_unsized(vec![TEST_LEAF.to_vec()], query); + + let proof_bytes = db + .prove_query(&path_query, None, grove_version) + .unwrap() + .expect("prove"); + let (_, results) = GroveDb::verify_query_raw(&proof_bytes, &path_query, grove_version) + .expect("honest proof should verify"); + let element = Element::deserialize(&results[0].value, grove_version).expect("deser"); + let fake_element_bytes = forge(&element).serialize(grove_version).expect("serialize"); + + for forgery in [ + TerminalForgery::KeepChildHash, + TerminalForgery::DowngradeToKvValueHash, + ] { + let tampered_proof_bytes = + forge_terminal_tree_element(&proof_bytes, key, &fake_element_bytes, forgery); + assert!( + GroveDb::verify_query_raw(&tampered_proof_bytes, &path_query, grove_version) + .is_err(), + "forged count for {} must be rejected", + String::from_utf8_lossy(key) + ); + } + } + } + + #[test] + fn empty_non_merk_trees_still_prove_and_verify() { + // The child-hash binding applies to empty non-Merk trees too, whose + // committed child hash is NULL_HASH (or + // EMPTY_COMMITMENT_TREE_STATE_ROOT for a CommitmentTree). These honest + // proofs must keep verifying. + let grove_version = GroveVersion::latest(); + let db = make_test_grovedb(grove_version); + + for (key, element) in [ + ( + b"ct".as_slice(), + Element::empty_commitment_tree(10).expect("valid chunk_power"), + ), + (b"mmr".as_slice(), Element::empty_mmr_tree()), + ( + b"bulk".as_slice(), + Element::empty_bulk_append_tree(4).expect("valid chunk_power"), + ), + (b"dense".as_slice(), Element::empty_dense_tree(4)), + ] { + db.insert( + [TEST_LEAF].as_ref(), + key, + element.clone(), + None, + None, + grove_version, + ) + .unwrap() + .expect("insert empty non-merk tree"); + + let mut query = Query::new(); + query.insert_key(key.to_vec()); + let path_query = PathQuery::new_unsized(vec![TEST_LEAF.to_vec()], query); + + let proof_bytes = db + .prove_query(&path_query, None, grove_version) + .unwrap() + .expect("prove"); + let (_, results) = GroveDb::verify_query_raw(&proof_bytes, &path_query, grove_version) + .unwrap_or_else(|e| { + panic!( + "empty {} proof should verify, got {:?}", + String::from_utf8_lossy(key), + e + ) + }); + assert_eq!( + results[0].value, + element.serialize(grove_version).expect("serialize"), + "empty {} should round-trip", + String::from_utf8_lossy(key) + ); + + // And a forgery on the empty tree is still caught. + let fake_element_bytes = Element::empty_mmr_tree() + .serialize(grove_version) + .expect("serialize"); + if fake_element_bytes != results[0].value { + let tampered = forge_terminal_tree_element( + &proof_bytes, + key, + &fake_element_bytes, + TerminalForgery::KeepChildHash, + ); + assert!( + GroveDb::verify_query_raw(&tampered, &path_query, grove_version).is_err(), + "type swap on empty {} must be rejected", + String::from_utf8_lossy(key) + ); + } + } + } } diff --git a/grovedb/src/tests/succinctness_gap_test.rs b/grovedb/src/tests/succinctness_gap_test.rs index ff48598d4..3db5e03d1 100644 --- a/grovedb/src/tests/succinctness_gap_test.rs +++ b/grovedb/src/tests/succinctness_gap_test.rs @@ -10,7 +10,7 @@ use grovedb_version::version::{v1::GROVE_V1, GroveVersion}; use crate::{ - operations::proof::GroveDBProof, + operations::proof::{GroveDBProof, LayerProof, ProofBytes}, tests::{make_deep_tree, TEST_LEAF}, GroveDb, PathQuery, Query, }; @@ -262,3 +262,168 @@ fn test_missing_lower_layer_for_non_empty_tree_is_rejected_v0() { "V0: verify_subset_query must reject proof missing a non-empty subtree's lower layer" ); } + +/// Regression: a subset verification whose query stops at a tree ELEMENT must +/// still verify against a proof that descended INTO that tree. +/// +/// This is the ordinary shape of `verify_subset_query`: a wide proof is +/// generated once, then re-verified against a narrower query. Dash Platform +/// reads a shielded `CommitmentTree`'s total note count exactly this way — +/// single-key query, no subquery, against the note-fetch proof it already +/// holds. +/// +/// The tree element is the only result, and it must come back bound: the +/// verifier derives the lower layer's root and checks +/// `combine_hash(H(value), child_root)` against the parent-committed value +/// hash, without reporting any of the lower layer's rows. +#[test] +fn test_subset_query_for_tree_element_itself_against_descending_proof() { + let grove_version = GroveVersion::latest(); + let db = make_deep_tree(grove_version); + let expected_root = db.root_hash(None, grove_version).unwrap().unwrap(); + + // Wide proof: descends into innertree, so the proof carries a lower layer + // at that key. + let mut inner = Query::new(); + inner.insert_all(); + let mut outer = Query::new(); + outer.insert_key(b"innertree".to_vec()); + outer.set_subquery(inner); + let broad_query = PathQuery::new_unsized(vec![TEST_LEAF.to_vec()], outer); + + let proof_bytes = db + .prove_query(&broad_query, None, grove_version) + .unwrap() + .expect("should generate descending proof"); + + // Narrow query: the innertree element itself, no subquery. + let narrow_query = PathQuery::new_single_key(vec![TEST_LEAF.to_vec()], b"innertree".to_vec()); + + let (subset_root, subset_results) = + GroveDb::verify_subset_query(&proof_bytes, &narrow_query, grove_version) + .expect("subset verification must accept a query that stops at the tree element"); + + assert_eq!(subset_root, expected_root); + assert_eq!( + subset_results.len(), + 1, + "only the tree element itself is a result; the lower layer's rows are not reported" + ); + let (path, key, element) = &subset_results[0]; + assert_eq!( + path, + &vec![TEST_LEAF.to_vec()], + "the tree must be reported under its PARENT path, so (path, key) lookups hit" + ); + assert_eq!(key, b"innertree"); + assert!( + element + .as_ref() + .expect("element present") + .is_non_empty_merk_tree(), + "the reported element must be the innertree subtree itself" + ); + + // Succinct mode still refuses: for this narrow query the lower layer is + // data the query never asked for. + assert!( + GroveDb::verify_query(&proof_bytes, &narrow_query, grove_version).is_err(), + "verify_query must still reject a proof carrying an unrequested lower layer" + ); +} + +/// The element bytes reported by the subset path above stay BOUND. +/// +/// A `KVValueHash`-family node hashes only `(key, value_hash)`, so a prover +/// that could get a tree element reported without any child-hash check could +/// swap in forged element bytes under a genuine root hash. Two tampers prove +/// the binding is load-bearing rather than incidental: +/// +/// 1. a dummy lower layer attached where none belongs, and +/// 2. a real-but-wrong lower layer (a sibling subtree's proof). +/// +/// Both must be rejected even though succinctness checking is OFF. +#[test] +fn test_subset_mode_still_binds_element_bytes_to_lower_layer() { + let grove_version = GroveVersion::latest(); + let db = make_deep_tree(grove_version); + let config = bincode::config::standard() + .with_big_endian() + .with_no_limit(); + + // Wide proof descending into BOTH innertree and innertree4, so the two + // sibling lower layers are available to swap. + let mut inner = Query::new(); + inner.insert_all(); + let mut outer = Query::new(); + outer.insert_all(); + outer.set_subquery(inner); + let broad_query = PathQuery::new_unsized(vec![TEST_LEAF.to_vec()], outer); + let proof_bytes = db + .prove_query(&broad_query, None, grove_version) + .unwrap() + .expect("should generate descending proof"); + + let narrow_query = PathQuery::new_single_key(vec![TEST_LEAF.to_vec()], b"innertree".to_vec()); + + // Sanity: untampered, the narrow subset query verifies. + GroveDb::verify_subset_query(&proof_bytes, &narrow_query, grove_version) + .expect("honest proof must verify with the narrow query"); + + let decode = |bytes: &[u8]| -> GroveDBProof { + bincode::decode_from_slice(bytes, config) + .expect("should decode proof") + .0 + }; + let test_leaf_key = TEST_LEAF.to_vec(); + let innertree_key = b"innertree".to_vec(); + let innertree4_key = b"innertree4".to_vec(); + + // These proofs are V1 envelopes; only V1 carries the typed lower-layer + // proof bytes this test tampers with. + fn root_layer_mut(proof: &mut GroveDBProof) -> &mut LayerProof { + match proof { + GroveDBProof::V1(v1) => &mut v1.root_layer, + GroveDBProof::V0(_) => panic!("expected a V1 proof envelope"), + } + } + + // Tamper 1: replace innertree's lower layer with an empty dummy. + let mut dummy = decode(&proof_bytes); + root_layer_mut(&mut dummy) + .lower_layers + .get_mut(&test_leaf_key) + .expect("TEST_LEAF layer") + .lower_layers + .insert( + innertree_key.clone(), + LayerProof { + merk_proof: ProofBytes::Merk(Vec::new()), + lower_layers: Default::default(), + }, + ); + let dummy_bytes = bincode::encode_to_vec(&dummy, config).expect("re-encode"); + assert!( + GroveDb::verify_subset_query(&dummy_bytes, &narrow_query, grove_version).is_err(), + "a dummy lower layer must not let unbound element bytes through in subset mode" + ); + + // Tamper 2: give innertree its SIBLING's lower layer — a structurally + // valid Merk proof that commits to a different root. + let mut swapped = decode(&proof_bytes); + let leaf_layers = &mut root_layer_mut(&mut swapped) + .lower_layers + .get_mut(&test_leaf_key) + .expect("TEST_LEAF layer") + .lower_layers; + let sibling = leaf_layers + .get(&innertree4_key) + .expect("innertree4 lower layer") + .clone(); + leaf_layers.insert(innertree_key, sibling); + let swapped_bytes = bincode::encode_to_vec(&swapped, config).expect("re-encode"); + assert!( + GroveDb::verify_subset_query(&swapped_bytes, &narrow_query, grove_version).is_err(), + "the reported element must be bound to ITS OWN child root, not any valid subtree proof" + ); +}