starknet_transaction_prover,starknet_patricia: add compute_missing_sibling_keys helper#14420
starknet_transaction_prover,starknet_patricia: add compute_missing_sibling_keys helper#14420Yoni-Starkware wants to merge 1 commit into
Conversation
PR SummaryMedium Risk Overview
Tests cover orphan siblings, deepest-only fetch, phantom deletes, empty tries, non-deletes, and incomplete proofs. Reviewed by Cursor Bugbot for commit d7135b8. Bugbot is set up for automated code reviews on this repo. Configure here. |
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 2 potential issues.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, have a team admin enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit cf7dc99. Configure here.
Yoni-Starkware
left a comment
There was a problem hiding this comment.
@Yoni-Starkware made 6 comments.
Reviewable status: 0 of 3 files reviewed, 8 unresolved discussions.
crates/starknet_transaction_prover/src/running/storage_proofs.rs line 175 at r1 (raw file):
// `rpc_proof.contracts_storage_proofs` are built together by `prepare_query` and share // index order. let Some(idx) = query.contract_addresses.iter().position(|a| a == addr) else { continue };
build the addr -> index map outside the for loop
crates/starknet_transaction_prover/src/running/storage_proofs.rs line 231 at r1 (raw file):
while !current_index.is_leaf() { let Some(node) = proof_nodes.get(¤t_hash) else { break };
Is this reachable? If not, please return an error. If yes, comment.
Code quote:
else { break };crates/starknet_transaction_prover/src/running/storage_proofs.rs line 232 at r1 (raw file):
while !current_index.is_leaf() { let Some(node) = proof_nodes.get(¤t_hash) else { break }; match node {
Are you sure this code works? you only document the sibling in binary nodes. What if I have a tree with only edges? (i.e., one edge)
Also, I'm not sure the sibling you're taking is the right one. The ancestor of a deleted node in our case is a middle of an edge, and we need the preimage of that edge's bottom. Please revise your solution
crates/starknet_transaction_prover/src/running/storage_proofs.rs line 270 at r1 (raw file):
))); } let bottom_index = (current_index << edge_len) + path_index;
This whole code is just to extract the bottom index. Please try to use an existing util from the Patricia crate. If there isn't, extract this part to a function in this file and make the sanity checks more concise
Code quote:
let edge_len = usize::try_from(en.length).map_err(|_| {
ProofProviderError::InvalidProofResponse(format!(
"edge node {current_hash:#x} has length {} that doesn't fit in usize",
en.length
))
})?;
// An edge can't extend past the leaf level in a valid trie; reject malformed proofs
// here so the index shifts below can't overflow `NodeIndex::MAX`.
if depth + edge_len > storage_tree_height {
return Err(ProofProviderError::InvalidProofResponse(format!(
"edge node {current_hash:#x} of length {edge_len} at depth {depth} extends \
past the leaf level (tree height {storage_tree_height})"
)));
}
let edge_len = u8::try_from(edge_len).expect("edge_len <= tree height fits in u8");
let path_index = NodeIndex::from_felt_value(&en.path);
// A path must fit in its declared length; stray higher bits mean a malformed proof.
if path_index >= NodeIndex::ROOT << edge_len {
return Err(ProofProviderError::InvalidProofResponse(format!(
"edge node {current_hash:#x} path {:#x} exceeds its length {edge_len}",
en.path
)));
}
let bottom_index = (current_index << edge_len) + path_index;crates/starknet_transaction_prover/src/running/storage_proofs.rs line 285 at r1 (raw file):
let Some((sibling_index, sibling_hash)) = collapse_sibling else { return Ok(None) }; // A leaf-level sibling is a storage value merged by hash; an already-present sibling needs no // follow-up. Only an orphan internal sibling requires a crafted key.
Move the comment below the if
Code quote:
// A leaf-level sibling is a storage value merged by hash; an already-present sibling needs no
// follow-up. Only an orphan internal sibling requires a crafted key.crates/starknet_transaction_prover/src/running/storage_proofs.rs line 294 at r1 (raw file):
while !crafted_leaf_index.is_leaf() { crafted_leaf_index = crafted_leaf_index << 1; }
Do it in a single line, without looping
Code quote:
// Any leaf under the sibling exposes its preimage on a follow-up query; descend to the
// left-most one and convert it back to a storage key (strip the leading `FIRST_LEAF` bit).
let mut crafted_leaf_index = sibling_index;
while !crafted_leaf_index.is_leaf() {
crafted_leaf_index = crafted_leaf_index << 1;
}cf7dc99 to
cafd5ce
Compare
|
Addressed all comments in the latest push (single amended commit). Verified locally: Per comment:
Cursor Bugbot:
Patricia |
…bling_keys helper When a storage leaf is deleted the Patricia trie collapses the deepest binary node on the path into an edge pointing at its sibling. Canonicalizing that edge needs the sibling's preimage, which a plain get_storage_proof does not return (siblings appear only as orphan hashes). compute_missing_sibling_keys walks each deleted key's proof to the deepest binary node and returns one crafted storage key per contract that, on a follow-up get_storage_proof, exposes exactly that sibling's preimage — and nothing else. Only the deepest binary node's sibling is fetched: shallower nodes are merely rehashed on deletion, for which the committer already has the sibling hash. The walk fetches nothing when the key is absent from the trie (a no-op delete) or the storage trie is empty, and errors when the proof doesn't reach the deleted leaf (a deleted key is always read, so a complete proof must contain its full path). 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) <noreply@anthropic.com>
cafd5ce to
d7135b8
Compare
|
Re L231 — agreed, it's an RPC bug. Changed the missing-node case from "return nothing" to a hard Reasoning it's safe to error there: a deleted key is always read before deletion, so it's queried and a complete proof contains its full path → the walk reaches its leaf. A no-op delete of an absent key diverges at an edge (binary nodes always have both children present), which the This also made the post-loop "didn't reach the leaf" guard dead code (the loop now only exits at the deleted leaf), so I removed it. The test is updated to assert an error ( |
Yoni-Starkware
left a comment
There was a problem hiding this comment.
@Yoni-Starkware resolved 6 discussions.
Reviewable status: 0 of 3 files reviewed, all discussions resolved.

