use pepper_sync::wallet::PoolActivation;
use zcash_protocol::consensus::BlockHeight;
use crate::wallet::error::WalletError;
use super::params::MigrationParams;
use super::parts::{PartId, PartRecord, PartState};
fn random_target_in_bucket(
bucket: u64,
rng: &mut impl CryptoRng,
params: &MigrationParams,
) -> BlockHeight {
let boundary = u32::from(boundary_of(bucket, params.bucket_modulus));
let offset = SchedulingParams::ZIP_318.transfer_delay().draw(rng);
BlockHeight::from_u32(boundary + offset)
}
const TARGET_BLOCK_SPACING_SECONDS: u64 = 75;
use std::num::NonZeroU32;
use rand::CryptoRng;
use zcash_pool_migration::scheduling::{AnchorBucketInterval, SchedulingParams};
pub fn canonical_expiry_height(transmission_height: BlockHeight) -> BlockHeight {
zcash_pool_migration::scheduling::expiry_height(transmission_height)
}
pub fn bucket_index(height: BlockHeight, bucket_modulus: u32) -> u64 {
u64::from(u32::from(height)) / u64::from(bucket_modulus)
}
pub fn boundary_of(bucket_index: u64, bucket_modulus: u32) -> BlockHeight {
BlockHeight::from_u32(
u32::try_from(bucket_index * u64::from(bucket_modulus))
.expect("bucket boundaries fit a block height"),
)
}
#[cfg(test)]
fn previous_boundary(height: BlockHeight, bucket_modulus: u32) -> BlockHeight {
boundary_of(bucket_index(height, bucket_modulus), bucket_modulus)
}
#[derive(Debug, Clone, Copy)]
pub struct AnchorFloor {
activation: PoolActivation,
note_confirmed_at: Option<BlockHeight>,
}
impl AnchorFloor {
#[must_use]
pub fn new(activation: PoolActivation, note_confirmed_at: Option<BlockHeight>) -> Self {
AnchorFloor {
activation,
note_confirmed_at,
}
}
#[must_use]
pub fn lowest_anchor_bucket(&self, bucket_modulus: u32) -> u64 {
let era = bucket_index(self.activation.height(), bucket_modulus) + 1;
let anchorability = self
.note_confirmed_at
.map_or(0, |height| bucket_at_or_after(height, bucket_modulus));
era.max(anchorability)
}
#[must_use]
pub fn earliest_window(&self, bucket_modulus: u32) -> u64 {
self.lowest_anchor_bucket(bucket_modulus) + 1
}
}
pub fn draw_anchor_bucket(
window: u64,
floor: &AnchorFloor,
rng: &mut impl CryptoRng,
bucket_modulus: u32,
) -> Option<u64> {
let interval = AnchorBucketInterval::custom(
NonZeroU32::new(bucket_modulus).expect("bucket modulus is nonzero by params invariant"),
);
let anchor = zcash_pool_migration::scheduling::draw_anchor_boundary(
interval,
floor.activation.height(),
floor
.note_confirmed_at
.unwrap_or_else(|| BlockHeight::from_u32(0)),
boundary_of(window, bucket_modulus),
rng,
)?;
Some(u64::from(u32::from(anchor)) / u64::from(bucket_modulus))
}
pub fn first_permitted_bucket(
now_height: BlockHeight,
floor: &AnchorFloor,
params: &MigrationParams,
) -> u64 {
(bucket_index(now_height, params.bucket_modulus) + 1)
.max(floor.earliest_window(params.bucket_modulus))
}
pub fn first_ironwood_era_window_boundary(
activation: PoolActivation,
bucket_modulus: u32,
) -> BlockHeight {
boundary_of(
bucket_index(activation.height(), bucket_modulus) + 2,
bucket_modulus,
)
}
fn bucket_at_or_after(height: BlockHeight, bucket_modulus: u32) -> u64 {
u64::from(u32::from(height)).div_ceil(u64::from(bucket_modulus))
}
#[allow(clippy::result_large_err)]
pub fn place(
part: &mut PartRecord,
bucket: u64,
floor: &AnchorFloor,
rng: &mut impl CryptoRng,
params: &MigrationParams,
) -> Result<(), WalletError> {
let anchor = anchor_for(bucket, floor, rng, params)?;
transition_to_bucket(part, bucket)?;
part.anchor_bucket = Some(anchor);
part.target_height = Some(random_target_in_bucket(bucket, rng, params));
Ok(())
}
#[allow(clippy::result_large_err)]
pub fn place_immediate(
part: &mut PartRecord,
bucket: u64,
floor: &AnchorFloor,
rng: &mut impl CryptoRng,
params: &MigrationParams,
) -> Result<(), WalletError> {
let anchor = anchor_for(bucket, floor, rng, params)?;
transition_to_bucket(part, bucket)?;
part.anchor_bucket = Some(anchor);
part.target_height = None;
Ok(())
}
#[allow(clippy::result_large_err)]
fn anchor_for(
bucket: u64,
floor: &AnchorFloor,
rng: &mut impl CryptoRng,
params: &MigrationParams,
) -> Result<u64, WalletError> {
draw_anchor_bucket(bucket, floor, rng, params.bucket_modulus).ok_or(
WalletError::MigrationNoLegalAnchor {
window: bucket,
lowest_anchor: floor.lowest_anchor_bucket(params.bucket_modulus),
},
)
}
#[allow(clippy::result_large_err)]
fn transition_to_bucket(part: &mut PartRecord, bucket: u64) -> Result<(), WalletError> {
match part.state {
PartState::Bound => part.assign(bucket),
PartState::Expired => part.reassign(bucket),
_ => part.shift(bucket),
}
}
#[allow(clippy::result_large_err)]
pub fn plan_schedule(
parts: &mut [PartRecord],
now_height: BlockHeight,
activation: PoolActivation,
note_confirmed_at: impl Fn(&PartRecord) -> Option<BlockHeight>,
params: &MigrationParams,
rng: &mut impl CryptoRng,
) -> Result<(), WalletError> {
let unassigned: Vec<usize> = parts
.iter()
.enumerate()
.filter(|(_, part)| part.state == PartState::Bound)
.map(|(index, _)| index)
.collect();
if unassigned.is_empty() {
return Ok(());
}
let k = u64::try_from(unassigned.len())
.expect("part count fits u64")
.div_ceil(u64::from(params.target_sessions.max(1)))
.clamp(1, u64::from(params.k_max.max(1)));
let mut ranked = unassigned;
ranked.sort_by_key(|&index| {
(
std::cmp::Reverse(parts[index].denomination),
parts[index].id,
)
});
let first_bucket = ranked.iter().fold(
bucket_index(now_height, params.bucket_modulus),
|earliest, &index| {
let floor = AnchorFloor::new(activation, note_confirmed_at(&parts[index]));
earliest.max(floor.earliest_window(params.bucket_modulus))
},
);
for (rank, index) in ranked.into_iter().enumerate() {
let floor = AnchorFloor::new(activation, note_confirmed_at(&parts[index]));
let bucket = first_bucket + rank as u64 / k;
place(&mut parts[index], bucket, &floor, rng, params)?;
}
Ok(())
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TransmissionWindow {
pub bucket_index: u64,
pub boundary: BlockHeight,
pub part_ids: Vec<PartId>,
pub window_opens_unix_time: u64,
pub latest_target_unix_time: u64,
}
fn estimated_unix_at(height: BlockHeight, now_height: BlockHeight, now_unix: u64) -> u64 {
let blocks_until = u64::from(u32::from(height).saturating_sub(u32::from(now_height)));
now_unix + blocks_until * TARGET_BLOCK_SPACING_SECONDS
}
pub fn part_in_current_bucket(part: &PartRecord, current_bucket: u64) -> bool {
matches!(part.state, PartState::Assigned | PartState::Signed)
&& part.bucket_index == Some(current_bucket)
}
pub fn upcoming_windows(
parts: &[PartRecord],
now_height: BlockHeight,
now_unix: u64,
horizon: u64,
params: &MigrationParams,
) -> Vec<TransmissionWindow> {
let current_bucket = bucket_index(now_height, params.bucket_modulus);
let mut buckets: std::collections::BTreeMap<u64, (Vec<PartId>, Option<BlockHeight>)> =
std::collections::BTreeMap::new();
for part in parts {
if !matches!(part.state, PartState::Assigned | PartState::Signed) {
continue;
}
let Some(bucket) = part.bucket_index else {
continue;
};
if bucket > current_bucket && bucket <= current_bucket.saturating_add(horizon) {
let (part_ids, latest_target) = buckets.entry(bucket).or_default();
part_ids.push(part.id);
*latest_target = (*latest_target).max(part.target_height);
}
}
buckets
.into_iter()
.map(|(bucket, (part_ids, latest_target))| {
let boundary = boundary_of(bucket, params.bucket_modulus);
let latest_target = latest_target.unwrap_or(boundary + 1);
TransmissionWindow {
bucket_index: bucket,
boundary,
part_ids,
window_opens_unix_time: estimated_unix_at(boundary, now_height, now_unix),
latest_target_unix_time: estimated_unix_at(latest_target, now_height, now_unix),
}
})
.collect()
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct WindowReport {
pub bucket_index: u64,
pub boundary: BlockHeight,
pub close: BlockHeight,
pub is_current: bool,
pub parts_total: u32,
pub parts_confirmed: u32,
pub value_total: u64,
pub value_migrated: u64,
}
pub fn window_timeline(
parts: &[PartRecord],
now_height: BlockHeight,
params: &MigrationParams,
) -> Vec<WindowReport> {
#[derive(Default)]
struct Tally {
parts_total: u32,
parts_confirmed: u32,
value_total: u64,
value_migrated: u64,
}
let current_bucket = bucket_index(now_height, params.bucket_modulus);
let mut buckets: std::collections::BTreeMap<u64, Tally> = std::collections::BTreeMap::new();
buckets.entry(current_bucket).or_default();
for part in parts {
let Some(bucket) = part.bucket_index else {
continue;
};
let tally = buckets.entry(bucket).or_default();
tally.parts_total += 1;
tally.value_total += part.denomination;
if matches!(part.state, PartState::Confirmed { .. }) {
tally.parts_confirmed += 1;
tally.value_migrated += part.denomination;
}
}
buckets
.into_iter()
.map(|(bucket, tally)| WindowReport {
bucket_index: bucket,
boundary: boundary_of(bucket, params.bucket_modulus),
close: boundary_of(bucket + 1, params.bucket_modulus),
is_current: bucket == current_bucket,
parts_total: tally.parts_total,
parts_confirmed: tally.parts_confirmed,
value_total: tally.value_total,
value_migrated: tally.value_migrated,
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::ChainType;
use crate::wallet::migration::parts::BoundNote;
use pepper_sync::wallet::OutputId;
use proptest::prelude::*;
use zcash_pool_migration::scheduling::{ANCHOR_AGE_CAP, EXPIRY_MODULUS, EXPIRY_WINDOW};
use zcash_primitives::transaction::TxId;
fn params() -> MigrationParams {
MigrationParams::provisional(ChainType::Mainnet)
}
fn no_floor() -> PoolActivation {
PoolActivation::new_for_test(BlockHeight::from_u32(0))
}
fn old_notes(_part: &PartRecord) -> Option<BlockHeight> {
Some(BlockHeight::from_u32(0))
}
fn weakest_floor() -> AnchorFloor {
AnchorFloor::new(no_floor(), Some(BlockHeight::from_u32(0)))
}
fn bound_part(id: u32, denomination: u64) -> PartRecord {
PartRecord::new(
PartId(id),
denomination,
BoundNote {
output_id: OutputId::new(TxId::from_bytes([id as u8; 32]), id),
nullifier: [0; 32],
commitment: [0; 32],
},
)
}
fn parts_with_denominations(denominations: &[u64]) -> Vec<PartRecord> {
denominations
.iter()
.enumerate()
.map(|(id, &denomination)| bound_part(id as u32, denomination))
.collect()
}
#[test]
fn schedule_respects_the_era_floor() {
let params = params();
let mut parts = parts_with_denominations(&[1_000_000, 2_000_000]);
let now = BlockHeight::from_u32(10);
let activation = BlockHeight::from_u32(1_000);
plan_schedule(
&mut parts,
now,
PoolActivation::new_for_test(activation),
old_notes,
¶ms,
&mut crate::utils::system_rng(),
)
.unwrap();
for part in &parts {
let anchor = boundary_of(part.anchor_bucket.unwrap(), params.bucket_modulus);
assert!(
anchor > activation,
"part {:?} anchors at {anchor:?}, not strictly above the \
NU6.3 activation {activation:?}",
part.id,
);
assert!(
part.bucket_index.unwrap() > part.anchor_bucket.unwrap(),
"part {:?} must transmit above the bucket it anchors in",
part.id,
);
}
}
#[test]
fn drawn_anchors_land_inside_the_candidate_set() {
let params = params();
let activation = PoolActivation::new_for_test(BlockHeight::from_u32(1_000));
let note = BlockHeight::from_u32(2_000);
let floor = AnchorFloor::new(activation, Some(note));
let lowest = floor.lowest_anchor_bucket(params.bucket_modulus);
let window = lowest + 40;
for _ in 0..2_000 {
let anchor = draw_anchor_bucket(
window,
&floor,
&mut crate::utils::system_rng(),
params.bucket_modulus,
)
.expect("the set is non-empty forty buckets above the floor");
assert!(anchor < window, "the anchor is below the open window");
assert!(anchor >= lowest, "the anchor clears both floors");
assert!(
window - anchor <= u64::from(ANCHOR_AGE_CAP),
"the age {} exceeds the cap",
window - anchor
);
let boundary = boundary_of(anchor, params.bucket_modulus);
assert!(boundary > activation.height(), "anchor above activation");
assert!(boundary >= note, "anchor at or above its own note");
}
}
#[test]
fn a_window_with_no_legal_anchor_is_refused() {
let params = params();
let floor = AnchorFloor::new(
PoolActivation::new_for_test(BlockHeight::from_u32(0)),
Some(BlockHeight::from_u32(0)),
);
let lowest = floor.lowest_anchor_bucket(params.bucket_modulus);
assert!(
draw_anchor_bucket(
lowest,
&floor,
&mut crate::utils::system_rng(),
params.bucket_modulus
)
.is_none(),
"the lowest legal anchor cannot also be the window"
);
assert_eq!(
floor.earliest_window(params.bucket_modulus),
lowest + 1,
"the earliest window is one bucket above the lowest anchor"
);
let mut part = bound_part(0, 1_000_000);
assert!(matches!(
place(
&mut part,
lowest,
&floor,
&mut crate::utils::system_rng(),
¶ms
),
Err(WalletError::MigrationNoLegalAnchor { .. })
));
}
#[test]
fn place_draws_a_fresh_target_from_the_new_boundary() {
let params = params();
let mut part = bound_part(0, 1_000_000);
part.assign(1).unwrap();
let old_target = boundary_of(1, params.bucket_modulus) + 17;
part.target_height = Some(old_target);
part.mark_signed(TxId::from_bytes([9; 32]), old_target + 40, None)
.unwrap();
part.mark_expired().unwrap();
place(
&mut part,
5,
&weakest_floor(),
&mut crate::utils::system_rng(),
¶ms,
)
.unwrap();
assert_eq!(part.state, PartState::Assigned);
let boundary = u32::from(boundary_of(5, params.bucket_modulus));
let target = u32::from(
part.target_height
.expect("jittered placement draws a fresh target"),
);
let cap = SchedulingParams::ZIP_318.transfer_delay().cap().get();
assert!(
target >= boundary && target <= boundary + cap,
"the fresh target {target} must be drawn from bucket 5's boundary \
under the canonical delay law [{boundary}, {}]",
boundary + cap,
);
}
#[test]
fn schedule_fills_consecutive_buckets_largest_first() {
let params = params();
let denominations: Vec<u64> = (1..=13).map(|i| i * 1_000_000).collect();
let mut parts = parts_with_denominations(&denominations);
let now = BlockHeight::from_u32(10_000);
plan_schedule(
&mut parts,
now,
no_floor(),
old_notes,
¶ms,
&mut crate::utils::system_rng(),
)
.unwrap();
let first_bucket = bucket_index(now, params.bucket_modulus);
for part in &parts {
assert_eq!(part.state, PartState::Assigned);
let bucket = part.bucket_index.unwrap();
assert!(bucket >= first_bucket, "current or future bucket");
let boundary = u32::from(boundary_of(bucket, params.bucket_modulus));
let target = u32::from(part.target_height.unwrap());
let cap = SchedulingParams::ZIP_318.transfer_delay().cap().get();
assert!(target >= boundary && target <= boundary + cap);
}
for a in &parts {
for b in &parts {
if a.denomination > b.denomination {
assert!(a.bucket_index.unwrap() <= b.bucket_index.unwrap());
}
}
}
let distinct: std::collections::BTreeSet<u64> =
parts.iter().filter_map(|p| p.bucket_index).collect();
assert_eq!(distinct.len(), 5);
}
#[test]
fn window_timeline_tallies_windows_past_and_future() {
let params = params();
let mut done = bound_part(0, 2_000_000);
done.assign(3).unwrap();
done.mark_confirmed(BlockHeight::from_u32(3 * params.bucket_modulus + 5))
.unwrap();
let mut pending = bound_part(1, 1_000_000);
pending.assign(3).unwrap();
let mut ahead = bound_part(2, 5_000_000);
ahead.assign(5).unwrap();
let unassigned = bound_part(3, 1_000_000);
let now = boundary_of(3, params.bucket_modulus) + 10;
let timeline = window_timeline(&[done, pending, ahead, unassigned], now, ¶ms);
assert_eq!(
timeline.len(),
2,
"assigned buckets plus the current window, which bucket 3 already is"
);
let current = &timeline[0];
assert_eq!(current.bucket_index, 3);
assert_eq!(current.boundary, boundary_of(3, params.bucket_modulus));
assert_eq!(current.close, boundary_of(4, params.bucket_modulus));
assert!(current.is_current);
assert_eq!((current.parts_confirmed, current.parts_total), (1, 2));
assert_eq!(current.value_migrated, 2_000_000);
assert_eq!(current.value_total, 3_000_000);
let future = &timeline[1];
assert_eq!(future.bucket_index, 5);
assert!(!future.is_current);
assert_eq!((future.parts_confirmed, future.parts_total), (0, 1));
assert_eq!(future.value_migrated, 0);
}
#[test]
fn window_timeline_always_reports_the_current_window() {
let params = params();
let now = boundary_of(7, params.bucket_modulus) + 100;
let bare = window_timeline(&[], now, ¶ms);
assert_eq!(bare.len(), 1);
assert!(bare[0].is_current);
assert_eq!(bare[0].bucket_index, 7);
assert_eq!(bare[0].boundary, boundary_of(7, params.bucket_modulus));
assert_eq!(bare[0].close, boundary_of(8, params.bucket_modulus));
assert_eq!((bare[0].parts_total, bare[0].value_total), (0, 0));
let mut ahead = bound_part(0, 1_000_000);
ahead.assign(9).unwrap();
let timeline = window_timeline(&[ahead], now, ¶ms);
assert_eq!(timeline.len(), 2);
assert!(timeline[0].is_current);
assert_eq!(timeline[0].parts_total, 0);
assert_eq!(timeline[1].bucket_index, 9);
}
#[test]
fn part_in_current_bucket_ignores_the_target_height() {
let params = params();
let current_bucket = 40;
let mut part = bound_part(0, 1_000_000);
part.assign(current_bucket).unwrap();
part.target_height = Some(boundary_of(current_bucket, params.bucket_modulus) + 100);
assert!(
part_in_current_bucket(&part, current_bucket),
"Assigned with its target still ahead is due"
);
assert!(
!part_in_current_bucket(&part, current_bucket + 1),
"a part of another bucket is not due"
);
part.mark_confirmed(BlockHeight::from_u32(20_000)).unwrap();
assert!(
!part_in_current_bucket(&part, current_bucket),
"a confirmed part is not due"
);
}
#[test]
fn first_batch_opens_in_the_current_bucket_when_the_notes_are_settled() {
let params = params();
let mut parts = parts_with_denominations(&[3_000_000, 2_000_000, 1_000_000]);
let now = BlockHeight::from_u32(10_000);
plan_schedule(
&mut parts,
now,
no_floor(),
old_notes,
¶ms,
&mut crate::utils::system_rng(),
)
.unwrap();
let current_bucket = bucket_index(now, params.bucket_modulus);
let earliest = parts.iter().filter_map(|p| p.bucket_index).min().unwrap();
assert_eq!(
earliest, current_bucket,
"notes settled a bucket or more ago pay nothing for the anchor age: \
the current bucket still has legal anchors below it"
);
for part in &parts {
assert!(
part.anchor_bucket.unwrap() < part.bucket_index.unwrap(),
"even in the open window the anchor is a closed bucket"
);
}
}
#[test]
fn a_fresh_split_costs_one_window() {
let params = params();
let mut parts = parts_with_denominations(&[3_000_000, 2_000_000, 1_000_000]);
let now = BlockHeight::from_u32(10_000);
let split_confirmed = now - 3;
plan_schedule(
&mut parts,
now,
no_floor(),
|_| Some(split_confirmed),
¶ms,
&mut crate::utils::system_rng(),
)
.unwrap();
let earliest = parts.iter().filter_map(|p| p.bucket_index).min().unwrap();
assert_eq!(
earliest,
bucket_index(now, params.bucket_modulus) + 2,
"the anchor clears the fresh notes and the window clears the anchor"
);
for part in &parts {
let anchor = boundary_of(part.anchor_bucket.unwrap(), params.bucket_modulus);
assert!(
anchor >= split_confirmed,
"every part must anchor at or above its own notes"
);
}
}
proptest! {
#[test]
fn schedule_properties(
denominations in proptest::collection::vec(1u64..=10_000_000_000, 1..80),
now in 0u32..=10_000_000,
target_sessions in 1u32..=12,
k_max in 1u32..=16,
) {
let mut params = params();
params.target_sessions = target_sessions;
params.k_max = k_max;
let now = BlockHeight::from_u32(now);
let mut parts = parts_with_denominations(&denominations);
plan_schedule(
&mut parts,
now,
no_floor(),
old_notes,
¶ms,
&mut crate::utils::system_rng(),
).unwrap();
let k = (denominations.len() as u64)
.div_ceil(u64::from(target_sessions))
.clamp(1, u64::from(k_max));
let mut cohort_sizes: std::collections::BTreeMap<u64, u64> = Default::default();
let current_bucket = bucket_index(now, params.bucket_modulus);
for part in &parts {
prop_assert_eq!(part.state, PartState::Assigned, "total");
let bucket = part.bucket_index.unwrap();
prop_assert!(bucket >= current_bucket, "current or future buckets");
*cohort_sizes.entry(bucket).or_default() += 1;
let boundary = u32::from(boundary_of(bucket, params.bucket_modulus));
let target = u32::from(part.target_height.unwrap());
let cap = SchedulingParams::ZIP_318.transfer_delay().cap().get();
prop_assert!(
target >= boundary && target <= boundary + cap,
"target drawn from the boundary under the canonical delay law"
);
}
prop_assert!(cohort_sizes.values().all(|&size| size <= k), "k bound");
prop_assert_eq!(
cohort_sizes.len() as u64,
(denominations.len() as u64).div_ceil(k),
"session count"
);
}
#[test]
fn bucket_algebra(height in 0u32..=100_000_000, modulus in 1u32..=4096) {
let height = BlockHeight::from_u32(height);
let boundary = previous_boundary(height, modulus);
prop_assert!(boundary <= height);
prop_assert_eq!(u32::from(boundary) % modulus, 0);
prop_assert!(u32::from(height) - u32::from(boundary) < modulus);
prop_assert_eq!(
bucket_index(boundary, modulus),
bucket_index(height, modulus)
);
}
}
#[test]
fn upcoming_windows_lists_future_windows_within_the_horizon() {
let params = params();
let mut parts = parts_with_denominations(&[100, 200, 300, 400, 500, 600, 700]);
let now = BlockHeight::from_u32(10_000);
let now_unix = 1_780_000_000;
plan_schedule(
&mut parts,
now,
no_floor(),
old_notes,
¶ms,
&mut crate::utils::system_rng(),
)
.unwrap();
let windows = upcoming_windows(&parts, now, now_unix, u64::MAX, ¶ms);
let listed: usize = windows.iter().map(|w| w.part_ids.len()).sum();
let current_bucket = bucket_index(now, params.bucket_modulus);
let future_parts = parts
.iter()
.filter(|part| {
part.bucket_index
.is_some_and(|bucket| bucket > current_bucket)
})
.count();
assert!(
future_parts > 0 && future_parts < parts.len(),
"the fixture must span current and future buckets"
);
assert_eq!(listed, future_parts, "every future-bucket part appears");
assert!(
windows
.iter()
.all(|window| window.bucket_index > current_bucket),
"no window is for the current bucket"
);
for pair in windows.windows(2) {
assert!(pair[0].bucket_index < pair[1].bucket_index, "soonest first");
}
for window in &windows {
assert_eq!(
window.boundary,
boundary_of(window.bucket_index, params.bucket_modulus)
);
assert!(window.window_opens_unix_time > now_unix);
let latest_target = parts
.iter()
.filter(|part| part.bucket_index == Some(window.bucket_index))
.filter_map(|part| part.target_height)
.max()
.expect("scheduled parts carry targets");
assert_eq!(
window.latest_target_unix_time,
estimated_unix_at(latest_target, now, now_unix)
);
assert!(window.latest_target_unix_time >= window.window_opens_unix_time);
}
parts[0]
.mark_confirmed(BlockHeight::from_u32(20_000))
.unwrap();
let windows = upcoming_windows(&parts, now, now_unix, u64::MAX, ¶ms);
assert!(
windows
.iter()
.all(|window| !window.part_ids.contains(&PartId(0)))
);
assert!(upcoming_windows(&parts, now, now_unix, 0, ¶ms).is_empty());
}
mod zip318_conformance_tripwires {
use super::*;
use zcash_protocol::value::COIN;
#[test]
fn expiry_constants_match_the_zip() {
assert_eq!(EXPIRY_MODULUS, 34_560);
assert_eq!(EXPIRY_WINDOW, 69_120);
}
#[test]
fn anchor_age_cap_matches_the_zip() {
assert_eq!(ANCHOR_AGE_CAP, 4);
}
#[test]
fn anchor_draw_matches_the_retired_mirror_goldens() {
use rand::SeedableRng;
use rand_chacha::ChaCha12Rng;
let activation = PoolActivation::new_for_test(BlockHeight::from_u32(1_000));
let expected: [(Option<u64>, Option<u64>, Option<u64>); 6] = [
(Some(99), Some(99), Some(51)),
(Some(99), Some(98), Some(51)),
(Some(99), Some(98), Some(52)),
(Some(97), Some(97), Some(52)),
(Some(97), Some(99), Some(52)),
(Some(98), Some(99), Some(52)),
];
for (seed, expected) in expected.iter().enumerate() {
let mut rng = ChaCha12Rng::seed_from_u64(seed as u64);
let floor_loose = AnchorFloor::new(activation, None);
let floor_note =
AnchorFloor::new(activation, Some(BlockHeight::from_u32(50 * 144 + 3)));
let drawn = (
draw_anchor_bucket(100, &floor_loose, &mut rng, 144),
draw_anchor_bucket(100, &floor_note, &mut rng, 144),
draw_anchor_bucket(53, &floor_note, &mut rng, 144),
);
assert_eq!(&drawn, expected, "diverged at seed {seed}");
}
}
const ADJUDICATED_UPSTREAM_VERSION: &str = "0.2.0-pre.1";
#[test]
fn upstream_version_is_the_adjudicated_one() {
let lockfile = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.parent()
.expect("zingolib sits directly under the workspace root")
.join("Cargo.lock");
let lock = std::fs::read_to_string(lockfile).expect("workspace lockfile is readable");
let package_block = lock
.split("[[package]]")
.find(|block| block.contains("name = \"zcash_pool_migration\""))
.expect("the lockfile resolves zcash_pool_migration");
assert!(
package_block.contains(&format!("version = \"{ADJUDICATED_UPSTREAM_VERSION}\"")),
"zcash_pool_migration moved off the adjudicated release \
{ADJUDICATED_UPSTREAM_VERSION}. Re-run the zip318 tripwires against \
the new release and bump ADJUDICATED_UPSTREAM_VERSION in the same \
commit that adopts it."
);
}
#[test]
fn expiry_heights_match_the_zip_arithmetic() {
let sample = [
0,
1,
EXPIRY_MODULUS - 1,
EXPIRY_MODULUS,
EXPIRY_MODULUS + 1,
3_428_499,
100 * EXPIRY_MODULUS,
101 * EXPIRY_MODULUS - 1,
];
for height in sample {
assert_eq!(
u32::from(canonical_expiry_height(BlockHeight::from_u32(height))),
height - (height % 34_560) + 69_120,
"diverged at {height}"
);
}
}
#[test]
fn params_match_the_zip() {
let params = MigrationParams::provisional(ChainType::Mainnet);
assert_eq!(params.bucket_modulus, 144);
assert_eq!(params.denom_cap, 10_000 * COIN);
assert_eq!(params.max_residual_value, COIN / 100);
assert_eq!(params.max_actions_per_split_tx, 16);
}
#[test]
fn transfer_delay_matches_the_zip() {
let delay = SchedulingParams::ZIP_318.transfer_delay();
assert_eq!(delay.mean().get(), 66);
assert_eq!(delay.cap().get(), 576);
}
#[test]
fn ladder_matches_the_zip318_enumeration() {
assert_eq!(
MigrationParams::provisional(ChainType::Mainnet).denominations,
vec![
10_000 * COIN,
5_000 * COIN,
2_000 * COIN,
1_000 * COIN,
500 * COIN,
200 * COIN,
100 * COIN,
50 * COIN,
20 * COIN,
10 * COIN,
5 * COIN,
2 * COIN,
COIN,
COIN / 2,
COIN / 5,
COIN / 10,
COIN / 20,
COIN / 50,
COIN / 100,
],
);
}
#[test]
fn denominations_follow_the_one_two_five_rule() {
for denomination in MigrationParams::provisional(ChainType::Mainnet).denominations {
let mut mantissa = denomination;
while mantissa % 10 == 0 {
mantissa /= 10;
}
assert!(
matches!(mantissa, 1 | 2 | 5),
"{denomination} is not a {{1, 2, 5}} × 10^k value"
);
}
}
}
}