Skip to content

shardtree: discard stale state when truncating#196

Open
nuttycom wants to merge 4 commits into
zcash:mainfrom
nuttycom:truncate_discards_stale_state
Open

shardtree: discard stale state when truncating#196
nuttycom wants to merge 4 commits into
zcash:mainfrom
nuttycom:truncate_discards_stale_state

Conversation

@nuttycom

Copy link
Copy Markdown
Contributor

Builds upon #147: this branch includes @gustavovalverde's commits fixing stale cached cap roots on truncation, and extends them to fix two further defects in checkpoint truncation discovered while testing wallet reorg recovery in librustzcash. Merging this PR lands the entire change as a single merge, and GitHub will automatically mark #147 as merged once its commits are reachable from main.

The defects fixed on top of #147:

  1. Stale cached annotations survived truncation inside shards. LocatedPrunableTree::truncate_to_position reconstructed the parent nodes along the truncation path with their cached root annotations intact. Such an annotation is a hash over the parent's complete subtree — including the data being truncated away — so a retained annotation caused a spurious Insert(Conflict(..)) error (or a silently incorrect root) once the truncated region was refilled with different data. Annotations of this shape are routinely written by insert_frontier, which wallets invoke with the chain state at the start of every scanned batch, so any wallet that rewinds past a scan-batch boundary and then re-scans a divergent (post-reorg) chain can hit this.

  2. Truncation at a position the tree does not individually represent was silently skipped. When the truncation position fell in the interior of a pruned subtree (a merged hash node), truncate_to_position returned None, and truncate_to_checkpoint reacted by skipping the truncation while still reporting success. (On shardtree 0.7.0, before shardtree: discard stale cap roots on truncation #147's fix, the cap had already been truncated at that point, additionally leaving the store internally inconsistent.)

The fix:

  • A subtree lying entirely at or below the truncation position is retained unmodified (including its still-valid annotations); parent nodes whose subtrees lose data in the truncation are reconstructed without their cached annotations.
  • truncate_to_position continues to refuse truncation at a position the tree does not individually represent: succeeding would require discarding retained data along with the requested suffix. ShardTree::truncate_to_checkpoint and truncate_to_checkpoint_depth now surface that refusal as a QueryError::CheckpointPruned error, leaving the store unmodified, instead of reporting success.
  • The cap continues to be truncated with shardtree: discard stale cap roots on truncation #147's truncate_cache_to_position: the cap is a cache of hashes recomputable from the shards below, so a cap entry spanning the truncation boundary is discarded rather than making the position unusable. With refusal handled up front, the cap, shard, and checkpoint truncation now proceed unconditionally.

The first commit on top of #147 adds regression tests (red at that commit); the second contains the fix. One of the tests reproduces the wallet-shaped failure entirely through the public API: append leaves, insert a batch-boundary frontier, truncate to an earlier checkpoint, then insert a frontier carrying divergent replacement data — which previously failed with Insert(Conflict(..)).

An earlier revision of this change was also validated against librustzcash by path-patching it into its workspace: a wallet-level test that re-mines a transaction on a divergent chain after rewind_to_chain_state fails against shardtree 0.7.0 with PutBlocksCommitmentTree { error: Insert(Conflict(..)) } whenever earlier scan batches had written frontier ommers above the rewind target, and passes with the fix. (That revision differed from this one only in the handling of non-representable truncation positions; the wallet scenario truncates to a representable position, for which the behavior is identical.)

Supersedes and closes #194.

🤖 Generated with Claude Code

gustavovalverde and others added 4 commits July 12, 2026 22:22
`LocatedPrunableTree::truncate_to_position` and its callers mishandle
two situations in which the tree's pruned representation interacts
badly with the truncation position:

1. The parent nodes reconstructed along the truncation path retain
   their cached root annotations. Such an annotation is a hash over
   the parent's complete subtree — including the data being truncated
   away — so a stale annotation later causes a spurious
   `Insert(Conflict(..))` (or a silently incorrect root) when the
   truncated region is refilled with different data, as happens when a
   wallet re-scans a divergent chain after a reorg. Annotations of
   this shape are routinely written by `insert_frontier`, which is
   invoked with the chain state at the start of every scanned batch.

2. When the truncation position falls in the interior of a pruned
   subtree (a merged hash node), `truncate_to_position` returns `None`
   — correctly, since truncating there would have to discard retained
   data along with the requested suffix — but `truncate_to_checkpoint`
   reacts to the refusal by silently skipping the truncation while
   still reporting success, leaving all post-checkpoint state in
   place. (Before the preceding cap-truncation fix, the cap had
   already been modified at that point, additionally leaving the
   store internally inconsistent.)

The annotation tests and the `truncate_to_checkpoint` coherence test
are RED at this commit; `truncate_to_position_fails_inside_pruned_subtree`
passes, pinning the refusal contract that the fix must preserve. The
fix follows in the next commit.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
`LocatedPrunableTree::truncate_to_position` now guarantees that nothing
derived from the discarded suffix of the tree survives a truncation
that it performs, and refuses truncation at any position the tree does
not individually represent:

- A subtree lying entirely at or below the truncation position is
  retained unmodified (including any cached root annotation). This
  check is now made once at the top of the recursion, generalizing the
  check previously made only for pruned leaf nodes.
- Parent nodes whose subtrees lose data in the truncation are
  reconstructed without their cached root annotations: such an
  annotation is a hash over the parent's complete subtree, which by
  construction of the truncation path includes discarded data.
- Truncation at a position in the interior of a pruned subtree (or at
  which the tree holds no data) is refused, as before; it is the
  caller's responsibility to select a position at which truncation is
  possible.

`ShardTree::truncate_to_checkpoint{_depth}` now handle the refusal by
failing with `QueryError::CheckpointPruned`, leaving the store
unmodified, instead of silently skipping the truncation while still
reporting success.

The cap remains truncated with `truncate_cache_to_position`,
introduced by the preceding cap-truncation fix: the cap is a cache —
every node in it is a hash recomputable from the shards below — so a
cached entry spanning the truncation boundary is discarded rather
than making the position unusable. With refusal now handled up front,
the cap, shard, and checkpoint updates are performed unconditionally.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants