use super::{
NnsProposalCollectionState, collection::validate_collection_state, model::NnsProposalRow,
};
use crate::nns::governance::{
NnsGovernanceCollectionStatus, NnsGovernanceSourceProvenance,
validate_governance_collection_source, validate_governance_report_identity,
validate_governance_time_interval,
};
use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, HashSet};
use thiserror::Error as ThisError;
pub const NNS_PROPOSAL_ACTIVITY_REPORT_SCHEMA_VERSION: u32 = 1;
const SECONDS_PER_DAY: u64 = 86_400;
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub struct NnsProposalActivityRequest {
pub from_proposal_timestamp_seconds: Option<u64>,
pub until_proposal_timestamp_seconds: Option<u64>,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(deny_unknown_fields)]
pub struct NnsProposalTopicCount {
pub topic: i32,
pub proposal_count: u64,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(deny_unknown_fields)]
pub struct NnsProposalStatusCount {
pub status: i32,
pub proposal_count: u64,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(deny_unknown_fields)]
pub struct NnsProposalRewardStatusCount {
pub reward_status: i32,
pub proposal_count: u64,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub struct NnsProposalDayCount {
pub day_start_timestamp_seconds: u64,
pub proposal_count: u64,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub struct NnsProposalActivityReport {
pub schema_version: u32,
pub network: String,
pub governance_canister_id: String,
pub source: NnsGovernanceSourceProvenance,
pub collection_started_at: String,
pub collection_updated_at: String,
pub collection_page_count: u32,
pub collected_proposal_count: u64,
pub point_in_time_guaranteed: bool,
pub from_proposal_timestamp_seconds: Option<u64>,
pub until_proposal_timestamp_seconds: Option<u64>,
pub included_proposal_count: u64,
pub excluded_before_from_count: u64,
pub excluded_at_or_after_until_count: u64,
pub earliest_included_proposal_timestamp_seconds: Option<u64>,
pub latest_included_proposal_timestamp_seconds: Option<u64>,
pub topic_counts: Vec<NnsProposalTopicCount>,
pub status_counts: Vec<NnsProposalStatusCount>,
pub reward_status_counts: Vec<NnsProposalRewardStatusCount>,
pub day_counts: Vec<NnsProposalDayCount>,
}
#[derive(Debug, Eq, PartialEq, ThisError)]
#[error("invalid NNS proposal activity report: {reason}")]
pub struct NnsProposalActivityValidationError {
pub reason: String,
}
#[derive(Debug, ThisError)]
pub enum NnsProposalActivityError {
#[error("invalid NNS proposal collection state for activity projection: {reason}")]
InvalidCollectionState {
reason: String,
},
#[error("NNS proposal activity requires a complete collection; state is {status}")]
CollectionNotComplete {
status: NnsGovernanceCollectionStatus,
},
#[error(
"invalid NNS proposal activity time window: from {from_proposal_timestamp_seconds} must be below until {until_proposal_timestamp_seconds}"
)]
InvalidTimeWindow {
from_proposal_timestamp_seconds: u64,
until_proposal_timestamp_seconds: u64,
},
#[error(
"NNS proposal activity received {actual} rows; complete collection accounts for {expected}"
)]
ProposalCountMismatch {
expected: u64,
actual: u64,
},
#[error("NNS proposal activity received a row without a proposal id")]
MissingProposalId,
#[error("NNS proposal activity received proposal id zero")]
ZeroProposalId,
#[error("NNS proposal activity received duplicate proposal id {proposal_id}")]
DuplicateProposalId {
proposal_id: u64,
},
#[error("NNS proposal {proposal_id} has proposal timestamp zero")]
ZeroProposalTimestamp {
proposal_id: u64,
},
#[error("NNS proposal activity accounting overflow while updating {field}")]
AccountingOverflow {
field: &'static str,
},
}
pub fn validate_nns_proposal_activity_report(
report: &NnsProposalActivityReport,
) -> Result<(), NnsProposalActivityValidationError> {
validate_activity_header(report)?;
validate_activity_selection(report)?;
validate_code_counts(
report,
"topic",
report
.topic_counts
.iter()
.map(|row| (row.topic, row.proposal_count)),
)?;
validate_code_counts(
report,
"status",
report
.status_counts
.iter()
.map(|row| (row.status, row.proposal_count)),
)?;
validate_code_counts(
report,
"reward-status",
report
.reward_status_counts
.iter()
.map(|row| (row.reward_status, row.proposal_count)),
)?;
validate_day_counts(report)
}
fn validate_activity_header(
report: &NnsProposalActivityReport,
) -> Result<(), NnsProposalActivityValidationError> {
if report.schema_version != NNS_PROPOSAL_ACTIVITY_REPORT_SCHEMA_VERSION {
return Err(invalid_validation(format!(
"schema version {} does not equal {}",
report.schema_version, NNS_PROPOSAL_ACTIVITY_REPORT_SCHEMA_VERSION
)));
}
validate_governance_report_identity(&report.network, &report.governance_canister_id)
.map_err(invalid_validation)?;
if report.collection_page_count == 0 {
return Err(invalid_validation(
"complete activity report must retain at least one collection page",
));
}
let minimum_proposal_count = u64::from(report.collection_page_count - 1);
if report.collected_proposal_count < minimum_proposal_count {
return Err(invalid_validation(format!(
"collection_page_count {} requires at least {minimum_proposal_count} collected proposals, found {}",
report.collection_page_count, report.collected_proposal_count
)));
}
validate_governance_time_interval(
"collection_started_at",
&report.collection_started_at,
"collection_updated_at",
&report.collection_updated_at,
)
.map_err(|error| invalid_validation(error.to_string()))?;
if report.point_in_time_guaranteed {
return Err(invalid_validation(
"sequential proposal activity cannot claim a point-in-time snapshot",
));
}
validate_governance_collection_source(&report.network, &report.source)
.map_err(invalid_validation)
}
fn validate_activity_selection(
report: &NnsProposalActivityReport,
) -> Result<(), NnsProposalActivityValidationError> {
if let (Some(from), Some(until)) = (
report.from_proposal_timestamp_seconds,
report.until_proposal_timestamp_seconds,
) && from >= until
{
return Err(invalid_validation(format!(
"from proposal timestamp {from} must be below until timestamp {until}"
)));
}
if report.from_proposal_timestamp_seconds.is_none() && report.excluded_before_from_count != 0 {
return Err(invalid_validation(
"excluded_before_from_count must be zero without a lower bound",
));
}
if report.until_proposal_timestamp_seconds.is_none()
&& report.excluded_at_or_after_until_count != 0
{
return Err(invalid_validation(
"excluded_at_or_after_until_count must be zero without an upper bound",
));
}
let accounted = report
.included_proposal_count
.checked_add(report.excluded_before_from_count)
.and_then(|count| count.checked_add(report.excluded_at_or_after_until_count))
.ok_or_else(|| invalid_validation("proposal selection count overflow"))?;
if accounted != report.collected_proposal_count {
return Err(invalid_validation(format!(
"selection accounts for {accounted} proposals, expected {}",
report.collected_proposal_count
)));
}
validate_included_range(report)
}
fn validate_included_range(
report: &NnsProposalActivityReport,
) -> Result<(), NnsProposalActivityValidationError> {
let (earliest, latest) = match (
report.earliest_included_proposal_timestamp_seconds,
report.latest_included_proposal_timestamp_seconds,
) {
(None, None) if report.included_proposal_count == 0 => return Ok(()),
(Some(earliest), Some(latest)) if report.included_proposal_count > 0 => (earliest, latest),
_ => {
return Err(invalid_validation(
"included timestamp range presence disagrees with included_proposal_count",
));
}
};
if earliest == 0 || earliest > latest {
return Err(invalid_validation(
"included proposal timestamps must be nonzero and ascending",
));
}
if report
.from_proposal_timestamp_seconds
.is_some_and(|from| earliest < from)
{
return Err(invalid_validation(
"earliest included proposal timestamp precedes the lower bound",
));
}
if report
.until_proposal_timestamp_seconds
.is_some_and(|until| latest >= until)
{
return Err(invalid_validation(
"latest included proposal timestamp reaches or exceeds the upper bound",
));
}
Ok(())
}
fn validate_code_counts(
report: &NnsProposalActivityReport,
dimension: &'static str,
rows: impl Iterator<Item = (i32, u64)>,
) -> Result<(), NnsProposalActivityValidationError> {
let mut previous = None;
let mut total = 0_u64;
for (key, count) in rows {
if previous.is_some_and(|previous| previous >= key) {
return Err(invalid_validation(format!(
"{dimension} count rows are not strictly raw-code ordered"
)));
}
total = add_dimension_count(total, count, dimension)?;
previous = Some(key);
}
validate_dimension_total(total, report.included_proposal_count, dimension)
}
fn validate_day_counts(
report: &NnsProposalActivityReport,
) -> Result<(), NnsProposalActivityValidationError> {
let mut previous = None;
let mut total = 0_u64;
for row in &report.day_counts {
if row.day_start_timestamp_seconds % SECONDS_PER_DAY != 0 {
return Err(invalid_validation(
"day count row is not aligned to 00:00:00 UTC",
));
}
if previous.is_some_and(|day| day >= row.day_start_timestamp_seconds) {
return Err(invalid_validation(
"day count rows are not strictly time ordered",
));
}
total = add_dimension_count(total, row.proposal_count, "day")?;
previous = Some(row.day_start_timestamp_seconds);
}
validate_dimension_total(total, report.included_proposal_count, "day")?;
validate_day_range(report)
}
fn validate_day_range(
report: &NnsProposalActivityReport,
) -> Result<(), NnsProposalActivityValidationError> {
if report.included_proposal_count == 0 {
return Ok(());
}
let (Some(earliest), Some(latest)) = (
report.earliest_included_proposal_timestamp_seconds,
report.latest_included_proposal_timestamp_seconds,
) else {
return Err(invalid_validation(
"included timestamp range is absent for positive day counts",
));
};
let expected_first = earliest - (earliest % SECONDS_PER_DAY);
let expected_last = latest - (latest % SECONDS_PER_DAY);
let (Some(first), Some(last)) = (report.day_counts.first(), report.day_counts.last()) else {
return Err(invalid_validation(
"positive included count requires nonempty day counts",
));
};
let first = first.day_start_timestamp_seconds;
let last = last.day_start_timestamp_seconds;
if first != expected_first || last != expected_last {
return Err(invalid_validation(
"day count endpoints do not cover the included timestamp range",
));
}
Ok(())
}
fn add_dimension_count(
total: u64,
count: u64,
dimension: &'static str,
) -> Result<u64, NnsProposalActivityValidationError> {
if count == 0 {
return Err(invalid_validation(format!(
"{dimension} count row must be nonzero"
)));
}
total
.checked_add(count)
.ok_or_else(|| invalid_validation(format!("{dimension} count total overflow")))
}
fn validate_dimension_total(
actual: u64,
expected: u64,
dimension: &'static str,
) -> Result<(), NnsProposalActivityValidationError> {
if actual == expected {
Ok(())
} else {
Err(invalid_validation(format!(
"{dimension} counts sum to {actual}, expected {expected}"
)))
}
}
fn invalid_validation(reason: impl Into<String>) -> NnsProposalActivityValidationError {
NnsProposalActivityValidationError {
reason: reason.into(),
}
}
pub fn build_nns_proposal_activity_report(
request: &NnsProposalActivityRequest,
collection: &NnsProposalCollectionState,
proposals: &[NnsProposalRow],
) -> Result<NnsProposalActivityReport, NnsProposalActivityError> {
validate_collection_state(collection).map_err(|error| {
NnsProposalActivityError::InvalidCollectionState {
reason: error.to_string(),
}
})?;
if !collection.is_complete() {
return Err(NnsProposalActivityError::CollectionNotComplete {
status: collection.status(),
});
}
validate_time_window(request)?;
let expected = collection.proposals_fetched();
let actual = u64::try_from(proposals.len()).map_err(|_| {
NnsProposalActivityError::AccountingOverflow {
field: "supplied_proposal_count",
}
})?;
if actual != expected {
return Err(NnsProposalActivityError::ProposalCountMismatch { expected, actual });
}
let mut activity = ActivityAccumulator::with_capacity(proposals.len());
for proposal in proposals {
activity.observe(request, proposal)?;
}
let source = collection.source().cloned().ok_or_else(|| {
NnsProposalActivityError::InvalidCollectionState {
reason: "complete collection has no concrete source provenance".to_string(),
}
})?;
Ok(activity.into_report(request, collection, expected, source))
}
struct ActivityAccumulator {
proposal_ids: HashSet<u64>,
topic_counts: BTreeMap<i32, u64>,
status_counts: BTreeMap<i32, u64>,
reward_status_counts: BTreeMap<i32, u64>,
day_counts: BTreeMap<u64, u64>,
included_proposal_count: u64,
excluded_before_from_count: u64,
excluded_at_or_after_until_count: u64,
included_timestamp_bounds: Option<(u64, u64)>,
}
impl ActivityAccumulator {
fn with_capacity(proposal_count: usize) -> Self {
Self {
proposal_ids: HashSet::with_capacity(proposal_count),
topic_counts: BTreeMap::new(),
status_counts: BTreeMap::new(),
reward_status_counts: BTreeMap::new(),
day_counts: BTreeMap::new(),
included_proposal_count: 0,
excluded_before_from_count: 0,
excluded_at_or_after_until_count: 0,
included_timestamp_bounds: None,
}
}
fn observe(
&mut self,
request: &NnsProposalActivityRequest,
proposal: &NnsProposalRow,
) -> Result<(), NnsProposalActivityError> {
validate_proposal_row(proposal, &mut self.proposal_ids)?;
let timestamp = proposal.proposal_timestamp_seconds;
if request
.from_proposal_timestamp_seconds
.is_some_and(|from| timestamp < from)
{
return increment_count(
&mut self.excluded_before_from_count,
"excluded_before_from_count",
);
}
if request
.until_proposal_timestamp_seconds
.is_some_and(|until| timestamp >= until)
{
return increment_count(
&mut self.excluded_at_or_after_until_count,
"excluded_at_or_after_until_count",
);
}
increment_count(&mut self.included_proposal_count, "included_proposal_count")?;
increment_count(
self.topic_counts.entry(proposal.topic).or_default(),
"topic_count",
)?;
increment_count(
self.status_counts.entry(proposal.status).or_default(),
"status_count",
)?;
increment_count(
self.reward_status_counts
.entry(proposal.reward_status)
.or_default(),
"reward_status_count",
)?;
let day_start = timestamp - (timestamp % SECONDS_PER_DAY);
increment_count(self.day_counts.entry(day_start).or_default(), "day_count")?;
self.included_timestamp_bounds = Some(match self.included_timestamp_bounds {
Some((earliest, latest)) => (earliest.min(timestamp), latest.max(timestamp)),
None => (timestamp, timestamp),
});
Ok(())
}
fn into_report(
self,
request: &NnsProposalActivityRequest,
collection: &NnsProposalCollectionState,
collected_proposal_count: u64,
source: NnsGovernanceSourceProvenance,
) -> NnsProposalActivityReport {
NnsProposalActivityReport {
schema_version: NNS_PROPOSAL_ACTIVITY_REPORT_SCHEMA_VERSION,
network: collection.network().to_string(),
governance_canister_id: collection.governance_canister_id().to_string(),
source,
collection_started_at: collection.started_at().to_string(),
collection_updated_at: collection.updated_at().to_string(),
collection_page_count: collection.pages_fetched(),
collected_proposal_count,
point_in_time_guaranteed: false,
from_proposal_timestamp_seconds: request.from_proposal_timestamp_seconds,
until_proposal_timestamp_seconds: request.until_proposal_timestamp_seconds,
included_proposal_count: self.included_proposal_count,
excluded_before_from_count: self.excluded_before_from_count,
excluded_at_or_after_until_count: self.excluded_at_or_after_until_count,
earliest_included_proposal_timestamp_seconds: self
.included_timestamp_bounds
.map(|bounds| bounds.0),
latest_included_proposal_timestamp_seconds: self
.included_timestamp_bounds
.map(|bounds| bounds.1),
topic_counts: self
.topic_counts
.into_iter()
.map(|(topic, proposal_count)| NnsProposalTopicCount {
topic,
proposal_count,
})
.collect(),
status_counts: self
.status_counts
.into_iter()
.map(|(status, proposal_count)| NnsProposalStatusCount {
status,
proposal_count,
})
.collect(),
reward_status_counts: self
.reward_status_counts
.into_iter()
.map(
|(reward_status, proposal_count)| NnsProposalRewardStatusCount {
reward_status,
proposal_count,
},
)
.collect(),
day_counts: self
.day_counts
.into_iter()
.map(
|(day_start_timestamp_seconds, proposal_count)| NnsProposalDayCount {
day_start_timestamp_seconds,
proposal_count,
},
)
.collect(),
}
}
}
pub(super) const fn validate_time_window(
request: &NnsProposalActivityRequest,
) -> Result<(), NnsProposalActivityError> {
if let (Some(from), Some(until)) = (
request.from_proposal_timestamp_seconds,
request.until_proposal_timestamp_seconds,
) && from >= until
{
return Err(NnsProposalActivityError::InvalidTimeWindow {
from_proposal_timestamp_seconds: from,
until_proposal_timestamp_seconds: until,
});
}
Ok(())
}
fn validate_proposal_row(
proposal: &NnsProposalRow,
proposal_ids: &mut HashSet<u64>,
) -> Result<(), NnsProposalActivityError> {
let proposal_id = proposal
.proposal_id
.ok_or(NnsProposalActivityError::MissingProposalId)?;
if proposal_id == 0 {
return Err(NnsProposalActivityError::ZeroProposalId);
}
if !proposal_ids.insert(proposal_id) {
return Err(NnsProposalActivityError::DuplicateProposalId { proposal_id });
}
if proposal.proposal_timestamp_seconds == 0 {
return Err(NnsProposalActivityError::ZeroProposalTimestamp { proposal_id });
}
Ok(())
}
fn increment_count(count: &mut u64, field: &'static str) -> Result<(), NnsProposalActivityError> {
*count = count
.checked_add(1)
.ok_or(NnsProposalActivityError::AccountingOverflow { field })?;
Ok(())
}
#[cfg(test)]
mod tests;