What
When a storage leaf is deleted, the Patricia storage trie changes shape: the deepest binary node on the path to the deleted key collapses into an edge pointing at its sibling. To produce the canonical post-deletion tree (checked by
patricia.cairo), the committer must merge that collapse edge with the sibling — which requires the sibling's preimage. A plainget_storage_proofdoes not return it: siblings on the path appear only as orphan hashes.compute_missing_sibling_keysscans the storage deletes in astate_diff, walks each deleted key's proof to the deepest binary node, and returns one crafted storage key per contract. Queried on a follow-upget_storage_proof, each crafted key routes into the sibling's subtree and forces the RPC to expose exactly that sibling's preimage.It is
#[allow(dead_code)]for now and wired intoget_storage_proofsin a follow-up.Why only the deepest sibling
For a single-leaf deletion, only the deepest binary node on the path collapses; every shallower binary node keeps a non-empty on-path child (the collapsed edge) and is merely rehashed, for which the committer already has the sibling's hash (carried by the parent / the dummy node from
add_dummy_nodes_for_orphan_hashes, exactly as for non-deletion updates). The sibling's structure is only consumed when it is an edge (node_from_edge_data→concat_paths); when it is binary or a leaf, the existing dummy-node path already yields the correct node. We can't tell an orphan sibling's type from the base proof, so we fetch the deepest one whenever its preimage is absent and the committer ignores it harmlessly if it turns out non-edge.Edge paths are validated against the key, so a zero-write to a key that isn't in the trie (a no-op) fetches nothing.
Implementation notes
The walk is expressed over
NodeIndex. To support it,starknet_patriciaexposesget_children_indicesandfrom_felt_valueaspuband adds a non-panickingis_descendant_of.Tests
🤖 Generated with Claude Code