You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Seek to 0xdd → empty → maybe_unwrap_subtrie → removes child from parent. Parent 0xd now has 1 child: {0xd8 (blinded)}.
maybe_collapse_or_remove_branch runs → count=1, sole remaining child is blinded → panic.
Why the pre-check missed it
check_subtrie_collapse_needs_proof evaluated each subtrie independently against the pre-update parent shape. It only requested a proof when the parent had exactly 2 children (the subtrie being emptied + one blinded sibling). With 3+ children, it assumed removing one subtrie was safe — without considering that other siblings in the same batch would also be removed.
The Fix
Remove the count_bits() == 2 gate and sibling_child() call (which asserts exactly 2 children). Instead, scan all parent children for any blinded sibling. If a subtrie would be fully emptied and the parent has any blinded child anywhere, conservatively request a proof upfront.
Diff
The change is entirely within check_subtrie_collapse_needs_proof. The early-exit conditions (not all removals, wouldn't empty the subtrie) are unchanged. Only the parent-shape check changes:
Before:
if parent_branch.state_mask.count_bits() != 2{returnNone;}if !parent_branch.sibling_child(child_nibble).is_blinded(){returnNone;}let sibling_nibble = parent_branch
.state_mask.iter().find(|&n| n != child_nibble).expect("branch has two children");
When check_subtrie_collapse_needs_proof returns Some, the subtrie's updates are put back into the updates map, the subtrie is skipped entirely, and a proof is requested. On the next reveal-update cycle, the proof reveals the blinded sibling, and the subtrie's updates are retried — this time the parent has no blinded children so the check passes and the deletions go through normally.
The false-positive scenario:
Parent branch at nibble 0xd, 4 children:
0xd3 → Subtrie (revealed, NOT being updated)
0xd7 → Subtrie (revealed, being emptied by all-deletion updates)
0xd8 → BLINDED (no updates)
0xdd → Subtrie (revealed, NOT being updated)
Old code: parent has 4 children → count_bits() != 2 → returns None → subtrie 0xd7 is taken and emptied → post-restore unwrap removes it → parent has 3 children {0xd3, 0xd8, 0xdd} → count >= 2 → no collapse → safe, no problem.
New code: parent has a blinded child 0xd8 → returns Some(proof for 0xd8) → subtrie 0xd7's updates are skipped and re-queued → proof for 0xd8 is requested → extra reveal-update round-trip to apply the same deletions that would have worked fine the first time.
Cost: one extra iteration of the reveal-update loop. The deletions still succeed, the hash is still correct — just one unnecessary round-trip through update_leaves → proof request → reveal_nodes → update_leaves.
How often: requires ALL of these simultaneously:
A subtrie where every single update is a deletion
Those deletions empty the subtrie completely (num_removals >= num_leaves)
The parent has a blinded child
The parent has other revealed children that survive (so the collapse-to-blinded never actually happens)
This is very narrow. In typical usage, subtries at depth 2 under the same parent rarely have all-deletion batches that fully empty them, and if the parent has blinded children it usually means the trie is partially revealed — which is the exact scenario where an extra proof round-trip is near-free anyway.
A tighter fix would need to predict which other sibling subtries will also be emptied in the same batch, but check_subtrie_collapse_needs_proof runs per-subtrie during a sequential walk — it doesn't have lookahead into future subtries. You'd need a two-pass approach or parent-level grouping, which is more code for a marginal improvement on a rare edge case.
A changelog entry is required before merging. We've generated a suggested changelog based on your changes:
Preview
---reth-trie-sparse: patch---
Fixed blinded-sibling collapse panic when multiple sibling subtries are emptied in the same parallel batch. Previously, `check_subtrie_collapse_needs_proof` only handled the case where the parent branch had exactly two children and the single sibling was blinded; it now finds any blinded sibling among an arbitrary number of children, preventing the branch from collapsing to a lone blinded child during post-restore unwrap.
pepyakin
changed the title
test: reproduce blinded-sibling collapse with forced parallel path
fix(trie): blinded-sibling collapse when multiple subtries empty in parallel
Mar 13, 2026
Incomplete fix: Blinded-sibling collapse panic can still be triggered via Touched updates
High
⏳ Pending
audit
⚙️ Controls
🚀 Keep only 1 remaining iteration per worker after the current work finishes.
👀 Keep only 2 remaining iterations per worker after the current work finishes.
❤️ Let only worker 1 continue; other workers skip queued iterations.
😄 Let only worker 2 continue; other workers skip queued iterations.
🎉 End faster by skipping queued iterations and moving toward consolidation.
😕 Stop active workers/verifiers now and start consolidation immediately.
📜 9 events
🔍 pr-23021-w1 iter 1/3 [audit-ripple.md]
🔍 pr-23021-w2 iter 1/3 [audit-focused.md]
🔍 pr-23021-w3 iter 1/3 [audit-deep-focus.md]
✅ pr-23021-w1 iter 1 — clear | Thread
🔍 pr-23021-w1 iter 2/3 [audit-historical.md]
🚨 pr-23021-w1 iter 2 — finding | Thread
🚨 Finding: Incomplete fix: Blinded-sibling collapse panic can still be triggered via Touched updates (High) | Thread
🔍 pr-23021-w1 iter 3/3 [audit-focused.md]
🔬 Verifying: Incomplete fix: Blinded-sibling collapse panic can still be triggered via Touched updates | Thread
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The Bug
Trie structure at the time of
update_leavesWhat happens
update_leaveswalks the sorted updates sequentially and encounters subtrie0xd7(all-removal update).Pre-check
check_subtrie_collapse_needs_proofruns for0xd7:num_removals == subtrie_updates.len())num_removals >= num_leaves)count_bits() != 2→ returnsNone(no proof needed)0xd7is taken for parallel processing.Same thing for
0xdd— identical check, identical result. Taken.Both subtries are processed in parallel. Both become
EmptyRoot.Post-restore unwrap phase iterates taken paths:
0xd7→ empty →maybe_unwrap_subtrie→ removes child from parent. Parent0xdnow has 2 children:{0xd8 (blinded), 0xdd (empty subtrie)}.maybe_collapse_or_remove_branchruns → count=2, no-op.0xdd→ empty →maybe_unwrap_subtrie→ removes child from parent. Parent0xdnow has 1 child:{0xd8 (blinded)}.maybe_collapse_or_remove_branchruns → count=1, sole remaining child is blinded → panic.Why the pre-check missed it
check_subtrie_collapse_needs_proofevaluated each subtrie independently against the pre-update parent shape. It only requested a proof when the parent had exactly 2 children (the subtrie being emptied + one blinded sibling). With 3+ children, it assumed removing one subtrie was safe — without considering that other siblings in the same batch would also be removed.The Fix
Remove the
count_bits() == 2gate andsibling_child()call (which asserts exactly 2 children). Instead, scan all parent children for any blinded sibling. If a subtrie would be fully emptied and the parent has any blinded child anywhere, conservatively request a proof upfront.Diff
The change is entirely within
check_subtrie_collapse_needs_proof. The early-exit conditions (not all removals, wouldn't empty the subtrie) are unchanged. Only the parent-shape check changes:Before:
After:
Conservatism
When
check_subtrie_collapse_needs_proofreturnsSome, the subtrie's updates are put back into theupdatesmap, the subtrie is skipped entirely, and a proof is requested. On the next reveal-update cycle, the proof reveals the blinded sibling, and the subtrie's updates are retried — this time the parent has no blinded children so the check passes and the deletions go through normally.The false-positive scenario:
Old code: parent has 4 children →
count_bits() != 2→ returnsNone→ subtrie0xd7is taken and emptied → post-restore unwrap removes it → parent has 3 children{0xd3, 0xd8, 0xdd}→count >= 2→ no collapse → safe, no problem.New code: parent has a blinded child
0xd8→ returnsSome(proof for 0xd8)→ subtrie0xd7's updates are skipped and re-queued → proof for0xd8is requested → extra reveal-update round-trip to apply the same deletions that would have worked fine the first time.Cost: one extra iteration of the reveal-update loop. The deletions still succeed, the hash is still correct — just one unnecessary round-trip through
update_leaves→ proof request →reveal_nodes→update_leaves.How often: requires ALL of these simultaneously:
num_removals >= num_leaves)This is very narrow. In typical usage, subtries at depth 2 under the same parent rarely have all-deletion batches that fully empty them, and if the parent has blinded children it usually means the trie is partially revealed — which is the exact scenario where an extra proof round-trip is near-free anyway.
A tighter fix would need to predict which other sibling subtries will also be emptied in the same batch, but
check_subtrie_collapse_needs_proofruns per-subtrie during a sequential walk — it doesn't have lookahead into future subtries. You'd need a two-pass approach or parent-level grouping, which is more code for a marginal improvement on a rare edge case.