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}