diff --git a/crates/starknet_patricia/src/patricia_merkle_tree/types.rs b/crates/starknet_patricia/src/patricia_merkle_tree/types.rs index 1eca9b5c26a..882030d8f66 100644 --- a/crates/starknet_patricia/src/patricia_merkle_tree/types.rs +++ b/crates/starknet_patricia/src/patricia_merkle_tree/types.rs @@ -68,21 +68,27 @@ impl NodeIndex { } // TODO(Amos, 1/5/2024): Move to EdgePath. - pub(crate) fn compute_bottom_index( - index: NodeIndex, - path_to_bottom: &PathToBottom, - ) -> NodeIndex { + pub fn compute_bottom_index(index: NodeIndex, path_to_bottom: &PathToBottom) -> NodeIndex { let PathToBottom { path, length, .. } = path_to_bottom; (index << u8::from(*length)) + Self::new(path.into()) } - pub(crate) fn get_children_indices(&self) -> [Self; 2] { + pub fn get_children_indices(&self) -> [Self; 2] { let left_child = *self << 1; [left_child, left_child + 1] } + /// Returns `true` if `self` lies in the subtree rooted at `ancestor`, where a node is + /// considered a descendant of itself (`self == ancestor` returns `true`). + pub fn is_descendant_of(&self, ancestor: &NodeIndex) -> bool { + let self_bit_length = self.bit_length(); + let ancestor_bit_length = ancestor.bit_length(); + self_bit_length >= ancestor_bit_length + && (self.0 >> u32::from(self_bit_length - ancestor_bit_length)) == ancestor.0 + } + /// Returns the number of leading zeroes when represented with Self::BITS bits. - pub(crate) fn leading_zeros(&self) -> u8 { + pub fn leading_zeros(&self) -> u8 { (self.0.leading_zeros() - (U256::BITS - u32::from(Self::BITS))) .try_into() .expect("Leading zeroes are unexpectedly larger than a u8.") diff --git a/crates/starknet_transaction_prover/src/running/storage_proofs.rs b/crates/starknet_transaction_prover/src/running/storage_proofs.rs index 7ece7233fdd..4e6929720b6 100644 --- a/crates/starknet_transaction_prover/src/running/storage_proofs.rs +++ b/crates/starknet_transaction_prover/src/running/storage_proofs.rs @@ -1,4 +1,5 @@ -use std::collections::HashMap; +use std::collections::hash_map::RandomState; +use std::collections::{BTreeMap, BTreeSet, HashMap}; use async_trait::async_trait; use blockifier::state::cached_state::StateMaps; @@ -13,7 +14,7 @@ use starknet_patricia::patricia_merkle_tree::node_data::inner_node::{ Preimage, PreimageMap, }; -use starknet_patricia::patricia_merkle_tree::types::SubTreeHeight; +use starknet_patricia::patricia_merkle_tree::types::{NodeIndex, SubTreeHeight}; use starknet_patricia_storage::map_storage::MapStorage; use starknet_rust::providers::jsonrpc::HttpTransport; use starknet_rust::providers::{JsonRpcClient, Provider}; @@ -149,6 +150,186 @@ pub(crate) fn merge_storage_proofs( RpcStorageProof { classes_proof, contracts_proof, contracts_storage_proofs, global_roots } } +/// For each contract with storage deletes in `state_diff`, walks the contract's storage-trie proof +/// and returns crafted keys that — when queried in a follow-up `get_storage_proof` — force the RPC +/// to expose the preimages of the sibling subtrees the committer needs to canonicalize the +/// post-deletion tree. +/// +/// Deleting a leaf empties its subtree; when a binary node ends up with exactly one empty child it +/// collapses into an edge pointing at the surviving child, and to canonicalize that edge the +/// committer needs the surviving child's preimage (e.g. to merge edge paths via +/// `PathToBottom::concat_paths`). A plain `get_storage_proof` only carries that sibling as an +/// orphan hash, so we craft a key routing into its subtree. Deletes are handled per contract as a +/// *set*, because they interact: deleting both children of a node empties it and pushes the +/// collapse up to its parent, whose surviving child is the one that must be fetched. +/// +/// Returns an empty vec when no extra preimages are needed (no deletes, all required siblings +/// already present or leaf-level, the trie is empty, or the deleted keys aren't in the trie). +#[allow(dead_code)] // Wired into get_storage_proofs in a follow-up PR. +pub(crate) fn compute_missing_sibling_keys( + rpc_proof: &RpcStorageProof, + query: &RpcStorageProofsQuery, + state_diff: &StateMaps, +) -> Result, ProofProviderError> { + // `query.contract_addresses`, `rpc_proof.contracts_proof.contract_leaves_data`, and + // `rpc_proof.contracts_storage_proofs` are built together by `prepare_query` and share index + // order, so a single address->index map indexes all three. + let address_to_index: HashMap<&ContractAddress, usize> = + query.contract_addresses.iter().enumerate().map(|(index, addr)| (addr, index)).collect(); + + // Group deleted storage keys (writes of zero) by contract, as leaf indices in its storage trie. + let mut deleted_leaves_by_address: BTreeMap> = BTreeMap::new(); + for ((addr, key), value) in &state_diff.storage { + if *value == Felt::ZERO { + deleted_leaves_by_address + .entry(*addr) + .or_default() + .push(NodeIndex::from_leaf_felt(key.0.key())); + } + } + + let mut result = Vec::new(); + for (addr, deleted_leaves) in deleted_leaves_by_address { + let Some(&idx) = address_to_index.get(&addr) else { continue }; + let leaf = &rpc_proof.contracts_proof.contract_leaves_data[idx]; + let nodes = &rpc_proof.contracts_storage_proofs[idx]; + // No `storage_root` means an empty storage trie (mirrors `build_storage_commitment_infos`). + // Deleting from an empty trie is a no-op, so there is no sibling to fetch. + let Some(root) = leaf.storage_root else { continue }; + + let mut crafted_keys = BTreeSet::new(); + subtree_empties(NodeIndex::ROOT, root, &deleted_leaves, nodes, &mut crafted_keys)?; + if !crafted_keys.is_empty() { + result.push(ContractStorageKeys { + contract_address: *addr.0.key(), + storage_keys: crafted_keys.into_iter().collect(), + }); + } + } + + Ok(result) +} + +/// Recursively determines whether the subtree rooted at (`index`, `hash`) becomes empty once the +/// leaves in `deleted` (the deleted leaf indices that fall in this subtree) are removed, and as a +/// side effect records a crafted key into `crafted_keys` for every collapse sibling whose preimage +/// the committer will need. +/// +/// A binary node collapses to its surviving child exactly when one side empties; the committer then +/// needs that child's preimage, but only when it is an unmodified orphan (a modified child's nodes +/// are already in the proof, and a leaf-level child is merged by hash). The recursion mirrors the +/// committer's own `node_from_binary_data`/`node_from_edge_data` collapse handling. +/// +/// Returns an error if a node on a deleted key's path is missing from the proof: a deleted key is +/// always read (hence queried), so a complete proof must contain its full path. +fn subtree_empties( + index: NodeIndex, + hash: Felt, + deleted: &[NodeIndex], + proof_nodes: &IndexMap, + crafted_keys: &mut BTreeSet, +) -> Result { + // An unmodified subtree keeps all its leaves, so it stays non-empty. + if deleted.is_empty() { + return Ok(false); + } + // A leaf reached with a delete on it (`deleted` can only hold this very index here) is removed. + if index.is_leaf() { + return Ok(true); + } + let Some(node) = proof_nodes.get(&hash) else { + return Err(ProofProviderError::InvalidProofResponse(format!( + "storage proof is missing inner node {hash:#x} on a deleted key's path" + ))); + }; + match node { + MerkleNode::BinaryNode(bn) => { + let [left_index, right_index] = index.get_children_indices(); + let left_deleted: Vec = + deleted.iter().copied().filter(|leaf| leaf.is_descendant_of(&left_index)).collect(); + let right_deleted: Vec = deleted + .iter() + .copied() + .filter(|leaf| leaf.is_descendant_of(&right_index)) + .collect(); + let left_empties = + subtree_empties(left_index, bn.left, &left_deleted, proof_nodes, crafted_keys)?; + let right_empties = + subtree_empties(right_index, bn.right, &right_deleted, proof_nodes, crafted_keys)?; + // Collapse to the surviving child when exactly one side empties. Only an unmodified + // surviving child (no deletes of its own) is an orphan that needs fetching. + match (left_empties, right_empties) { + (true, false) if right_deleted.is_empty() => { + record_collapse_sibling(right_index, bn.right, proof_nodes, crafted_keys)?; + } + (false, true) if left_deleted.is_empty() => { + record_collapse_sibling(left_index, bn.left, proof_nodes, crafted_keys)?; + } + _ => {} + } + Ok(left_empties && right_empties) + } + MerkleNode::EdgeNode(_) => { + // Reuse the crate's existing RPC-node -> Patricia conversion for the edge's + // path/bottom. + let Preimage::Edge(edge) = Preimage::from(node) else { + unreachable!("node matched MerkleNode::EdgeNode") + }; + // An edge can't extend past the leaf level in a valid trie; reject malformed proofs so + // `compute_bottom_index`'s shift can't overflow `NodeIndex::MAX`. + let storage_tree_height = usize::from(SubTreeHeight::ACTUAL_HEIGHT.0); + let depth = storage_tree_height - usize::from(index.leading_zeros()); + let edge_len = usize::from(u8::from(edge.path_to_bottom.length)); + if depth + edge_len > storage_tree_height { + return Err(ProofProviderError::InvalidProofResponse(format!( + "edge node {hash:#x} of length {edge_len} at depth {depth} extends past the \ + leaf level (tree height {storage_tree_height})" + ))); + } + let bottom_index = NodeIndex::compute_bottom_index(index, &edge.path_to_bottom); + // Deletes that don't descend through the edge target keys absent from the trie (no-op + // deletes); they leave this subtree unchanged. + let descending: Vec = deleted + .iter() + .copied() + .filter(|leaf| leaf.is_descendant_of(&bottom_index)) + .collect(); + if descending.is_empty() { + return Ok(false); + } + subtree_empties( + bottom_index, + edge.bottom_data.0, + &descending, + proof_nodes, + crafted_keys, + ) + } + } +} + +/// Records a crafted key that routes into the collapse sibling's subtree, so a follow-up +/// `get_storage_proof` exposes its preimage. Skips leaf-level siblings (merged by hash) and +/// siblings already present in the proof. +fn record_collapse_sibling( + sibling_index: NodeIndex, + sibling_hash: Felt, + proof_nodes: &IndexMap, + crafted_keys: &mut BTreeSet, +) -> Result<(), ProofProviderError> { + if sibling_index.is_leaf() || proof_nodes.contains_key(&sibling_hash) { + return Ok(()); + } + // Any leaf under the sibling exposes its preimage on a follow-up query; take the left-most one + // (shift the sibling index down to the leaf layer) and strip the leading `FIRST_LEAF` bit. + let crafted_leaf_index = sibling_index << sibling_index.leading_zeros(); + let crafted_key = Felt::try_from(crafted_leaf_index - NodeIndex::FIRST_LEAF).map_err(|e| { + ProofProviderError::InvalidProofResponse(format!("crafted sibling key out of range: {e}")) + })?; + crafted_keys.insert(crafted_key); + Ok(()) +} + /// Configuration for storage proof provider behavior. #[derive(Clone, Serialize, Deserialize, Debug)] pub struct StorageProofConfig { diff --git a/crates/starknet_transaction_prover/src/running/storage_proofs_test.rs b/crates/starknet_transaction_prover/src/running/storage_proofs_test.rs index c942405cc19..56a099b1b88 100644 --- a/crates/starknet_transaction_prover/src/running/storage_proofs_test.rs +++ b/crates/starknet_transaction_prover/src/running/storage_proofs_test.rs @@ -2,6 +2,7 @@ use std::collections::HashSet; use blockifier::context::BlockContext; use blockifier::state::cached_state::StateMaps; +use indexmap::IndexMap; use rstest::rstest; use starknet_api::block::{BlockHash, BlockNumber}; use starknet_api::block_hash::block_hash_calculator::BlockHeaderCommitments; @@ -13,6 +14,7 @@ use starknet_rust_core::types::{ ContractLeafData, ContractStorageKeys, ContractsProof, + EdgeNode, GlobalRoots, MerkleNode, StorageProof as RpcStorageProof, @@ -21,6 +23,7 @@ use starknet_types_core::felt::Felt; use crate::running::storage_proofs::{ collect_query, + compute_missing_sibling_keys, count_total_keys, flatten_query, merge_storage_proofs, @@ -33,6 +36,41 @@ use crate::running::storage_proofs::{ use crate::running::virtual_block_executor::{BaseBlockInfo, VirtualBlockExecutionData}; use crate::test_utils::{rpc_provider, STRK_TOKEN_ADDRESS}; +/// Builds an `RpcStorageProof` + `RpcStorageProofsQuery` for a single contract whose storage +/// trie's nodes are exactly `nodes`. `storage_root` is the trie root hash. +fn single_contract_proof( + addr: ContractAddress, + storage_root: Felt, + nodes: IndexMap, +) -> (RpcStorageProof, RpcStorageProofsQuery) { + let query = RpcStorageProofsQuery { + class_hashes: vec![], + contract_addresses: vec![addr], + contract_storage_keys: vec![ContractStorageKeys { + contract_address: *addr.0.key(), + storage_keys: vec![], + }], + }; + let proof = RpcStorageProof { + classes_proof: IndexMap::default(), + contracts_proof: ContractsProof { + nodes: IndexMap::default(), + contract_leaves_data: vec![ContractLeafData { + nonce: Felt::ZERO, + class_hash: Felt::ZERO, + storage_root: Some(storage_root), + }], + }, + contracts_storage_proofs: vec![nodes], + global_roots: GlobalRoots { + contracts_tree_root: Felt::ZERO, + classes_tree_root: Felt::ZERO, + block_hash: Felt::ZERO, + }, + }; + (proof, query) +} + /// Fixture: Creates initial reads with the STRK contract and storage slot 0. #[rstest::fixture] fn initial_reads() -> (StateMaps, ContractAddress, StorageKey) { @@ -266,3 +304,283 @@ fn test_split_merge_roundtrip() { // Tight limit forces many small chunks. assert_split_merge_identity(&make_query(3, 4, &[8, 6, 3]), 3); } + +/// Storage trie shape under test: +/// +/// ```text +/// A (edge, path=000, length=3) +/// | +/// v +/// B (binary) +/// / \ +/// C D <- D = orphan: hash present in B, preimage absent from the proof +/// | +/// | (edge, length=247) +/// v +/// leaf +/// ``` +/// +/// Deleting the leaf under C (storage key `0`) forces B to collapse into an edge +/// rooted at D, which requires D's preimage. The helper must therefore emit a +/// crafted key that routes through D's subtree on a follow-up `get_storage_proof`. +#[test] +fn test_compute_missing_sibling_keys_orphan_on_delete_path() { + let addr = ContractAddress::try_from(Felt::from(100u64)).unwrap(); + let (a, b, c, d_orphan, leaf) = + (Felt::from(1u64), Felt::from(2u64), Felt::from(3u64), Felt::from(4u64), Felt::from(5u64)); + + let mut nodes = IndexMap::default(); + nodes.insert(a, MerkleNode::EdgeNode(EdgeNode { path: Felt::ZERO, length: 3, child: b })); + nodes.insert(b, MerkleNode::BinaryNode(BinaryNode { left: c, right: d_orphan })); + nodes.insert(c, MerkleNode::EdgeNode(EdgeNode { path: Felt::ZERO, length: 247, child: leaf })); + + let storage_root = a; + let (proof, query) = single_contract_proof(addr, storage_root, nodes); + + // Deleting the leftmost key (storage key `0`) routes through C; D becomes the orphan. + let mut state_diff = StateMaps::default(); + state_diff.storage.insert((addr, StorageKey::from(0u32)), Felt::ZERO); + + let result = compute_missing_sibling_keys(&proof, &query, &state_diff).unwrap(); + + // D sits at trie depth 4 (3 from A's edge + 1 from B's binary-right). The crafted key + // must route there: top bits = "0001", everything else zero. Bit (251 - 4) = 247. + let expected_crafted_key = Felt::TWO.pow(247_u32); + assert_eq!( + result, + vec![ContractStorageKeys { + contract_address: *addr.0.key(), + storage_keys: vec![expected_crafted_key], + }] + ); +} + +/// Storage trie shape under test (two binary nodes on the delete path): +/// +/// ```text +/// R (binary) +/// / \ +/// B2 S1 <- S1 = orphan (no preimage), but R only rehashes on delete +/// / \ +/// C S2 <- S2 = orphan (no preimage); B2 is the node that collapses +/// | +/// | (edge, length=249) +/// v +/// leaf +/// ``` +/// +/// Deleting the leaf (storage key `0`) collapses only `B2` — the deepest binary node — into an +/// edge rooted at `S2`, so only `S2`'s preimage is needed. `R` keeps a non-empty on-path child +/// (the collapsed edge) and is merely rehashed using `S1`'s hash, which the committer already has. +/// The helper must therefore emit exactly one crafted key (for `S2`) and must NOT fetch `S1`. +#[test] +fn test_compute_missing_sibling_keys_fetches_only_deepest_binary_sibling() { + let addr = ContractAddress::try_from(Felt::from(100u64)).unwrap(); + let (r, b2, s1_orphan, c, s2_orphan, leaf) = ( + Felt::from(1u64), + Felt::from(2u64), + Felt::from(10u64), + Felt::from(3u64), + Felt::from(20u64), + Felt::from(5u64), + ); + + let mut nodes = IndexMap::default(); + nodes.insert(r, MerkleNode::BinaryNode(BinaryNode { left: b2, right: s1_orphan })); + nodes.insert(b2, MerkleNode::BinaryNode(BinaryNode { left: c, right: s2_orphan })); + nodes.insert(c, MerkleNode::EdgeNode(EdgeNode { path: Felt::ZERO, length: 249, child: leaf })); + + let (proof, query) = single_contract_proof(addr, r, nodes); + + let mut state_diff = StateMaps::default(); + state_diff.storage.insert((addr, StorageKey::from(0u32)), Felt::ZERO); + + let result = compute_missing_sibling_keys(&proof, &query, &state_diff).unwrap(); + + // S2 sits at trie depth 2 (R at depth 0, B2 at depth 1). The crafted key routes right at B2: + // bit (251 - 2) = 249. S1 (the depth-0 sibling, key 2^250) must be absent from the result. + let expected_crafted_key = Felt::TWO.pow(249_u32); + assert_eq!( + result, + vec![ContractStorageKeys { + contract_address: *addr.0.key(), + storage_keys: vec![expected_crafted_key], + }] + ); +} + +/// A zero-write to a key that isn't in the trie is a no-op: the path diverges at an edge whose +/// label doesn't match the key, so nothing collapses and no sibling preimage is needed — even +/// though a shallower binary node on the way carries an orphan sibling. +#[test] +fn test_compute_missing_sibling_keys_phantom_delete_returns_empty() { + let addr = ContractAddress::try_from(Felt::from(100u64)).unwrap(); + let (r, diverging_edge, s1_orphan, bottom) = + (Felt::from(1u64), Felt::from(2u64), Felt::from(10u64), Felt::from(5u64)); + + let mut nodes = IndexMap::default(); + // Deleting key `0` routes left at R (to the edge), leaving S1 as an orphan sibling. + nodes.insert(r, MerkleNode::BinaryNode(BinaryNode { left: diverging_edge, right: s1_orphan })); + // Edge label `0b11` diverges from key `0` at the very first step -> key not in trie. + nodes.insert( + diverging_edge, + MerkleNode::EdgeNode(EdgeNode { path: Felt::from(3u64), length: 2, child: bottom }), + ); + let (proof, query) = single_contract_proof(addr, r, nodes); + + let mut state_diff = StateMaps::default(); + state_diff.storage.insert((addr, StorageKey::from(0u32)), Felt::ZERO); + + assert!(compute_missing_sibling_keys(&proof, &query, &state_diff).unwrap().is_empty()); +} + +/// A trie that is a single edge to one leaf has no binary node, so deleting that leaf empties the +/// whole trie and collapses nothing — the helper must fetch nothing. +#[test] +fn test_compute_missing_sibling_keys_single_edge_trie_returns_empty() { + let addr = ContractAddress::try_from(Felt::from(100u64)).unwrap(); + let (root, leaf) = (Felt::from(1u64), Felt::from(5u64)); + + let mut nodes = IndexMap::default(); + // Root edge whose path matches storage key `0` (all-zero path) straight down to the leaf. + nodes.insert( + root, + MerkleNode::EdgeNode(EdgeNode { path: Felt::ZERO, length: 251, child: leaf }), + ); + let (proof, query) = single_contract_proof(addr, root, nodes); + + let mut state_diff = StateMaps::default(); + state_diff.storage.insert((addr, StorageKey::from(0u32)), Felt::ZERO); + + assert!(compute_missing_sibling_keys(&proof, &query, &state_diff).unwrap().is_empty()); +} + +/// Both leaves under binary `B` are deleted, so `B` empties entirely and the collapse cascades up +/// to the root, which then collapses to its other child `S_A`. The helper must fetch `S_A` (the +/// parent's surviving orphan sibling) and must NOT fetch `B`'s own children `E1`/`E2`. +/// +/// ```text +/// root (binary) +/// / \ +/// B (binary) S_A <- orphan: needed once the root collapses +/// / \ +/// E1 E2 +/// | | +/// L1 L2 <- both deleted +/// ``` +#[test] +fn test_compute_missing_sibling_keys_both_children_deleted_cascades_to_parent_sibling() { + let addr = ContractAddress::try_from(Felt::from(100u64)).unwrap(); + let (root, b, s_a_orphan, e1, e2, l1, l2) = ( + Felt::from(1u64), + Felt::from(2u64), + Felt::from(30u64), + Felt::from(4u64), + Felt::from(5u64), + Felt::from(100u64), + Felt::from(101u64), + ); + + let mut nodes = IndexMap::default(); + nodes.insert(root, MerkleNode::BinaryNode(BinaryNode { left: b, right: s_a_orphan })); + nodes.insert(b, MerkleNode::BinaryNode(BinaryNode { left: e1, right: e2 })); + nodes.insert(e1, MerkleNode::EdgeNode(EdgeNode { path: Felt::ZERO, length: 249, child: l1 })); + nodes.insert(e2, MerkleNode::EdgeNode(EdgeNode { path: Felt::ZERO, length: 249, child: l2 })); + let (proof, query) = single_contract_proof(addr, root, nodes); + + // L1 = key 0 (left at root, left at B); L2 = key 2^249 (left at root, right at B). + let mut state_diff = StateMaps::default(); + state_diff.storage.insert((addr, StorageKey::from(0u32)), Felt::ZERO); + state_diff + .storage + .insert((addr, StorageKey::try_from(Felt::TWO.pow(249u32)).unwrap()), Felt::ZERO); + + let result = compute_missing_sibling_keys(&proof, &query, &state_diff).unwrap(); + + // Only S_A (the root's right child at depth 1) is fetched: crafted key 2^250. B's children + // E1/E2 are not fetched. + assert_eq!( + result, + vec![ContractStorageKeys { + contract_address: *addr.0.key(), + storage_keys: vec![Felt::TWO.pow(250u32)], + }] + ); +} + +#[test] +fn test_compute_missing_sibling_keys_no_deletes_returns_empty() { + let addr = ContractAddress::try_from(Felt::from(100u64)).unwrap(); + let mut nodes = IndexMap::default(); + // An orphan exists in the tree (right child has no preimage) but no delete is requested. + nodes.insert( + Felt::from(1u64), + MerkleNode::BinaryNode(BinaryNode { left: Felt::from(2u64), right: Felt::from(3u64) }), + ); + let (proof, query) = single_contract_proof(addr, Felt::from(1u64), nodes); + + let mut state_diff = StateMaps::default(); + state_diff.storage.insert((addr, StorageKey::from(0u32)), Felt::from(42u64)); + + assert!(compute_missing_sibling_keys(&proof, &query, &state_diff).unwrap().is_empty()); +} + +/// A delete against a contract with an empty storage trie (`storage_root: None`) is a no-op: the +/// helper must return no crafted keys rather than erroring. +#[test] +fn test_compute_missing_sibling_keys_empty_storage_trie_returns_empty() { + let addr = ContractAddress::try_from(Felt::from(100u64)).unwrap(); + let query = RpcStorageProofsQuery { + class_hashes: vec![], + contract_addresses: vec![addr], + contract_storage_keys: vec![ContractStorageKeys { + contract_address: *addr.0.key(), + storage_keys: vec![], + }], + }; + let proof = RpcStorageProof { + classes_proof: IndexMap::default(), + contracts_proof: ContractsProof { + nodes: IndexMap::default(), + contract_leaves_data: vec![ContractLeafData { + nonce: Felt::ZERO, + class_hash: Felt::ZERO, + storage_root: None, + }], + }, + contracts_storage_proofs: vec![IndexMap::default()], + global_roots: GlobalRoots { + contracts_tree_root: Felt::ZERO, + classes_tree_root: Felt::ZERO, + block_hash: Felt::ZERO, + }, + }; + + let mut state_diff = StateMaps::default(); + state_diff.storage.insert((addr, StorageKey::from(0u32)), Felt::ZERO); + + assert!(compute_missing_sibling_keys(&proof, &query, &state_diff).unwrap().is_empty()); +} + +/// The proof is incomplete: the on-path child of the root binary node is missing, so the walk can't +/// reach the deleted leaf. A deleted key is always read (hence queried), so a complete proof must +/// contain its full path — a missing node is an RPC bug and must surface as an error. +#[test] +fn test_compute_missing_sibling_keys_incomplete_proof_errors() { + let addr = ContractAddress::try_from(Felt::from(100u64)).unwrap(); + let (root, missing_on_path_child, s1_orphan) = + (Felt::from(1u64), Felt::from(2u64), Felt::from(10u64)); + + let mut nodes = IndexMap::default(); + // Deleting key `0` routes left at the root, but the left child's preimage is absent. + nodes.insert( + root, + MerkleNode::BinaryNode(BinaryNode { left: missing_on_path_child, right: s1_orphan }), + ); + let (proof, query) = single_contract_proof(addr, root, nodes); + + let mut state_diff = StateMaps::default(); + state_diff.storage.insert((addr, StorageKey::from(0u32)), Felt::ZERO); + + assert!(compute_missing_sibling_keys(&proof, &query, &state_diff).is_err()); +}