use std::collections::HashSet;
use serde::{Deserialize, Serialize};
use crate::{
types::{ShareDelegationRecord, VotingError},
wire::BoundedU32,
};
pub const SHARE_STATUS_CHECK_GRACE_SECONDS: u64 = 10;
pub const SHARE_MIN_OVERDUE_THRESHOLD_SECONDS: u64 = 30;
pub const SHARE_MAX_OVERDUE_THRESHOLD_SECONDS: u64 = 60 * 60;
pub const SHARE_RESUBMIT_CUTOFF_SECONDS: u64 = 10;
pub const SHARE_READY_POLL_INTERVAL_SECONDS: u64 = 15;
pub const SHARE_FUTURE_CHECK_MAX_DELAY_SECONDS: u64 = 30;
pub const SHARE_MIN_TRACKING_DELAY_SECONDS: u64 = 3;
pub const SHARE_SUBMIT_AT_RANDOM_BYTES: usize = 8;
pub const LAST_MOMENT_BUFFER_FRACTION_NUMERATOR: u64 = 2;
pub const LAST_MOMENT_BUFFER_FRACTION_DENOMINATOR: u64 = 5;
pub const LAST_MOMENT_BUFFER_MAX_SECONDS: u64 = 6 * 60 * 60;
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct ShareTimingPolicy {
pub status_check_grace_seconds: u64,
pub min_overdue_threshold_seconds: u64,
pub max_overdue_threshold_seconds: u64,
pub resubmit_cutoff_seconds: u64,
pub ready_poll_interval_seconds: u64,
pub future_check_max_delay_seconds: u64,
pub min_tracking_delay_seconds: u64,
}
impl Default for ShareTimingPolicy {
fn default() -> Self {
Self {
status_check_grace_seconds: SHARE_STATUS_CHECK_GRACE_SECONDS,
min_overdue_threshold_seconds: SHARE_MIN_OVERDUE_THRESHOLD_SECONDS,
max_overdue_threshold_seconds: SHARE_MAX_OVERDUE_THRESHOLD_SECONDS,
resubmit_cutoff_seconds: SHARE_RESUBMIT_CUTOFF_SECONDS,
ready_poll_interval_seconds: SHARE_READY_POLL_INTERVAL_SECONDS,
future_check_max_delay_seconds: SHARE_FUTURE_CHECK_MAX_DELAY_SECONDS,
min_tracking_delay_seconds: SHARE_MIN_TRACKING_DELAY_SECONDS,
}
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct ShareSubmissionPlan {
pub submit_at: u64,
pub target_count: u32,
pub target_servers: Vec<String>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct ShareSubmissionRandomBytesRequired {
pub submit_at_random_bytes: usize,
pub server_random_bytes: usize,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct ShareTrackingSummary {
pub total: u64,
pub confirmed: u64,
pub waiting: u64,
pub ready: u64,
pub overdue: u64,
}
impl ShareTrackingSummary {
pub fn has_shares(&self) -> bool {
self.total > 0
}
}
pub fn last_moment_buffer_seconds(
ceremony_start_seconds: u64,
vote_end_time_seconds: u64,
) -> Option<u64> {
let duration = vote_end_time_seconds.checked_sub(ceremony_start_seconds)?;
if duration == 0 {
return None;
}
let numerator = u128::from(duration) * u128::from(LAST_MOMENT_BUFFER_FRACTION_NUMERATOR);
let denominator = u128::from(LAST_MOMENT_BUFFER_FRACTION_DENOMINATOR);
let buffer = (numerator + denominator - 1) / denominator;
let capped = buffer.min(u128::from(LAST_MOMENT_BUFFER_MAX_SECONDS));
Some(capped as u64)
}
pub fn last_moment_deadline_seconds(
ceremony_start_seconds: u64,
vote_end_time_seconds: u64,
) -> Option<u64> {
let buffer = last_moment_buffer_seconds(ceremony_start_seconds, vote_end_time_seconds)?;
Some(vote_end_time_seconds.saturating_sub(buffer))
}
pub fn is_last_moment(
now_seconds: u64,
ceremony_start_seconds: u64,
vote_end_time_seconds: u64,
) -> bool {
last_moment_deadline_seconds(ceremony_start_seconds, vote_end_time_seconds)
.is_some_and(|deadline| now_seconds >= deadline && now_seconds < vote_end_time_seconds)
}
pub fn share_recovery_base_time(share: &ShareDelegationRecord) -> u64 {
if share.submit_at > 0 {
share.submit_at
} else {
share.created_at
}
}
pub fn is_share_ready_for_status_check(
share: &ShareDelegationRecord,
now_seconds: u64,
policy: ShareTimingPolicy,
) -> bool {
if share.confirmed {
return false;
}
now_seconds >= share_recovery_base_time(share).saturating_add(policy.status_check_grace_seconds)
}
pub fn overdue_threshold_seconds(
share: &ShareDelegationRecord,
vote_end_time_seconds: u64,
policy: ShareTimingPolicy,
) -> u64 {
let base_time = share_recovery_base_time(share);
let remaining_window = vote_end_time_seconds.saturating_sub(base_time);
let threshold = remaining_window / 4;
let max_threshold = policy
.max_overdue_threshold_seconds
.max(policy.min_overdue_threshold_seconds);
threshold
.max(policy.min_overdue_threshold_seconds)
.min(max_threshold)
}
pub fn should_resubmit_share(
share: &ShareDelegationRecord,
now_seconds: u64,
vote_end_time_seconds: u64,
policy: ShareTimingPolicy,
) -> bool {
if share.confirmed {
return false;
}
let base_time = share_recovery_base_time(share);
let retry_at = base_time.saturating_add(overdue_threshold_seconds(
share,
vote_end_time_seconds,
policy,
));
now_seconds >= retry_at
&& vote_end_time_seconds > now_seconds.saturating_add(policy.resubmit_cutoff_seconds)
}
pub fn next_tracking_delay_seconds(
shares: &[ShareDelegationRecord],
now_seconds: u64,
policy: ShareTimingPolicy,
) -> Option<u64> {
let mut next_second: Option<u64> = None;
let mut has_unconfirmed = false;
for share in shares.iter().filter(|share| !share.confirmed) {
has_unconfirmed = true;
let base_time = share_recovery_base_time(share);
let check_at = base_time.saturating_add(policy.status_check_grace_seconds);
if check_at > now_seconds {
next_second = min_second(next_second, check_at);
}
}
if !has_unconfirmed {
return None;
}
let delay_seconds = match next_second {
Some(next) => next
.saturating_sub(now_seconds)
.min(policy.future_check_max_delay_seconds),
None => policy.ready_poll_interval_seconds,
};
Some(delay_seconds.max(policy.min_tracking_delay_seconds))
}
pub fn summarize_share_tracking(
shares: &[ShareDelegationRecord],
now_seconds: u64,
vote_end_time_seconds: Option<u64>,
policy: ShareTimingPolicy,
) -> ShareTrackingSummary {
let mut summary = ShareTrackingSummary {
total: shares.len() as u64,
confirmed: 0,
waiting: 0,
ready: 0,
overdue: 0,
};
for share in shares {
if share.confirmed {
summary.confirmed += 1;
} else if match vote_end_time_seconds {
Some(vote_end_time_seconds) => {
should_resubmit_share(share, now_seconds, vote_end_time_seconds, policy)
}
None => false,
} {
summary.overdue += 1;
} else if is_share_ready_for_status_check(share, now_seconds, policy) {
summary.ready += 1;
} else {
summary.waiting += 1;
}
}
summary
}
pub fn share_submit_at_random_bytes_required(
now_seconds: u64,
vote_end_time_seconds: u64,
last_moment_buffer_seconds: Option<u64>,
single_share: bool,
) -> usize {
if delayed_share_window_seconds(
now_seconds,
vote_end_time_seconds,
last_moment_buffer_seconds,
single_share,
)
.is_some()
{
SHARE_SUBMIT_AT_RANDOM_BYTES
} else {
0
}
}
pub fn scheduled_share_submit_at_from_random_unit(
now_seconds: u64,
vote_end_time_seconds: u64,
last_moment_buffer_seconds: Option<u64>,
single_share: bool,
random_unit: f64,
) -> Result<u64, VotingError> {
let Some(window_seconds) = delayed_share_window_seconds(
now_seconds,
vote_end_time_seconds,
last_moment_buffer_seconds,
single_share,
) else {
return Ok(0);
};
if !random_unit.is_finite() || !(0.0..1.0).contains(&random_unit) {
return Err(VotingError::InvalidInput {
message: "random_unit must be finite and in [0, 1)".to_string(),
});
}
let delay_seconds = (random_unit * window_seconds as f64).floor() as u64;
Ok(now_seconds.saturating_add(delay_seconds))
}
pub fn scheduled_share_submit_at_from_entropy(
now_seconds: u64,
vote_end_time_seconds: u64,
last_moment_buffer_seconds: Option<u64>,
single_share: bool,
submit_at_random_bytes: &[u8],
) -> Result<u64, VotingError> {
let Some(window_seconds) = delayed_share_window_seconds(
now_seconds,
vote_end_time_seconds,
last_moment_buffer_seconds,
single_share,
) else {
return Ok(0);
};
if submit_at_random_bytes.len() < SHARE_SUBMIT_AT_RANDOM_BYTES {
return Err(VotingError::InvalidInput {
message: format!(
"submit_at_random_bytes must contain at least {SHARE_SUBMIT_AT_RANDOM_BYTES} bytes"
),
});
}
let mut sample_bytes = [0u8; 8];
sample_bytes.copy_from_slice(&submit_at_random_bytes[..8]);
let sample = u64::from_le_bytes(sample_bytes);
let delay_seconds = ((sample as u128 * window_seconds as u128) >> 64) as u64;
Ok(now_seconds.saturating_add(delay_seconds))
}
fn delayed_share_window_seconds(
now_seconds: u64,
vote_end_time_seconds: u64,
last_moment_buffer_seconds: Option<u64>,
single_share: bool,
) -> Option<u64> {
if single_share {
return None;
}
let last_moment_buffer_seconds = last_moment_buffer_seconds?;
if last_moment_buffer_seconds == 0 {
return None;
}
let deadline = vote_end_time_seconds.saturating_sub(last_moment_buffer_seconds);
if deadline <= now_seconds {
return None;
}
Some(deadline - now_seconds)
}
pub fn share_submission_target_count(server_count: usize) -> usize {
if server_count == 0 {
0
} else {
server_count / 2 + server_count % 2
}
}
pub fn share_server_order_random_bytes_required(server_count: usize) -> usize {
server_count
.saturating_sub(1)
.saturating_mul(std::mem::size_of::<u64>())
}
pub fn share_submission_random_bytes_required(
share_count: usize,
server_count: usize,
now_seconds: u64,
vote_end_time_seconds: u64,
last_moment_buffer_seconds: Option<u64>,
single_share: bool,
) -> ShareSubmissionRandomBytesRequired {
let submit_at_per_share = share_submit_at_random_bytes_required(
now_seconds,
vote_end_time_seconds,
last_moment_buffer_seconds,
single_share,
);
let server_per_share = share_server_order_random_bytes_required(server_count);
ShareSubmissionRandomBytesRequired {
submit_at_random_bytes: submit_at_per_share.saturating_mul(share_count),
server_random_bytes: server_per_share.saturating_mul(share_count),
}
}
pub fn resubmission_server_order_random_bytes_required(
configured_server_urls: &[String],
sent_to_urls: &[String],
) -> usize {
let sent: HashSet<&str> = sent_to_urls.iter().map(String::as_str).collect();
let untried_count = configured_server_urls
.iter()
.filter(|server| !sent.contains(server.as_str()))
.count();
let already_sent_count = configured_server_urls
.iter()
.filter(|server| sent.contains(server.as_str()))
.count();
share_server_order_random_bytes_required(untried_count)
.saturating_add(share_server_order_random_bytes_required(already_sent_count))
}
pub fn shuffled_share_server_order(
server_urls: &[String],
random_bytes: &[u8],
) -> Result<Vec<String>, VotingError> {
require_unique_share_servers(server_urls)?;
let needed = share_server_order_random_bytes_required(server_urls.len());
if random_bytes.len() < needed {
return Err(VotingError::InvalidInput {
message: format!(
"server_random_bytes must contain at least {needed} bytes for {} servers",
server_urls.len()
),
});
}
let mut ordered = server_urls.to_vec();
let mut offset = 0usize;
for index in (1..ordered.len()).rev() {
let mut sample_bytes = [0u8; 8];
sample_bytes.copy_from_slice(&random_bytes[offset..offset + 8]);
offset += 8;
let sample = u64::from_le_bytes(sample_bytes);
let swap_index = (sample % ((index + 1) as u64)) as usize;
ordered.swap(index, swap_index);
}
Ok(ordered)
}
pub fn select_share_submission_targets(
server_urls: &[String],
target_count: usize,
server_random_bytes: &[u8],
) -> Result<Vec<String>, VotingError> {
let target_count = target_count.min(server_urls.len());
if target_count == 0 {
return Ok(Vec::new());
}
let randomized_order = shuffled_share_server_order(server_urls, server_random_bytes)?;
Ok(select_share_submission_targets_from_order(
&randomized_order,
target_count,
))
}
pub fn select_share_submission_targets_from_order(
server_urls: &[String],
target_count: usize,
) -> Vec<String> {
server_urls
.iter()
.take(target_count.min(server_urls.len()))
.cloned()
.collect()
}
pub fn plan_share_submission(
server_urls: &[String],
now_seconds: u64,
vote_end_time_seconds: u64,
last_moment_buffer_seconds: Option<u64>,
single_share: bool,
submit_at_random_bytes: &[u8],
server_random_bytes: &[u8],
) -> Result<ShareSubmissionPlan, VotingError> {
require_share_servers(server_urls)?;
let target_count = share_submission_target_count(server_urls.len());
let target_servers =
select_share_submission_targets(server_urls, target_count, server_random_bytes)?;
let submit_at = scheduled_share_submit_at_from_entropy(
now_seconds,
vote_end_time_seconds,
last_moment_buffer_seconds,
single_share,
submit_at_random_bytes,
)?;
Ok(ShareSubmissionPlan {
submit_at,
target_count: BoundedU32::try_from(target_count)
.map_err(|_| VotingError::InvalidInput {
message: format!("target_count {target_count} does not fit u32"),
})?
.0,
target_servers,
})
}
pub fn plan_share_submissions(
share_count: usize,
server_urls: &[String],
now_seconds: u64,
vote_end_time_seconds: u64,
last_moment_buffer_seconds: Option<u64>,
single_share: bool,
submit_at_random_bytes: &[u8],
server_random_bytes: &[u8],
) -> Result<Vec<ShareSubmissionPlan>, VotingError> {
if share_count == 0 {
return Ok(Vec::new());
}
require_share_servers(server_urls)?;
let submit_at_bytes_per_share = share_submit_at_random_bytes_required(
now_seconds,
vote_end_time_seconds,
last_moment_buffer_seconds,
single_share,
);
let server_bytes_per_share = share_server_order_random_bytes_required(server_urls.len());
let submit_at_bytes_needed = checked_random_bytes_required(
submit_at_bytes_per_share,
share_count,
"submit_at_random_bytes",
)?;
let server_bytes_needed =
checked_random_bytes_required(server_bytes_per_share, share_count, "server_random_bytes")?;
if submit_at_random_bytes.len() < submit_at_bytes_needed {
return Err(VotingError::InvalidInput {
message: format!(
"submit_at_random_bytes must contain at least {submit_at_bytes_needed} bytes"
),
});
}
if server_random_bytes.len() < server_bytes_needed {
return Err(VotingError::InvalidInput {
message: format!(
"server_random_bytes must contain at least {server_bytes_needed} bytes"
),
});
}
let mut plans = Vec::with_capacity(share_count);
for share_index in 0..share_count {
let submit_at_start = share_index * submit_at_bytes_per_share;
let submit_at_end = submit_at_start + submit_at_bytes_per_share;
let server_start = share_index * server_bytes_per_share;
let server_end = server_start + server_bytes_per_share;
plans.push(plan_share_submission(
server_urls,
now_seconds,
vote_end_time_seconds,
last_moment_buffer_seconds,
single_share,
&submit_at_random_bytes[submit_at_start..submit_at_end],
&server_random_bytes[server_start..server_end],
)?);
}
Ok(plans)
}
pub fn plan_share_submission_from_order(
server_urls: &[String],
now_seconds: u64,
vote_end_time_seconds: u64,
last_moment_buffer_seconds: Option<u64>,
single_share: bool,
random_unit: f64,
) -> Result<ShareSubmissionPlan, VotingError> {
require_share_servers(server_urls)?;
let target_count = share_submission_target_count(server_urls.len());
let target_servers = select_share_submission_targets_from_order(server_urls, target_count);
plan_share_submission_with_targets(
target_count,
target_servers,
now_seconds,
vote_end_time_seconds,
last_moment_buffer_seconds,
single_share,
random_unit,
)
}
fn plan_share_submission_with_targets(
target_count: usize,
target_servers: Vec<String>,
now_seconds: u64,
vote_end_time_seconds: u64,
last_moment_buffer_seconds: Option<u64>,
single_share: bool,
random_unit: f64,
) -> Result<ShareSubmissionPlan, VotingError> {
let submit_at = scheduled_share_submit_at_from_random_unit(
now_seconds,
vote_end_time_seconds,
last_moment_buffer_seconds,
single_share,
random_unit,
)?;
Ok(ShareSubmissionPlan {
submit_at,
target_count: BoundedU32::try_from(target_count)
.map_err(|_| VotingError::InvalidInput {
message: format!("target_count {target_count} does not fit u32"),
})?
.0,
target_servers,
})
}
fn require_share_servers(server_urls: &[String]) -> Result<(), VotingError> {
if server_urls.is_empty() {
return Err(VotingError::InvalidInput {
message: "server_urls must not be empty".to_string(),
});
}
require_unique_share_servers(server_urls)
}
fn require_unique_share_servers(server_urls: &[String]) -> Result<(), VotingError> {
let mut seen = HashSet::new();
for server_url in server_urls {
if !seen.insert(server_url.as_str()) {
return Err(VotingError::InvalidInput {
message: "server_urls must not contain duplicates".to_string(),
});
}
}
Ok(())
}
fn checked_random_bytes_required(
per_share: usize,
share_count: usize,
name: &str,
) -> Result<usize, VotingError> {
per_share
.checked_mul(share_count)
.ok_or_else(|| VotingError::InvalidInput {
message: format!("{name} requirement overflows usize"),
})
}
pub fn resubmission_server_order_from_groups(
untried_server_urls: &[String],
already_sent_server_urls: &[String],
) -> Vec<String> {
untried_server_urls
.iter()
.chain(already_sent_server_urls.iter())
.cloned()
.collect()
}
pub fn resubmission_server_order(
configured_server_urls: &[String],
sent_to_urls: &[String],
server_random_bytes: &[u8],
) -> Result<Vec<String>, VotingError> {
require_unique_share_servers(configured_server_urls)?;
let sent: HashSet<&str> = sent_to_urls.iter().map(String::as_str).collect();
let untried: Vec<String> = configured_server_urls
.iter()
.filter(|server| !sent.contains(server.as_str()))
.cloned()
.collect();
let already_sent: Vec<String> = configured_server_urls
.iter()
.filter(|server| sent.contains(server.as_str()))
.cloned()
.collect();
let untried_bytes = share_server_order_random_bytes_required(untried.len());
let already_sent_bytes = share_server_order_random_bytes_required(already_sent.len());
let needed =
resubmission_server_order_random_bytes_required(configured_server_urls, sent_to_urls);
if server_random_bytes.len() < needed {
return Err(VotingError::InvalidInput {
message: format!(
"server_random_bytes must contain at least {needed} bytes for resubmission order"
),
});
}
let randomized_untried =
shuffled_share_server_order(&untried, &server_random_bytes[..untried_bytes])?;
let randomized_already_sent = shuffled_share_server_order(
&already_sent,
&server_random_bytes[untried_bytes..untried_bytes + already_sent_bytes],
)?;
Ok(resubmission_server_order_from_groups(
&randomized_untried,
&randomized_already_sent,
))
}
pub fn resubmission_server_order_from_configured_order(
configured_server_urls: &[String],
sent_to_urls: &[String],
) -> Vec<String> {
let sent: HashSet<&str> = sent_to_urls.iter().map(String::as_str).collect();
let untried: Vec<String> = configured_server_urls
.iter()
.filter(|server| !sent.contains(server.as_str()))
.cloned()
.collect();
let already_sent: Vec<String> = configured_server_urls
.iter()
.filter(|server| sent.contains(server.as_str()))
.cloned()
.collect();
resubmission_server_order_from_groups(&untried, &already_sent)
}
fn min_second(current: Option<u64>, candidate: u64) -> Option<u64> {
match current {
Some(current) if current <= candidate => Some(current),
_ => Some(candidate),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn share(submit_at: u64, created_at: u64) -> ShareDelegationRecord {
ShareDelegationRecord {
round_id: "round".to_string(),
bundle_index: 0,
proposal_id: 1,
share_index: 0,
sent_to_urls: vec!["https://helper.example.com".to_string()],
nullifier: vec![7; 32],
confirmed: false,
submit_at,
created_at,
}
}
fn random_bytes(samples: &[u64]) -> Vec<u8> {
samples
.iter()
.flat_map(|sample| sample.to_le_bytes())
.collect()
}
#[test]
fn last_moment_buffer_uses_two_fifths_of_round_duration() {
assert_eq!(last_moment_buffer_seconds(1_000, 1_600), Some(240));
}
#[test]
fn last_moment_buffer_caps_at_six_hours() {
assert_eq!(
last_moment_buffer_seconds(1_000, 1_000 + 24 * 60 * 60),
Some(LAST_MOMENT_BUFFER_MAX_SECONDS)
);
}
#[test]
fn last_moment_buffer_caps_without_u64_overflow() {
assert_eq!(
last_moment_buffer_seconds(0, u64::MAX),
Some(LAST_MOMENT_BUFFER_MAX_SECONDS)
);
}
#[test]
fn last_moment_buffer_rejects_invalid_round_timing() {
assert_eq!(last_moment_buffer_seconds(1_000, 1_000), None);
assert_eq!(last_moment_buffer_seconds(1_001, 1_000), None);
}
#[test]
fn last_moment_buffer_rounds_up_to_whole_seconds() {
assert_eq!(last_moment_buffer_seconds(1_000, 1_001), Some(1));
assert_eq!(last_moment_buffer_seconds(1_000, 1_002), Some(1));
assert_eq!(last_moment_buffer_seconds(1_000, 1_003), Some(2));
}
#[test]
fn last_moment_deadline_subtracts_buffer_from_vote_end() {
assert_eq!(last_moment_deadline_seconds(1_000, 1_600), Some(1_360));
assert_eq!(
last_moment_deadline_seconds(1_000, 1_000 + 24 * 60 * 60),
Some(1_000 + 18 * 60 * 60)
);
}
#[test]
fn last_moment_predicate_uses_deadline_boundary() {
assert!(!is_last_moment(1_359, 1_000, 1_600));
assert!(is_last_moment(1_360, 1_000, 1_600));
assert!(is_last_moment(1_599, 1_000, 1_600));
assert!(!is_last_moment(1_600, 1_000, 1_600));
assert!(!is_last_moment(1_000, 1_000, 1_000));
}
#[test]
fn scheduled_submit_at_from_random_unit_samples_before_deadline() {
let submit_at =
scheduled_share_submit_at_from_random_unit(1_000, 2_000, Some(100), false, 0.5)
.unwrap();
assert_eq!(submit_at, 1_450);
}
#[test]
fn scheduled_submit_at_entropy_requirement_matches_delay_window() {
assert_eq!(
share_submit_at_random_bytes_required(1_000, 2_000, Some(100), false),
SHARE_SUBMIT_AT_RANDOM_BYTES
);
assert_eq!(
share_submit_at_random_bytes_required(1_000, 2_000, Some(100), true),
0
);
assert_eq!(
share_submit_at_random_bytes_required(1_000, 2_000, None, false),
0
);
assert_eq!(
share_submit_at_random_bytes_required(1_950, 2_000, Some(100), false),
0
);
}
#[test]
fn scheduled_submit_at_from_entropy_samples_before_deadline() {
let submit_at = scheduled_share_submit_at_from_entropy(
1_000,
2_000,
Some(100),
false,
&random_bytes(&[1u64 << 63]),
)
.unwrap();
assert_eq!(submit_at, 1_450);
}
#[test]
fn scheduled_submit_at_is_immediate_without_a_delay_window() {
assert_eq!(
scheduled_share_submit_at_from_random_unit(1_000, 2_000, Some(100), true, f64::NAN)
.unwrap(),
0
);
assert_eq!(
scheduled_share_submit_at_from_random_unit(1_000, 2_000, None, false, f64::NAN)
.unwrap(),
0
);
assert_eq!(
scheduled_share_submit_at_from_random_unit(1_000, 2_000, Some(0), false, f64::NAN)
.unwrap(),
0
);
assert_eq!(
scheduled_share_submit_at_from_random_unit(1_950, 2_000, Some(100), false, f64::NAN)
.unwrap(),
0
);
assert_eq!(
scheduled_share_submit_at_from_entropy(1_000, 2_000, Some(100), true, &[]).unwrap(),
0
);
}
#[test]
fn scheduled_submit_at_rejects_non_finite_random_unit_for_delay_window() {
assert!(matches!(
scheduled_share_submit_at_from_random_unit(1_000, 2_000, Some(100), false, f64::NAN),
Err(VotingError::InvalidInput { .. })
));
}
#[test]
fn scheduled_submit_at_from_entropy_rejects_missing_entropy_for_delay_window() {
assert!(matches!(
scheduled_share_submit_at_from_entropy(1_000, 2_000, Some(100), false, &[]),
Err(VotingError::InvalidInput { .. })
));
}
#[test]
fn scheduled_submit_at_from_random_unit_rejects_out_of_range_samples() {
assert!(matches!(
scheduled_share_submit_at_from_random_unit(1_000, 2_000, Some(100), false, 1.0),
Err(VotingError::InvalidInput { .. })
));
assert!(matches!(
scheduled_share_submit_at_from_random_unit(1_000, 2_000, Some(100), false, -1.0),
Err(VotingError::InvalidInput { .. })
));
}
#[test]
fn immediate_shares_use_created_at_for_status_and_retry() {
let share = share(0, 100);
let policy = ShareTimingPolicy::default();
assert_eq!(share_recovery_base_time(&share), 100);
assert!(!is_share_ready_for_status_check(&share, 109, policy));
assert!(is_share_ready_for_status_check(&share, 110, policy));
assert!(!should_resubmit_share(&share, 129, 200, policy));
assert!(should_resubmit_share(&share, 130, 200, policy));
}
#[test]
fn delayed_shares_use_submit_at_for_status_and_retry() {
let share = share(200, 100);
let policy = ShareTimingPolicy::default();
assert_eq!(share_recovery_base_time(&share), 200);
assert!(!is_share_ready_for_status_check(&share, 209, policy));
assert!(is_share_ready_for_status_check(&share, 210, policy));
assert!(!should_resubmit_share(&share, 229, 320, policy));
assert!(should_resubmit_share(&share, 230, 320, policy));
}
#[test]
fn overdue_threshold_is_quarter_window_with_bounds() {
let share = share(0, 100);
let policy = ShareTimingPolicy::default();
assert_eq!(overdue_threshold_seconds(&share, 500, policy), 100);
assert_eq!(overdue_threshold_seconds(&share, 120, policy), 30);
assert_eq!(overdue_threshold_seconds(&share, 20_000, policy), 3_600);
}
#[test]
fn should_resubmit_respects_vote_end_cutoff() {
let share = share(0, 100);
let policy = ShareTimingPolicy::default();
assert!(should_resubmit_share(&share, 130, 200, policy));
assert!(!should_resubmit_share(&share, 190, 200, policy));
}
#[test]
fn next_tracking_delay_uses_future_check_times() {
let shares = vec![share(0, 100), share(200, 100)];
let policy = ShareTimingPolicy::default();
assert_eq!(next_tracking_delay_seconds(&shares, 105, policy), Some(5));
}
#[test]
fn next_tracking_delay_applies_minimum_and_future_cap() {
let shares = vec![share(0, 100), share(200, 100)];
let policy = ShareTimingPolicy::default();
assert_eq!(next_tracking_delay_seconds(&shares, 109, policy), Some(3));
assert_eq!(next_tracking_delay_seconds(&shares, 111, policy), Some(30));
}
#[test]
fn next_tracking_delay_uses_ready_poll_interval_for_ready_pending_shares() {
let shares = vec![share(0, 100)];
let policy = ShareTimingPolicy::default();
assert_eq!(next_tracking_delay_seconds(&shares, 130, policy), Some(15));
assert_eq!(next_tracking_delay_seconds(&shares, 131, policy), Some(15));
}
#[test]
fn next_tracking_delay_stops_when_all_shares_are_confirmed() {
let mut confirmed = share(0, 100);
confirmed.confirmed = true;
assert_eq!(
next_tracking_delay_seconds(&[confirmed], 130, ShareTimingPolicy::default()),
None
);
}
#[test]
fn tracking_summary_uses_confirmed_overdue_ready_waiting_order() {
let mut confirmed = share(0, 100);
confirmed.confirmed = true;
let overdue = share(0, 100);
let ready = share(120, 100);
let waiting = share(300, 100);
let shares = vec![confirmed, overdue, ready, waiting];
let summary =
summarize_share_tracking(&shares, 130, Some(200), ShareTimingPolicy::default());
assert_eq!(
summary,
ShareTrackingSummary {
total: 4,
confirmed: 1,
waiting: 1,
ready: 1,
overdue: 1,
}
);
assert!(summary.has_shares());
}
#[test]
fn helper_target_count_is_half_rounded_up() {
assert_eq!(share_submission_target_count(0), 0);
assert_eq!(share_submission_target_count(1), 1);
assert_eq!(share_submission_target_count(2), 1);
assert_eq!(share_submission_target_count(3), 2);
assert_eq!(share_submission_target_count(5), 3);
}
#[test]
fn helper_order_random_bytes_required_matches_shuffle_steps() {
assert_eq!(share_server_order_random_bytes_required(0), 0);
assert_eq!(share_server_order_random_bytes_required(1), 0);
assert_eq!(share_server_order_random_bytes_required(3), 16);
}
#[test]
fn resubmission_order_random_bytes_required_matches_group_shuffles() {
let configured = vec![
"https://already-one.example.com".to_string(),
"https://untried-one.example.com".to_string(),
"https://untried-two.example.com".to_string(),
"https://already-two.example.com".to_string(),
];
let sent = vec![
"https://already-one.example.com".to_string(),
"https://already-two.example.com".to_string(),
];
assert_eq!(
resubmission_server_order_random_bytes_required(&configured, &sent),
16
);
}
#[test]
fn randomized_helper_order_uses_entropy() {
let servers = vec![
"https://one.example.com".to_string(),
"https://two.example.com".to_string(),
"https://three.example.com".to_string(),
];
let ordered = shuffled_share_server_order(&servers, &random_bytes(&[1, 0])).unwrap();
assert_eq!(
ordered,
vec![
"https://three.example.com".to_string(),
"https://one.example.com".to_string(),
"https://two.example.com".to_string()
]
);
}
#[test]
fn randomized_helper_order_rejects_missing_entropy() {
let servers = vec![
"https://one.example.com".to_string(),
"https://two.example.com".to_string(),
];
assert!(matches!(
shuffled_share_server_order(&servers, &[]),
Err(VotingError::InvalidInput { .. })
));
}
#[test]
fn randomized_helper_order_rejects_duplicate_server_urls() {
let servers = vec![
"https://one.example.com".to_string(),
"https://one.example.com".to_string(),
];
assert!(matches!(
shuffled_share_server_order(&servers, &random_bytes(&[0])),
Err(VotingError::InvalidInput { .. })
));
}
#[test]
fn share_submission_plan_randomizes_target_servers() {
let servers = vec![
"https://one.example.com".to_string(),
"https://two.example.com".to_string(),
"https://three.example.com".to_string(),
];
let plan = plan_share_submission(
&servers,
1_000,
2_000,
Some(100),
false,
&random_bytes(&[1u64 << 63]),
&random_bytes(&[1, 0]),
)
.unwrap();
assert_eq!(plan.submit_at, 1_450);
assert_eq!(plan.target_count, 2);
assert_eq!(
plan.target_servers,
vec![
"https://three.example.com".to_string(),
"https://one.example.com".to_string()
]
);
}
#[test]
fn share_submission_plan_rejects_empty_server_list() {
assert!(matches!(
plan_share_submission(
&[],
1_000,
2_000,
Some(100),
false,
&random_bytes(&[1u64 << 63]),
&[]
),
Err(VotingError::InvalidInput { .. })
));
assert!(matches!(
plan_share_submission_from_order(&[], 1_000, 2_000, Some(100), false, 0.0),
Err(VotingError::InvalidInput { .. })
));
}
#[test]
fn share_submission_plan_rejects_duplicate_server_urls() {
let servers = vec![
"https://one.example.com".to_string(),
"https://one.example.com".to_string(),
];
assert!(matches!(
plan_share_submission(
&servers,
1_000,
2_000,
Some(100),
false,
&random_bytes(&[1u64 << 63]),
&random_bytes(&[0])
),
Err(VotingError::InvalidInput { .. })
));
assert!(matches!(
plan_share_submission_from_order(&servers, 1_000, 2_000, Some(100), false, 0.0),
Err(VotingError::InvalidInput { .. })
));
}
#[test]
fn share_submission_random_bytes_required_counts_independent_share_plans() {
assert_eq!(
share_submission_random_bytes_required(2, 3, 1_000, 2_000, Some(100), false),
ShareSubmissionRandomBytesRequired {
submit_at_random_bytes: 16,
server_random_bytes: 32,
}
);
assert_eq!(
share_submission_random_bytes_required(2, 3, 1_000, 2_000, Some(100), true),
ShareSubmissionRandomBytesRequired {
submit_at_random_bytes: 0,
server_random_bytes: 32,
}
);
}
#[test]
fn share_submission_batch_plan_uses_independent_entropy_per_share() {
let servers = vec![
"https://one.example.com".to_string(),
"https://two.example.com".to_string(),
"https://three.example.com".to_string(),
];
let plans = plan_share_submissions(
2,
&servers,
1_000,
2_000,
Some(100),
false,
&random_bytes(&[0, 1u64 << 63]),
&random_bytes(&[1, 0, 0, 1]),
)
.unwrap();
assert_eq!(plans.len(), 2);
assert_eq!(plans[0].submit_at, 1_000);
assert_eq!(
plans[0].target_servers,
vec![
"https://three.example.com".to_string(),
"https://one.example.com".to_string()
]
);
assert_eq!(plans[1].submit_at, 1_450);
assert_eq!(
plans[1].target_servers,
vec![
"https://three.example.com".to_string(),
"https://two.example.com".to_string()
]
);
}
#[test]
fn share_submission_batch_plan_rejects_missing_entropy() {
let servers = vec![
"https://one.example.com".to_string(),
"https://two.example.com".to_string(),
"https://three.example.com".to_string(),
];
assert!(matches!(
plan_share_submissions(
2,
&servers,
1_000,
2_000,
Some(100),
false,
&random_bytes(&[0]),
&random_bytes(&[1, 0, 0, 1]),
),
Err(VotingError::InvalidInput { .. })
));
assert!(matches!(
plan_share_submissions(
2,
&servers,
1_000,
2_000,
Some(100),
false,
&random_bytes(&[0, 1u64 << 63]),
&random_bytes(&[1, 0, 0]),
),
Err(VotingError::InvalidInput { .. })
));
}
#[test]
fn share_submission_plan_from_order_uses_caller_server_order() {
let servers = vec![
"https://one.example.com".to_string(),
"https://two.example.com".to_string(),
"https://three.example.com".to_string(),
];
let plan = plan_share_submission_from_order(&servers, 1_000, 2_000, Some(100), false, 0.0)
.unwrap();
assert_eq!(plan.submit_at, 1_000);
assert_eq!(plan.target_count, 2);
assert_eq!(
plan.target_servers,
vec![
"https://one.example.com".to_string(),
"https://two.example.com".to_string()
]
);
}
#[test]
fn share_submission_target_selection_uses_caller_server_order() {
let servers = vec![
"https://three.example.com".to_string(),
"https://one.example.com".to_string(),
"https://two.example.com".to_string(),
];
assert_eq!(
select_share_submission_targets_from_order(&servers, 2),
vec![
"https://three.example.com".to_string(),
"https://one.example.com".to_string()
]
);
}
#[test]
fn randomized_share_submission_target_selection_uses_entropy() {
let servers = vec![
"https://one.example.com".to_string(),
"https://two.example.com".to_string(),
"https://three.example.com".to_string(),
];
assert_eq!(
select_share_submission_targets(&servers, 2, &random_bytes(&[1, 0])).unwrap(),
vec![
"https://three.example.com".to_string(),
"https://one.example.com".to_string()
]
);
}
#[test]
fn resubmission_order_tries_ordered_untried_helpers_first() {
let untried = vec![
"https://untried-two.example.com".to_string(),
"https://untried-one.example.com".to_string(),
];
let already_sent = vec!["https://already.example.com".to_string()];
assert_eq!(
resubmission_server_order_from_groups(&untried, &already_sent),
vec![
"https://untried-two.example.com".to_string(),
"https://untried-one.example.com".to_string(),
"https://already.example.com".to_string()
]
);
}
#[test]
fn randomized_resubmission_order_shuffles_groups_separately() {
let configured = vec![
"https://already-one.example.com".to_string(),
"https://untried-one.example.com".to_string(),
"https://untried-two.example.com".to_string(),
"https://already-two.example.com".to_string(),
];
let sent = vec![
"https://already-one.example.com".to_string(),
"https://already-two.example.com".to_string(),
];
assert_eq!(
resubmission_server_order(&configured, &sent, &random_bytes(&[0, 0])).unwrap(),
vec![
"https://untried-two.example.com".to_string(),
"https://untried-one.example.com".to_string(),
"https://already-two.example.com".to_string(),
"https://already-one.example.com".to_string()
]
);
}
#[test]
fn randomized_resubmission_order_rejects_missing_entropy() {
let configured = vec![
"https://untried-one.example.com".to_string(),
"https://untried-two.example.com".to_string(),
];
assert!(matches!(
resubmission_server_order(&configured, &[], &[]),
Err(VotingError::InvalidInput { .. })
));
}
#[test]
fn randomized_resubmission_order_rejects_duplicate_configured_urls() {
let configured = vec![
"https://untried-one.example.com".to_string(),
"https://untried-one.example.com".to_string(),
];
assert!(matches!(
resubmission_server_order(&configured, &[], &random_bytes(&[0])),
Err(VotingError::InvalidInput { .. })
));
}
#[test]
fn resubmission_order_from_configured_order_preserves_group_order() {
let configured = vec![
"https://already.example.com".to_string(),
"https://untried.example.com".to_string(),
];
let sent = vec!["https://already.example.com".to_string()];
assert_eq!(
resubmission_server_order_from_configured_order(&configured, &sent),
vec![
"https://untried.example.com".to_string(),
"https://already.example.com".to_string()
]
);
}
}