From 99ff95ffef95967b23daf22f4c3db4963f956697 Mon Sep 17 00:00:00 2001 From: Yonatan Iluz Date: Mon, 8 Jun 2026 17:07:17 +0300 Subject: [PATCH] starknet_transaction_prover,starknet_patricia: add compute_missing_sibling_keys helper When storage leaves are deleted the Patricia trie collapses: a binary node left with exactly one empty child becomes an edge to its surviving child, and canonicalizing that edge needs the surviving child's preimage (e.g. to merge edge paths). A plain get_storage_proof carries that sibling only as an orphan hash, so compute_missing_sibling_keys returns crafted storage keys that, on a follow-up get_storage_proof, expose exactly those preimages. 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. A recursive walk over the proof computes, for each subtree, whether it becomes empty, and records the surviving sibling at every binary node where exactly one side empties (mirroring the committer's node_from_binary_data/node_from_edge_data). It fetches nothing for no-op deletes (key absent / empty trie) and errors if the proof doesn't reach a deleted leaf (a deleted key is always read, so its full path must be present). The walk is expressed over NodeIndex; get_children_indices, compute_bottom_index and leading_zeros are made pub and a non-panicking is_descendant_of is added to starknet_patricia. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/patricia_merkle_tree/types.rs | 18 +- .../src/running/storage_proofs.rs | 185 +++++++++- .../src/running/storage_proofs_test.rs | 318 ++++++++++++++++++ 3 files changed, 513 insertions(+), 8 deletions(-) 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()); +}