use super::{
NnsProposalCollectionState, NnsProposalCollectionStatus,
collection::validate_collection_state,
model::{NnsProposalRewardStatus, NnsProposalRow, NnsProposalStatus, NnsProposalTopic},
};
use crate::{
nns::{
MAINNET_GOVERNANCE_CANISTER_ID,
governance::{NnsGovernanceSourceProvenance, validate_governance_report_source},
},
subnet_catalog::MAINNET_NETWORK,
};
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)]
pub struct NnsProposalTopicCount {
pub topic: i32,
pub topic_text: NnsProposalTopic,
pub proposal_count: u64,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub struct NnsProposalStatusCount {
pub status: i32,
pub status_text: NnsProposalStatus,
pub proposal_count: u64,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub struct NnsProposalRewardStatusCount {
pub reward_status: i32,
pub reward_status_text: NnsProposalRewardStatus,
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: NnsProposalCollectionStatus,
},
#[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 {proposal_id} topic classification {actual:?} does not match raw code {topic} ({expected:?})"
)]
TopicClassificationMismatch {
proposal_id: u64,
topic: i32,
actual: NnsProposalTopic,
expected: NnsProposalTopic,
},
#[error(
"NNS proposal {proposal_id} status classification {actual:?} does not match raw code {status} ({expected:?})"
)]
StatusClassificationMismatch {
proposal_id: u64,
status: i32,
actual: NnsProposalStatus,
expected: NnsProposalStatus,
},
#[error(
"NNS proposal {proposal_id} reward-status classification {actual:?} does not match raw code {reward_status} ({expected:?})"
)]
RewardStatusClassificationMismatch {
proposal_id: u64,
reward_status: i32,
actual: NnsProposalRewardStatus,
expected: NnsProposalRewardStatus,
},
#[error("NNS proposal activity accounting overflow while updating {field}")]
AccountingOverflow {
field: &'static str,
},
#[error(transparent)]
InvalidReport(#[from] NnsProposalActivityValidationError),
}
pub fn validate_nns_proposal_activity_report(
report: &NnsProposalActivityReport,
) -> Result<(), NnsProposalActivityValidationError> {
validate_activity_header(report)?;
validate_activity_selection(report)?;
validate_topic_counts(report)?;
validate_status_counts(report)?;
validate_reward_status_counts(report)?;
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
)));
}
if report.network != MAINNET_NETWORK {
return Err(invalid_validation(format!(
"network is {}, expected {MAINNET_NETWORK}",
report.network
)));
}
if report.governance_canister_id != MAINNET_GOVERNANCE_CANISTER_ID {
return Err(invalid_validation(format!(
"governance_canister_id is {}, expected {MAINNET_GOVERNANCE_CANISTER_ID}",
report.governance_canister_id
)));
}
if report.collection_page_count == 0 {
return Err(invalid_validation(
"complete activity report must retain at least one collection page",
));
}
if report.point_in_time_guaranteed {
return Err(invalid_validation(
"sequential proposal activity cannot claim a point-in-time snapshot",
));
}
validate_governance_report_source(&report.network, &report.source).map_err(|error| {
let context = match &report.source {
NnsGovernanceSourceProvenance::ReplicaQuery { .. } => "source",
NnsGovernanceSourceProvenance::ReplicatedInterCanisterCall { .. } => "provenance",
};
invalid_validation(format!("invalid collection {context}: {error}"))
})
}
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_topic_counts(
report: &NnsProposalActivityReport,
) -> Result<(), NnsProposalActivityValidationError> {
let mut previous = None;
let mut total = 0_u64;
for row in &report.topic_counts {
if previous.is_some_and(|topic| topic >= row.topic) {
return Err(invalid_validation(
"topic count rows are not strictly raw-code ordered",
));
}
if row.topic_text != NnsProposalTopic::from_code(row.topic) {
return Err(invalid_validation(format!(
"topic classification for raw code {} is inconsistent",
row.topic
)));
}
total = add_dimension_count(total, row.proposal_count, "topic")?;
previous = Some(row.topic);
}
validate_dimension_total(total, report.included_proposal_count, "topic")
}
fn validate_status_counts(
report: &NnsProposalActivityReport,
) -> Result<(), NnsProposalActivityValidationError> {
let mut previous = None;
let mut total = 0_u64;
for row in &report.status_counts {
if previous.is_some_and(|status| status >= row.status) {
return Err(invalid_validation(
"status count rows are not strictly raw-code ordered",
));
}
if row.status_text != NnsProposalStatus::from_code(row.status) {
return Err(invalid_validation(format!(
"status classification for raw code {} is inconsistent",
row.status
)));
}
total = add_dimension_count(total, row.proposal_count, "status")?;
previous = Some(row.status);
}
validate_dimension_total(total, report.included_proposal_count, "status")
}
fn validate_reward_status_counts(
report: &NnsProposalActivityReport,
) -> Result<(), NnsProposalActivityValidationError> {
let mut previous = None;
let mut total = 0_u64;
for row in &report.reward_status_counts {
if previous.is_some_and(|reward_status| reward_status >= row.reward_status) {
return Err(invalid_validation(
"reward-status count rows are not strictly raw-code ordered",
));
}
if row.reward_status_text != NnsProposalRewardStatus::from_code(row.reward_status) {
return Err(invalid_validation(format!(
"reward-status classification for raw code {} is inconsistent",
row.reward_status
)));
}
total = add_dimension_count(total, row.proposal_count, "reward-status")?;
previous = Some(row.reward_status);
}
validate_dimension_total(total, report.included_proposal_count, "reward-status")
}
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(),
}
})?;
let report = activity.into_report(request, collection, expected, source);
validate_nns_proposal_activity_report(&report)?;
Ok(report)
}
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,
earliest_included_proposal_timestamp_seconds: Option<u64>,
latest_included_proposal_timestamp_seconds: Option<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,
earliest_included_proposal_timestamp_seconds: None,
latest_included_proposal_timestamp_seconds: 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.earliest_included_proposal_timestamp_seconds = Some(
self.earliest_included_proposal_timestamp_seconds
.map_or(timestamp, |earliest| earliest.min(timestamp)),
);
self.latest_included_proposal_timestamp_seconds = Some(
self.latest_included_proposal_timestamp_seconds
.map_or(timestamp, |latest| latest.max(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
.earliest_included_proposal_timestamp_seconds,
latest_included_proposal_timestamp_seconds: self
.latest_included_proposal_timestamp_seconds,
topic_counts: self
.topic_counts
.into_iter()
.map(|(topic, proposal_count)| NnsProposalTopicCount {
topic,
topic_text: NnsProposalTopic::from_code(topic),
proposal_count,
})
.collect(),
status_counts: self
.status_counts
.into_iter()
.map(|(status, proposal_count)| NnsProposalStatusCount {
status,
status_text: NnsProposalStatus::from_code(status),
proposal_count,
})
.collect(),
reward_status_counts: self
.reward_status_counts
.into_iter()
.map(
|(reward_status, proposal_count)| NnsProposalRewardStatusCount {
reward_status,
reward_status_text: NnsProposalRewardStatus::from_code(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(),
}
}
}
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 });
}
let expected_topic = NnsProposalTopic::from_code(proposal.topic);
if proposal.topic_text != expected_topic {
return Err(NnsProposalActivityError::TopicClassificationMismatch {
proposal_id,
topic: proposal.topic,
actual: proposal.topic_text,
expected: expected_topic,
});
}
let expected_status = NnsProposalStatus::from_code(proposal.status);
if proposal.status_text != expected_status {
return Err(NnsProposalActivityError::StatusClassificationMismatch {
proposal_id,
status: proposal.status,
actual: proposal.status_text,
expected: expected_status,
});
}
let expected_reward_status = NnsProposalRewardStatus::from_code(proposal.reward_status);
if proposal.reward_status_text != expected_reward_status {
return Err(
NnsProposalActivityError::RewardStatusClassificationMismatch {
proposal_id,
reward_status: proposal.reward_status,
actual: proposal.reward_status_text,
expected: expected_reward_status,
},
);
}
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;