pepper_sync/sync/truncate.rs
1//! The truncation contract: two pure decision rules, applied where the
2//! data lives.
3//!
4//! [`plan_truncation`] routes the wallet-level decision, from the
5//! wallet state ([`WalletTruncationState`]: birthday and highest
6//! scanned height) and the truncation target, it returns whether the
7//! truncation is a no-op, a full clear, or a rollback to the target.
8//! `plan_pool_truncation` is the per-tree rule: from one tree's
9//! checkpoint facts ([`TreeTruncationFacts`]) it returns that tree's
10//! outcome, and the shard-tree applier derives each tree's outcome
11//! through it at the point of application. Both rules are pure. All
12//! mutation stays with the appliers.
13//!
14//! Every per-pool outcome carries the evidence that justifies it: a
15//! rollback carries the checkpoint it found, and a rescan sentence
16//! carries the newest checkpoint that condemned the tree. The applier
17//! derives its actions and its error messages from that evidence
18//! instead of re-deriving or asserting it.
19//!
20//! The contract the plan guarantees, per store:
21//!
22//! - Wallet blocks, transactions, nullifiers, and outpoints retain
23//! exactly the data at or below the plan's height.
24//! - A shard tree holding a checkpoint at the height rolls back to it.
25//! - A shard tree recording no chain state above the height is left
26//! untouched: it has nothing to remove, so it cannot be "broken" with
27//! respect to this truncation. This is the state of the empty ironwood
28//! tree a pre-ironwood (v0) wallet blob migrates to, whose only
29//! checkpoint is the height-zero initialization checkpoint.
30//! - A shard tree holding state above the height with no checkpoint at
31//! it cannot roll back. Only the clear-and-rescan recovery restores
32//! integrity, and the plan says so explicitly.
33//!
34//! Decisions live here and nowhere else: the applying code performs no
35//! judgement of its own beyond reporting a planned rollback that the
36//! tree store unexpectedly refuses.
37
38use incrementalmerkletree::Hashable;
39use shardtree::ShardTree;
40use shardtree::store::ShardStore;
41use shardtree::store::memory::MemoryShardStore;
42use zcash_protocol::consensus::{self, BlockHeight};
43
44/// The wallet state a truncation decision reads: where scanning began
45/// and how far it has reached.
46#[derive(Debug, Clone, Copy, PartialEq, Eq)]
47pub struct WalletTruncationState {
48 /// The wallet birthday. A target below it means no retained state
49 /// can anchor the wallet, so every store resets.
50 pub birthday: BlockHeight,
51 /// The highest block height that has been scanned. A target above
52 /// it leaves nothing to remove.
53 pub highest_scanned_height: BlockHeight,
54}
55
56/// What the planner reads of one shard tree, gathered at the truncation
57/// target.
58///
59/// A checkpoint records every *scanned* chain state the tree has
60/// absorbed (frontier insertion and scanned-subtree insertion attach
61/// checkpoints), so the newest checkpoint bounds the scanned state.
62/// Server-fetched subtree roots are the exception: they enter the store
63/// with no checkpoint, so this planner cannot see them. They are kept
64/// safe by a different mechanism: each sync session refetches the
65/// newest still-bare root and replaces it if the chain moved (see
66/// `crate::witness::subtree_fetch_start_index`), so a stale bare root
67/// surviving an `Untouched` verdict is healed at the next session.
68#[derive(Debug, Clone, Copy, PartialEq, Eq)]
69pub struct TreeTruncationFacts {
70 /// The checkpoint the tree's store holds exactly at the truncation
71 /// target, if any.
72 pub checkpoint_at_target: Option<BlockHeight>,
73 /// The newest checkpoint id the store holds, if any.
74 pub newest_checkpoint: Option<BlockHeight>,
75}
76
77/// Reads one tree's truncation facts from its store.
78pub(crate) fn tree_facts<H, const DEPTH: u8, const SHARD_HEIGHT: u8>(
79 tree: &ShardTree<MemoryShardStore<H, BlockHeight>, DEPTH, SHARD_HEIGHT>,
80 truncate_height: BlockHeight,
81) -> TreeTruncationFacts
82where
83 H: Hashable + Clone + PartialEq,
84{
85 TreeTruncationFacts {
86 checkpoint_at_target: tree
87 .store()
88 .get_checkpoint(&truncate_height)
89 .expect("infallible")
90 .map(|_| truncate_height),
91 newest_checkpoint: tree.store().max_checkpoint_id().expect("infallible"),
92 }
93}
94
95/// The correct truncation outcome for one shard tree, carrying the
96/// evidence that justifies it.
97#[derive(Debug, Clone, Copy, PartialEq, Eq)]
98pub enum PoolTruncation {
99 /// The tree holds this checkpoint at the target height: roll back
100 /// to it.
101 ToCheckpoint {
102 /// The checkpoint found at the target height.
103 checkpoint: BlockHeight,
104 },
105 /// The tree records no chain state above the target height (its
106 /// newest checkpoint, if any, is at or below it): leave it untouched.
107 Untouched,
108 /// The tree holds state above the target height but no checkpoint at
109 /// it: rollback is impossible, and only a full clear and rescan
110 /// restores integrity.
111 RequiresRescan {
112 /// The newest checkpoint above the target that condemned
113 /// the tree.
114 newest_checkpoint: BlockHeight,
115 },
116}
117
118/// The correct post-truncation state of every store, decided purely.
119#[derive(Debug, Clone, Copy, PartialEq, Eq)]
120pub enum TruncationPlan {
121 /// The target lies above the highest scanned block: every store is
122 /// already correct and nothing is removed.
123 NoOp,
124 /// The target lies below the wallet birthday (or is height zero):
125 /// no retained state can anchor the wallet, so every store (blocks,
126 /// transactions, nullifiers, outpoints, and all three shard
127 /// trees) resets to empty.
128 ClearAll,
129 /// Roll the wallet back: blocks, transactions, nullifiers, and
130 /// outpoints retain exactly the data at or below `height`, and each
131 /// shard tree applies the per-pool outcome `plan_pool_truncation`
132 /// derives at the point of application.
133 Truncate {
134 /// The height retained. Everything strictly above it goes.
135 height: BlockHeight,
136 },
137}
138
139/// Decides the correct truncation of the wallet, purely.
140///
141/// See the module documentation for the contract the returned plan
142/// guarantees per store.
143#[must_use]
144pub fn plan_truncation(
145 wallet_state: WalletTruncationState,
146 truncate_height: BlockHeight,
147) -> TruncationPlan {
148 if truncate_height == consensus::H0 || truncate_height < wallet_state.birthday {
149 return TruncationPlan::ClearAll;
150 }
151 if truncate_height > wallet_state.highest_scanned_height {
152 return TruncationPlan::NoOp;
153 }
154 TruncationPlan::Truncate {
155 height: truncate_height,
156 }
157}
158
159/// Decides the correct truncation of one shard tree, purely, from the
160/// evidence in its facts.
161pub(crate) fn plan_pool_truncation(
162 facts: TreeTruncationFacts,
163 truncate_height: BlockHeight,
164) -> PoolTruncation {
165 if let Some(checkpoint) = facts.checkpoint_at_target {
166 return PoolTruncation::ToCheckpoint { checkpoint };
167 }
168 match facts.newest_checkpoint {
169 Some(newest_checkpoint) if newest_checkpoint > truncate_height => {
170 PoolTruncation::RequiresRescan { newest_checkpoint }
171 }
172 _ => PoolTruncation::Untouched,
173 }
174}
175
176/// Decides whether one shard tree rolls back to the wallet's highest
177/// scanned height at the start of a sync session, purely, from the
178/// evidence in its facts gathered at that height.
179///
180/// A newest checkpoint above the highest scanned height is the evidence
181/// of state from blocks the wallet has yet to record as scanned, which
182/// a sync session leaves behind when it ends partway through a wallet
183/// update. Such a tree rolls back to its checkpoint at the highest
184/// scanned height, and the checkpoint is returned.
185///
186/// Every other tree is left as it is. A tree whose newest checkpoint is
187/// at or below the highest scanned height has nothing to remove. A tree
188/// that holds a newer checkpoint and none at the highest scanned height
189/// cannot roll back, and its state above that height only conflicts
190/// with scanning when a re-org replaces the blocks it came from. A pool
191/// rescan leaves the trees of the earlier pools in this state.
192pub(crate) fn plan_unscanned_state_rollback(
193 facts: TreeTruncationFacts,
194 highest_scanned_height: BlockHeight,
195) -> Option<BlockHeight> {
196 facts.checkpoint_at_target.filter(|_| {
197 facts
198 .newest_checkpoint
199 .is_some_and(|newest_checkpoint| newest_checkpoint > highest_scanned_height)
200 })
201}
202
203#[cfg(test)]
204mod test {
205 use super::*;
206 use crate::shardtree_ext::{CheckpointAppendOutcome, ShardTreeExt};
207 use crate::wallet::ShardTrees;
208
209 fn wallet_state() -> WalletTruncationState {
210 WalletTruncationState {
211 birthday: BlockHeight::from_u32(6),
212 highest_scanned_height: BlockHeight::from_u32(10),
213 }
214 }
215
216 /// Facts for a tree whose checkpoint at the target is present when
217 /// `at_target` is `Some` (carrying the target height itself).
218 fn facts(at_target: Option<u32>, newest_checkpoint: Option<u32>) -> TreeTruncationFacts {
219 TreeTruncationFacts {
220 checkpoint_at_target: at_target.map(BlockHeight::from_u32),
221 newest_checkpoint: newest_checkpoint.map(BlockHeight::from_u32),
222 }
223 }
224
225 /// The migrated pre-ironwood wallet: sapling and orchard roll back to
226 /// their target checkpoints, and the empty ironwood tree whose only
227 /// checkpoint is the height-zero initialization checkpoint records
228 /// nothing above the target, so it is untouched, never broken.
229 #[test]
230 fn migrated_empty_tree_is_untouched() {
231 assert_eq!(
232 plan_truncation(wallet_state(), BlockHeight::from_u32(8)),
233 TruncationPlan::Truncate {
234 height: BlockHeight::from_u32(8),
235 }
236 );
237 assert_eq!(
238 plan_pool_truncation(facts(Some(8), Some(10)), BlockHeight::from_u32(8)),
239 PoolTruncation::ToCheckpoint {
240 checkpoint: BlockHeight::from_u32(8),
241 }
242 );
243 assert_eq!(
244 plan_pool_truncation(facts(None, Some(0)), BlockHeight::from_u32(8)),
245 PoolTruncation::Untouched
246 );
247 }
248
249 /// A tree with no checkpoints at all likewise records nothing above
250 /// the target.
251 #[test]
252 fn checkpointless_tree_is_untouched() {
253 assert_eq!(
254 plan_pool_truncation(facts(None, None), BlockHeight::from_u32(8)),
255 PoolTruncation::Untouched
256 );
257 }
258
259 /// A tree scanned past the target whose checkpoint at the target is
260 /// gone cannot roll back: the plan names the rescan recovery and the
261 /// checkpoint that condemned the tree.
262 #[test]
263 fn pruned_checkpoint_requires_rescan() {
264 assert_eq!(
265 plan_pool_truncation(facts(None, Some(10)), BlockHeight::from_u32(8)),
266 PoolTruncation::RequiresRescan {
267 newest_checkpoint: BlockHeight::from_u32(10),
268 }
269 );
270 }
271
272 /// A checkpoint exactly at the target wins over everything else, and
273 /// the outcome carries the checkpoint it found.
274 #[test]
275 fn checkpoint_at_target_rolls_back() {
276 assert_eq!(
277 plan_pool_truncation(facts(Some(8), Some(10)), BlockHeight::from_u32(8)),
278 PoolTruncation::ToCheckpoint {
279 checkpoint: BlockHeight::from_u32(8),
280 }
281 );
282 }
283
284 /// A tree holding a checkpoint above the highest scanned height
285 /// rolls back to its checkpoint at that height.
286 #[test]
287 fn state_above_highest_scanned_rolls_back() {
288 assert_eq!(
289 plan_unscanned_state_rollback(facts(Some(10), Some(11)), BlockHeight::from_u32(10)),
290 Some(BlockHeight::from_u32(10))
291 );
292 }
293
294 /// A tree whose newest checkpoint is the highest scanned height is
295 /// left as it is, so the subtree roots it holds above that height
296 /// are kept.
297 #[test]
298 fn tree_at_highest_scanned_is_left_as_it_is() {
299 assert_eq!(
300 plan_unscanned_state_rollback(facts(Some(10), Some(10)), BlockHeight::from_u32(10)),
301 None
302 );
303 assert_eq!(
304 plan_unscanned_state_rollback(facts(None, Some(0)), BlockHeight::from_u32(10)),
305 None
306 );
307 }
308
309 /// A tree holding a newer checkpoint and none at the highest scanned
310 /// height is left as it is, as a pool rescan leaves the trees of the
311 /// earlier pools.
312 #[test]
313 fn tree_without_a_checkpoint_at_highest_scanned_is_left_as_it_is() {
314 assert_eq!(
315 plan_unscanned_state_rollback(facts(None, Some(12)), BlockHeight::from_u32(10)),
316 None
317 );
318 }
319
320 /// A target above the highest scanned block removes nothing.
321 #[test]
322 fn target_above_highest_scanned_is_a_no_op() {
323 let plan = plan_truncation(wallet_state(), BlockHeight::from_u32(11));
324 assert_eq!(plan, TruncationPlan::NoOp);
325 }
326
327 /// A target below the birthday leaves nothing to anchor the wallet:
328 /// every store resets.
329 #[test]
330 fn target_below_birthday_clears_all() {
331 let plan = plan_truncation(wallet_state(), BlockHeight::from_u32(5));
332 assert_eq!(plan, TruncationPlan::ClearAll);
333 }
334
335 /// Height zero is the explicit clear-everything request, birthday
336 /// notwithstanding.
337 #[test]
338 fn height_zero_clears_all() {
339 let plan = plan_truncation(
340 WalletTruncationState {
341 birthday: consensus::H0,
342 highest_scanned_height: BlockHeight::from_u32(10),
343 },
344 consensus::H0,
345 );
346 assert_eq!(plan, TruncationPlan::ClearAll);
347 }
348
349 /// The birthday itself is a valid target: the wallet rolls back to
350 /// its first block rather than clearing.
351 #[test]
352 fn target_at_birthday_truncates() {
353 let plan = plan_truncation(wallet_state(), BlockHeight::from_u32(6));
354 assert!(matches!(plan, TruncationPlan::Truncate { height }
355 if height == BlockHeight::from_u32(6)));
356 }
357
358 /// The planner agrees with an independently stated reference model
359 /// over its entire input space (heights bounded at `MAX`), and
360 /// satisfies the class-level safety property that motivated this
361 /// module: a tree recording nothing above the target is never
362 /// condemned to rescan. The facts-level inputs are small enough to
363 /// enumerate exhaustively, so no case is left to sampling. The
364 /// `properties` module complements this with generated checkpoint
365 /// sets.
366 #[test]
367 fn exhaustive_sweep_matches_the_reference_model() {
368 const MAX: u32 = 6;
369 let heights = || (0..=MAX).map(BlockHeight::from_u32);
370 let newest_choices = || std::iter::once(None).chain(heights().map(Some));
371
372 for birthday in heights() {
373 for highest_scanned_height in heights() {
374 for target in heights() {
375 let plan = plan_truncation(
376 WalletTruncationState {
377 birthday,
378 highest_scanned_height,
379 },
380 target,
381 );
382
383 // The wallet-level reference model, stated
384 // independently of the implementation.
385 if target == consensus::H0 || target < birthday {
386 assert_eq!(plan, TruncationPlan::ClearAll);
387 continue;
388 }
389 if target > highest_scanned_height {
390 assert_eq!(plan, TruncationPlan::NoOp);
391 continue;
392 }
393 assert_eq!(plan, TruncationPlan::Truncate { height: target });
394
395 // The per-pool reference model at this target.
396 for checkpoint_at_target in [None, Some(target)] {
397 for newest_checkpoint in newest_choices() {
398 let outcome = plan_pool_truncation(
399 TreeTruncationFacts {
400 checkpoint_at_target,
401 newest_checkpoint,
402 },
403 target,
404 );
405 let expected = if let Some(checkpoint) = checkpoint_at_target {
406 PoolTruncation::ToCheckpoint { checkpoint }
407 } else {
408 match newest_checkpoint {
409 Some(newest_checkpoint) if newest_checkpoint > target => {
410 PoolTruncation::RequiresRescan { newest_checkpoint }
411 }
412 _ => PoolTruncation::Untouched,
413 }
414 };
415 assert_eq!(outcome, expected);
416 // The safety property behind the migrated-
417 // wallet wipe: nothing-above-target is never
418 // a rescan sentence.
419 if newest_checkpoint.is_none_or(|newest| newest <= target) {
420 assert!(!matches!(outcome, PoolTruncation::RequiresRescan { .. }));
421 }
422 }
423 }
424 }
425 }
426 }
427 }
428
429 /// `gather` reports the store's ground truth: the checkpoint facts it
430 /// returns match what the shard-tree stores actually hold, per pool.
431 /// This guards the proxy seam between fact-gathering and planning.
432 #[test]
433 fn gather_reports_store_ground_truth() {
434 // Each tree starts with the height-zero initialization
435 // checkpoint; orchard alone gains checkpoints at 3 and 5.
436 let mut shard_trees = ShardTrees::new();
437 for height in [3u32, 5] {
438 assert_eq!(
439 shard_trees
440 .orchard
441 .append_checkpoint(BlockHeight::from_u32(height))
442 .unwrap(),
443 CheckpointAppendOutcome::Appended
444 );
445 }
446
447 assert_eq!(
448 tree_facts(&shard_trees.orchard, BlockHeight::from_u32(5)),
449 TreeTruncationFacts {
450 checkpoint_at_target: Some(BlockHeight::from_u32(5)),
451 newest_checkpoint: Some(BlockHeight::from_u32(5)),
452 }
453 );
454 assert_eq!(
455 tree_facts(&shard_trees.sapling, BlockHeight::from_u32(5)),
456 TreeTruncationFacts {
457 checkpoint_at_target: None,
458 newest_checkpoint: Some(BlockHeight::from_u32(0)),
459 }
460 );
461 assert_eq!(
462 tree_facts(&shard_trees.ironwood, BlockHeight::from_u32(5)),
463 tree_facts(&shard_trees.sapling, BlockHeight::from_u32(5)),
464 );
465
466 assert_eq!(
467 tree_facts(&shard_trees.orchard, BlockHeight::from_u32(4)),
468 TreeTruncationFacts {
469 checkpoint_at_target: None,
470 newest_checkpoint: Some(BlockHeight::from_u32(5)),
471 }
472 );
473 }
474}
475
476/// Property tests over generated checkpoint sets, complementing the
477/// exhaustive facts-level sweep in `test`: the facts here are derived
478/// from a whole generated set, as `gather` would derive them from a
479/// store, so the properties quantify over tree histories rather than
480/// pre-digested facts.
481#[cfg(test)]
482mod properties {
483 use std::collections::BTreeSet;
484
485 use proptest::prelude::*;
486
487 use super::*;
488
489 /// The facts `gather` would report for a tree holding exactly
490 /// `checkpoints`.
491 fn facts_for_set(checkpoints: &BTreeSet<u32>, target: u32) -> TreeTruncationFacts {
492 TreeTruncationFacts {
493 checkpoint_at_target: checkpoints
494 .contains(&target)
495 .then(|| BlockHeight::from_u32(target)),
496 newest_checkpoint: checkpoints
497 .last()
498 .map(|&height| BlockHeight::from_u32(height)),
499 }
500 }
501
502 fn checkpoint_sets() -> impl Strategy<Value = BTreeSet<u32>> {
503 proptest::collection::btree_set(0u32..=1_000, 0..16)
504 }
505
506 proptest! {
507 /// Agreement with a brute-force model computed directly from the
508 /// checkpoint set.
509 #[test]
510 fn pool_outcome_agrees_with_the_set_model(
511 checkpoints in checkpoint_sets(),
512 target in 0u32..=1_000,
513 ) {
514 let outcome = plan_pool_truncation(
515 facts_for_set(&checkpoints, target),
516 BlockHeight::from_u32(target),
517 );
518 let expected = if checkpoints.contains(&target) {
519 PoolTruncation::ToCheckpoint {
520 checkpoint: BlockHeight::from_u32(target),
521 }
522 } else {
523 match checkpoints.last() {
524 Some(&newest) if newest > target => PoolTruncation::RequiresRescan {
525 newest_checkpoint: BlockHeight::from_u32(newest),
526 },
527 _ => PoolTruncation::Untouched,
528 }
529 };
530 prop_assert_eq!(outcome, expected);
531 }
532
533 /// The class property behind the migrated-wallet wipe: a tree
534 /// with no checkpoint above the target is never condemned to
535 /// rescan.
536 #[test]
537 fn nothing_above_target_is_never_condemned(
538 candidates in checkpoint_sets(),
539 target in 0u32..=1_000,
540 ) {
541 // Constructed, not assumed: keeping only checkpoints at or
542 // below the target samples the property's whole domain
543 // without rejects (a prop_assume here starved the runner).
544 let checkpoints: BTreeSet<u32> = candidates
545 .into_iter()
546 .filter(|&height| height <= target)
547 .collect();
548 let outcome = plan_pool_truncation(
549 facts_for_set(&checkpoints, target),
550 BlockHeight::from_u32(target),
551 );
552 prop_assert!(
553 !matches!(outcome, PoolTruncation::RequiresRescan { .. }),
554 "a tree with nothing above target {} was condemned to rescan: {:?}",
555 target,
556 outcome,
557 );
558 }
559
560 /// Monotonicity in the target: once a tree is `Untouched` at a
561 /// target, every higher target leaves it untouched too, since moving
562 /// the truncation point up can never invent state above it.
563 #[test]
564 fn untouched_is_upward_closed(
565 checkpoints in checkpoint_sets(),
566 target in 0u32..1_000,
567 ) {
568 let outcome_at = |t: u32| {
569 plan_pool_truncation(facts_for_set(&checkpoints, t), BlockHeight::from_u32(t))
570 };
571 if matches!(outcome_at(target), PoolTruncation::Untouched) {
572 prop_assert!(matches!(outcome_at(target + 1), PoolTruncation::Untouched));
573 }
574 }
575
576 /// Totality: every input falls in exactly the region the
577 /// wallet-state rules name, and the planner never panics.
578 #[test]
579 fn planner_is_total_over_its_regions(
580 birthday in 0u32..=1_000,
581 highest_scanned_height in 0u32..=1_000,
582 target in 0u32..=1_000,
583 ) {
584 let plan = plan_truncation(
585 WalletTruncationState {
586 birthday: BlockHeight::from_u32(birthday),
587 highest_scanned_height: BlockHeight::from_u32(highest_scanned_height),
588 },
589 BlockHeight::from_u32(target),
590 );
591 if target == 0 || target < birthday {
592 prop_assert_eq!(plan, TruncationPlan::ClearAll);
593 } else if target > highest_scanned_height {
594 prop_assert_eq!(plan, TruncationPlan::NoOp);
595 } else {
596 prop_assert!(
597 matches!(plan, TruncationPlan::Truncate { height }
598 if height == BlockHeight::from_u32(target)),
599 "expected Truncate at height {}, got {:?}",
600 target,
601 plan,
602 );
603 }
604 }
605 }
606}