Skip to main content

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}