Skip to main content

zcash_client_backend/data_api/
anchor_retention.rs

1//! Durable anchor-checkpoint retention: the block-height grid on which note commitment tree
2//! checkpoints are kept alive past the ordinary pruning window.
3//!
4//! A [ZIP 318] pool migration proves each pool-crossing transfer against the note commitment tree
5//! state at a BOUNDARY block rather than at the chain tip, so that many wallets' transfers share a
6//! small set of common anchors instead of each pinning a unique recent block. A transfer is proved
7//! long after its boundary has passed, so the boundary's checkpoint must still be present in the
8//! wallet's tree at proving time — ordinary checkpoint pruning would have discarded it. Retention
9//! exempts the boundary checkpoints from that pruning.
10//!
11//! [`AnchorRetentionInterval`] defines the grid, and owns the arithmetic that decides whether a
12//! height is a boundary. The migration scheduler MUST draw its anchors from the same grid the
13//! wallet retains: a transfer anchored to a height the wallet did not retain cannot be proved. See
14//! [`WalletRead::anchor_retention_interval`], the accessor through which a migration reads the grid
15//! back off the wallet that maintains it.
16//!
17//! [ZIP 318]: https://zips.z.cash/zip-0318
18//! [`WalletRead::anchor_retention_interval`]: super::WalletRead::anchor_retention_interval
19
20use std::collections::BTreeSet;
21
22use zcash_protocol::{
23    consensus::BlockHeight,
24    zip318::{AnchorGrid, BlockSpacing},
25};
26
27/// The interval, in reference blocks, between the durable anchor checkpoints a wallet retains.
28///
29/// This is a re-export of [`zcash_protocol::zip318::AnchorBucketInterval`], the single definition
30/// shared with `zcash_pool_migration`. The grid a wallet retains its checkpoints on and the grid a
31/// pool-crossing transfer anchors to must be the same, so they are one type rather than two kept
32/// aligned by a conversion.
33///
34/// The value recommended for use with this crate is supplied by the [`Default`] implementation,
35/// which is `AnchorBucketInterval::ZIP_318`.
36pub use zcash_protocol::zip318::AnchorBucketInterval as AnchorRetentionInterval;
37
38/// The anchor-retention policy in force while a range of blocks is being added to the wallet: the
39/// set of grids whose boundaries are retained, the block spacing that places those boundaries, and
40/// the height from which retention applies.
41///
42/// Retention applies only from [`Self::from_height`] upward: below the network upgrade that
43/// introduces the destination pool there are no migration transfers to anchor, so retaining
44/// checkpoints there would consume storage to no purpose.
45///
46/// The policy holds a SET of intervals rather than one because a wallet may owe retention to more
47/// than one grid at a time. The note commitment trees are wallet-wide while a pool migration is
48/// per-account, so two accounts migrating under different intervals each need their own boundaries
49/// kept; and a migration already committed under one grid must keep being retained even if the
50/// wallet is later configured with a different one, or the boundaries its transfers are anchored to
51/// would be pruned out from under it.
52#[derive(Clone, Debug, PartialEq, Eq)]
53pub struct AnchorRetention {
54    from_height: BlockHeight,
55    spacing: BlockSpacing,
56    intervals: BTreeSet<AnchorRetentionInterval>,
57}
58
59impl AnchorRetention {
60    /// Constructs a retention policy that retains the checkpoint at every boundary of `interval`
61    /// on a chain with the given `spacing`, at or above `from_height`.
62    pub fn new(
63        from_height: BlockHeight,
64        spacing: BlockSpacing,
65        interval: AnchorRetentionInterval,
66    ) -> Self {
67        Self {
68            from_height,
69            spacing,
70            intervals: core::iter::once(interval).collect(),
71        }
72    }
73
74    /// Constructs a retention policy that retains the checkpoint at every height that is a boundary
75    /// of ANY of `intervals` on a chain with the given `spacing`, at or above `from_height`. Returns
76    /// `None` if `intervals` is empty, there then being nothing to retain.
77    pub fn union(
78        from_height: BlockHeight,
79        spacing: BlockSpacing,
80        intervals: impl IntoIterator<Item = AnchorRetentionInterval>,
81    ) -> Option<Self> {
82        let intervals: BTreeSet<AnchorRetentionInterval> = intervals.into_iter().collect();
83        (!intervals.is_empty()).then_some(Self {
84            from_height,
85            spacing,
86            intervals,
87        })
88    }
89
90    /// The height at or above which anchor retention applies (the activation height of the network
91    /// upgrade that enables pool migration).
92    pub fn from_height(&self) -> BlockHeight {
93        self.from_height
94    }
95
96    /// The grids whose boundaries are retained as durable anchors.
97    pub fn intervals(&self) -> &BTreeSet<AnchorRetentionInterval> {
98        &self.intervals
99    }
100
101    /// The block spacing that places the boundaries of [`Self::intervals`].
102    pub fn spacing(&self) -> BlockSpacing {
103        self.spacing
104    }
105
106    /// The boundary grid of each retained interval.
107    fn grids(&self) -> impl Iterator<Item = AnchorGrid> + '_ {
108        self.intervals
109            .iter()
110            .map(|interval| AnchorGrid::new(*interval, self.spacing))
111    }
112
113    /// Returns whether the checkpoint at `height` should be retained as a durable anchor under this
114    /// policy: `height` is at or above [`Self::from_height`] and is a boundary of at least one of
115    /// [`Self::intervals`].
116    pub fn retains(&self, height: BlockHeight) -> bool {
117        height >= self.from_height && self.grids().any(|grid| grid.is_boundary(height))
118    }
119
120    /// Returns every height in `range` whose checkpoint this policy retains, ascending.
121    ///
122    /// This is the enumeration form of [`Self::retains`]: a height is in the result exactly when
123    /// `range` contains it and [`Self::retains`] holds for it. It exists so that a caller adding a
124    /// scanned range of blocks can CREATE the checkpoints the policy will need retained — a
125    /// boundary block containing no note commitments produces no checkpoint of its own, and a
126    /// policy can only keep alive a checkpoint that exists.
127    pub fn retained_in_range(
128        &self,
129        range: core::ops::RangeInclusive<BlockHeight>,
130    ) -> BTreeSet<BlockHeight> {
131        let start = core::cmp::max(*range.start(), self.from_height);
132        let end = *range.end();
133        self.grids()
134            .flat_map(|grid| {
135                // The grid's rounding saturates at the top of the height range, so a height it
136                // returns there may not be a boundary, and the walk stops once it stops rising.
137                core::iter::successors(Some(grid.boundary_at_or_above(start)), move |boundary| {
138                    let next = grid.boundary_after(*boundary);
139                    (next > *boundary).then_some(next)
140                })
141                .take_while(move |boundary| *boundary <= end)
142                .filter(move |boundary| grid.is_boundary(*boundary))
143            })
144            .collect()
145    }
146}
147
148#[cfg(test)]
149mod tests {
150    use super::*;
151
152    use core::num::NonZeroU32;
153
154    use proptest::prelude::*;
155
156    fn interval(blocks: u32) -> AnchorRetentionInterval {
157        AnchorRetentionInterval::custom(NonZeroU32::new(blocks).expect("nonzero"))
158    }
159
160    /// An empty grid set means there is nothing to retain, which the constructor reports as `None`
161    /// rather than as a policy that silently retains nothing.
162    #[test]
163    fn union_of_no_intervals_is_none() {
164        assert!(
165            AnchorRetention::union(BlockHeight::from_u32(0), BlockSpacing::UNIFORM, []).is_none()
166        );
167        assert!(
168            AnchorRetention::union(
169                BlockHeight::from_u32(0),
170                BlockSpacing::UNIFORM,
171                [AnchorRetentionInterval::ZIP_318]
172            )
173            .is_some()
174        );
175    }
176
177    #[test]
178    fn zip_318_is_the_default() {
179        assert_eq!(AnchorRetentionInterval::default(), interval(144));
180        assert_eq!(AnchorRetentionInterval::ZIP_318.block_count().get(), 144);
181    }
182
183    #[test]
184    fn zip_318_boundaries() {
185        let i = AnchorRetentionInterval::ZIP_318;
186        for (height, below, above) in [
187            (0u32, 0u32, 0u32),
188            (1, 0, 144),
189            (143, 0, 144),
190            (144, 144, 144),
191            (145, 144, 288),
192            (1_000_000, 999_936, 1_000_080),
193        ] {
194            let h = BlockHeight::from_u32(height);
195            assert_eq!(
196                u32::from(AnchorGrid::new(i, BlockSpacing::UNIFORM).boundary_at_or_below(h)),
197                below,
198                "below {height}"
199            );
200            assert_eq!(
201                u32::from(AnchorGrid::new(i, BlockSpacing::UNIFORM).boundary_at_or_above(h)),
202                above,
203                "above {height}"
204            );
205        }
206        assert!(AnchorGrid::new(i, BlockSpacing::UNIFORM).is_boundary(BlockHeight::from_u32(288)));
207        assert!(!AnchorGrid::new(i, BlockSpacing::UNIFORM).is_boundary(BlockHeight::from_u32(289)));
208    }
209
210    /// The enumeration clamps to the policy's floor, includes both endpoints of the range, and
211    /// yields the union of every constituent grid's boundaries.
212    #[test]
213    fn retained_in_range_examples() {
214        let h = BlockHeight::from_u32;
215        let heights = |policy: &AnchorRetention, lo: u32, hi: u32| {
216            policy
217                .retained_in_range(h(lo)..=h(hi))
218                .into_iter()
219                .map(u32::from)
220                .collect::<Vec<_>>()
221        };
222
223        let single = AnchorRetention::new(h(25), BlockSpacing::UNIFORM, interval(10));
224        // The floor excludes 20 even though it is a boundary within the range.
225        assert_eq!(heights(&single, 15, 55), vec![30, 40, 50]);
226        // Both endpoints are inclusive.
227        assert_eq!(heights(&single, 30, 50), vec![30, 40, 50]);
228        // An inverted range is empty rather than an error.
229        assert_eq!(heights(&single, 50, 30), Vec::<u32>::new());
230        // A range wholly below the floor is empty.
231        assert_eq!(heights(&single, 0, 24), Vec::<u32>::new());
232
233        let union =
234            AnchorRetention::union(h(0), BlockSpacing::UNIFORM, [interval(10), interval(15)])
235                .expect("non-empty");
236        // Multiples of 10 and 15, deduplicated at the common multiple 30.
237        assert_eq!(heights(&union, 1, 45), vec![10, 15, 20, 30, 40, 45]);
238    }
239
240    proptest! {
241        /// Rounding down yields a boundary that does not exceed the height and is within one
242        /// interval of it; rounding up yields a boundary that is not below it, likewise within one
243        /// interval (except where the round-up saturates).
244        #[test]
245        fn boundary_rounding_props(h in 0u32..5_000_000, blocks in 1u32..10_000) {
246            let i = interval(blocks);
247            let height = BlockHeight::from_u32(h);
248
249            let below = u32::from(AnchorGrid::new(i, BlockSpacing::UNIFORM).boundary_at_or_below(height));
250            prop_assert!(AnchorGrid::new(i, BlockSpacing::UNIFORM).is_boundary(BlockHeight::from_u32(below)));
251            prop_assert!(below <= h);
252            prop_assert!(h - below < blocks);
253
254            let above = u32::from(AnchorGrid::new(i, BlockSpacing::UNIFORM).boundary_at_or_above(height));
255            prop_assert!(AnchorGrid::new(i, BlockSpacing::UNIFORM).is_boundary(BlockHeight::from_u32(above)));
256            prop_assert!(above >= h);
257            prop_assert!(above - h < blocks);
258        }
259
260        /// A height is a boundary exactly when rounding it in either direction leaves it fixed.
261        #[test]
262        fn is_boundary_agrees_with_rounding(h in 0u32..5_000_000, blocks in 1u32..10_000) {
263            let i = interval(blocks);
264            let height = BlockHeight::from_u32(h);
265            prop_assert_eq!(AnchorGrid::new(i, BlockSpacing::UNIFORM).is_boundary(height), AnchorGrid::new(i, BlockSpacing::UNIFORM).boundary_at_or_below(height) == height);
266            prop_assert_eq!(AnchorGrid::new(i, BlockSpacing::UNIFORM).is_boundary(height), AnchorGrid::new(i, BlockSpacing::UNIFORM).boundary_at_or_above(height) == height);
267        }
268
269        /// A policy retains exactly the boundaries at or above its floor.
270        #[test]
271        fn retention_policy_props(
272            floor in 0u32..1_000_000,
273            offset in 0u32..2_000,
274            blocks in 1u32..500,
275        ) {
276            let i = interval(blocks);
277            let policy = AnchorRetention::new(BlockHeight::from_u32(floor), BlockSpacing::UNIFORM, i);
278            let h = BlockHeight::from_u32(floor.saturating_sub(1_000).saturating_add(offset));
279            prop_assert_eq!(policy.retains(h), h >= BlockHeight::from_u32(floor) && AnchorGrid::new(i, BlockSpacing::UNIFORM).is_boundary(h));
280        }
281
282        /// Membership in the enumerated range agrees exactly with the predicate: `h` is in
283        /// `retained_in_range(lo..=hi)` iff `lo <= h <= hi` and `retains(h)`.
284        #[test]
285        fn retained_in_range_agrees_with_retains(
286            floor in 0u32..100_000,
287            lo in 0u32..200_000,
288            len in 0u32..2_000,
289            a in 1u32..500,
290            b in 1u32..500,
291        ) {
292            let f = BlockHeight::from_u32(floor);
293            let policy = AnchorRetention::union(f, BlockSpacing::UNIFORM, [interval(a), interval(b)]).expect("non-empty");
294            let (lo, hi) = (BlockHeight::from_u32(lo), BlockHeight::from_u32(lo + len));
295            let enumerated = policy.retained_in_range(lo..=hi);
296
297            for h in u32::from(lo)..=u32::from(hi) {
298                let h = BlockHeight::from_u32(h);
299                prop_assert_eq!(
300                    enumerated.contains(&h),
301                    policy.retains(h),
302                    "height {:?}", h
303                );
304            }
305            // Nothing outside the range is ever emitted.
306            for h in &enumerated {
307                prop_assert!((lo..=hi).contains(h));
308            }
309        }
310
311        /// A union policy retains a height iff ANY of its grids does, so adding a grid only ever
312        /// widens what survives pruning — a migration committed under one interval keeps its
313        /// boundaries even once the wallet is retaining on another.
314        #[test]
315        fn union_retains_every_constituent_grid(
316            floor in 0u32..100_000,
317            offset in 0u32..5_000,
318            a in 1u32..500,
319            b in 1u32..500,
320        ) {
321            let (ia, ib) = (interval(a), interval(b));
322            let f = BlockHeight::from_u32(floor);
323            let union = AnchorRetention::union(f, BlockSpacing::UNIFORM, [ia, ib]).expect("non-empty");
324            let h = BlockHeight::from_u32(floor.saturating_add(offset));
325
326            prop_assert_eq!(
327                union.retains(h),
328                AnchorRetention::new(f, BlockSpacing::UNIFORM, ia).retains(h) || AnchorRetention::new(f, BlockSpacing::UNIFORM, ib).retains(h)
329            );
330            // Widening never removes anything either grid alone would have kept.
331            for single in [ia, ib] {
332                if AnchorRetention::new(f, BlockSpacing::UNIFORM, single).retains(h) {
333                    prop_assert!(union.retains(h));
334                }
335            }
336        }
337    }
338
339    proptest! {
340        /// From NU7 activation, retention keeps only the boundaries of the scaled grid, and the
341        /// enumeration form agrees with the predicate across activation.
342        #[test]
343        fn retention_follows_the_scaled_grid_from_nu7(
344            activation in 0u32..2_000,
345            blocks in 1u32..50,
346            start in 0u32..3_000,
347            len in 0u32..1_000,
348        ) {
349            let spacing = BlockSpacing::with_nu7_activation(BlockHeight::from_u32(activation));
350            let policy = AnchorRetention::new(BlockHeight::from_u32(0), spacing, interval(blocks));
351            let range = BlockHeight::from_u32(start)..=BlockHeight::from_u32(start + len);
352            let expected: BTreeSet<BlockHeight> = (start..=start + len)
353                .map(BlockHeight::from_u32)
354                .filter(|h| policy.retains(*h))
355                .collect();
356            prop_assert_eq!(policy.retained_in_range(range), expected);
357            for h in start..=start + len {
358                if h >= activation && policy.retains(BlockHeight::from_u32(h)) {
359                    prop_assert_eq!(
360                        h % (zcash_protocol::consensus::NU7_POW_TARGET_SPACING_RATIO * blocks),
361                        0
362                    );
363                }
364            }
365        }
366    }
367}
368
369/// The [ZIP 318] pool-migration parameters in force for a particular wallet: the specified values,
370/// with the anchor bucket grid taken from the grid that wallet actually retains, on the block
371/// spacing of the wallet's network.
372///
373/// The wallet is the authority on the grid, because it is the side that keeps the checkpoints
374/// alive. A crossing anchored to a boundary the wallet did not retain cannot be proved, so every
375/// decision that depends on the grid — whether to bucket an anchor, and whether the resulting
376/// transaction is a canonical crossing — must consult the same source. Reading the grid from the
377/// network defaults instead would agree with the wallet only by coincidence, and silently disagree
378/// for any wallet configured with a different interval.
379///
380/// Obtained from [`WalletRead::pool_migration_params`].
381///
382/// [ZIP 318]: https://zips.z.cash/zip-0318
383/// [`WalletRead::pool_migration_params`]: super::WalletRead::pool_migration_params
384#[derive(Clone, Copy, Debug, PartialEq, Eq)]
385pub struct PoolMigrationParams {
386    spacing: BlockSpacing,
387    interval: AnchorRetentionInterval,
388}
389
390impl PoolMigrationParams {
391    /// Constructs the parameters for a wallet on a chain with the given `spacing` that retains
392    /// anchors on `interval`. Every other ZIP 318 value takes its specified default.
393    pub fn new(spacing: BlockSpacing, interval: AnchorRetentionInterval) -> Self {
394        Self { spacing, interval }
395    }
396}
397
398impl zcash_protocol::zip318::PoolMigrationConstants for PoolMigrationParams {
399    fn anchor_bucket_interval(&self) -> AnchorRetentionInterval {
400        self.interval
401    }
402
403    fn block_spacing(&self) -> BlockSpacing {
404        self.spacing
405    }
406}