1use super::{
8 NnsProposalCollectionState, collection::validate_collection_state, model::NnsProposalRow,
9};
10use crate::nns::governance::{
11 NnsGovernanceCollectionStatus, NnsGovernanceSourceProvenance,
12 validate_governance_collection_source, validate_governance_report_identity,
13 validate_governance_time_interval,
14};
15use serde::{Deserialize, Serialize};
16use std::collections::{BTreeMap, HashSet};
17use thiserror::Error as ThisError;
18
19pub const NNS_PROPOSAL_ACTIVITY_REPORT_SCHEMA_VERSION: u32 = 1;
21
22const SECONDS_PER_DAY: u64 = 86_400;
23
24#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
31pub struct NnsProposalActivityRequest {
32 pub from_proposal_timestamp_seconds: Option<u64>,
34 pub until_proposal_timestamp_seconds: Option<u64>,
36}
37
38#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
45#[serde(deny_unknown_fields)]
46pub struct NnsProposalTopicCount {
47 pub topic: i32,
49 pub proposal_count: u64,
51}
52
53#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
60#[serde(deny_unknown_fields)]
61pub struct NnsProposalStatusCount {
62 pub status: i32,
64 pub proposal_count: u64,
66}
67
68#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
75#[serde(deny_unknown_fields)]
76pub struct NnsProposalRewardStatusCount {
77 pub reward_status: i32,
79 pub proposal_count: u64,
81}
82
83#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
90pub struct NnsProposalDayCount {
91 pub day_start_timestamp_seconds: u64,
93 pub proposal_count: u64,
95}
96
97#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
104pub struct NnsProposalActivityReport {
105 pub schema_version: u32,
107 pub network: String,
109 pub governance_canister_id: String,
111 pub source: NnsGovernanceSourceProvenance,
113 pub collection_started_at: String,
115 pub collection_updated_at: String,
117 pub collection_page_count: u32,
119 pub collected_proposal_count: u64,
121 pub point_in_time_guaranteed: bool,
123 pub from_proposal_timestamp_seconds: Option<u64>,
125 pub until_proposal_timestamp_seconds: Option<u64>,
127 pub included_proposal_count: u64,
129 pub excluded_before_from_count: u64,
131 pub excluded_at_or_after_until_count: u64,
133 pub earliest_included_proposal_timestamp_seconds: Option<u64>,
135 pub latest_included_proposal_timestamp_seconds: Option<u64>,
137 pub topic_counts: Vec<NnsProposalTopicCount>,
139 pub status_counts: Vec<NnsProposalStatusCount>,
141 pub reward_status_counts: Vec<NnsProposalRewardStatusCount>,
143 pub day_counts: Vec<NnsProposalDayCount>,
145}
146
147#[derive(Debug, Eq, PartialEq, ThisError)]
154#[error("invalid NNS proposal activity report: {reason}")]
155pub struct NnsProposalActivityValidationError {
156 pub reason: String,
158}
159
160#[derive(Debug, ThisError)]
167pub enum NnsProposalActivityError {
168 #[error("invalid NNS proposal collection state for activity projection: {reason}")]
170 InvalidCollectionState {
171 reason: String,
173 },
174
175 #[error("NNS proposal activity requires a complete collection; state is {status}")]
177 CollectionNotComplete {
178 status: NnsGovernanceCollectionStatus,
180 },
181
182 #[error(
184 "invalid NNS proposal activity time window: from {from_proposal_timestamp_seconds} must be below until {until_proposal_timestamp_seconds}"
185 )]
186 InvalidTimeWindow {
187 from_proposal_timestamp_seconds: u64,
189 until_proposal_timestamp_seconds: u64,
191 },
192
193 #[error(
195 "NNS proposal activity received {actual} rows; complete collection accounts for {expected}"
196 )]
197 ProposalCountMismatch {
198 expected: u64,
200 actual: u64,
202 },
203
204 #[error("NNS proposal activity received a row without a proposal id")]
206 MissingProposalId,
207
208 #[error("NNS proposal activity received proposal id zero")]
210 ZeroProposalId,
211
212 #[error("NNS proposal activity received duplicate proposal id {proposal_id}")]
214 DuplicateProposalId {
215 proposal_id: u64,
217 },
218
219 #[error("NNS proposal {proposal_id} has proposal timestamp zero")]
221 ZeroProposalTimestamp {
222 proposal_id: u64,
224 },
225
226 #[error("NNS proposal activity accounting overflow while updating {field}")]
228 AccountingOverflow {
229 field: &'static str,
231 },
232}
233
234pub fn validate_nns_proposal_activity_report(
236 report: &NnsProposalActivityReport,
237) -> Result<(), NnsProposalActivityValidationError> {
238 validate_activity_header(report)?;
239 validate_activity_selection(report)?;
240 validate_code_counts(
241 report,
242 "topic",
243 report
244 .topic_counts
245 .iter()
246 .map(|row| (row.topic, row.proposal_count)),
247 )?;
248 validate_code_counts(
249 report,
250 "status",
251 report
252 .status_counts
253 .iter()
254 .map(|row| (row.status, row.proposal_count)),
255 )?;
256 validate_code_counts(
257 report,
258 "reward-status",
259 report
260 .reward_status_counts
261 .iter()
262 .map(|row| (row.reward_status, row.proposal_count)),
263 )?;
264 validate_day_counts(report)
265}
266
267fn validate_activity_header(
268 report: &NnsProposalActivityReport,
269) -> Result<(), NnsProposalActivityValidationError> {
270 if report.schema_version != NNS_PROPOSAL_ACTIVITY_REPORT_SCHEMA_VERSION {
271 return Err(invalid_validation(format!(
272 "schema version {} does not equal {}",
273 report.schema_version, NNS_PROPOSAL_ACTIVITY_REPORT_SCHEMA_VERSION
274 )));
275 }
276 validate_governance_report_identity(&report.network, &report.governance_canister_id)
277 .map_err(invalid_validation)?;
278 if report.collection_page_count == 0 {
279 return Err(invalid_validation(
280 "complete activity report must retain at least one collection page",
281 ));
282 }
283 let minimum_proposal_count = u64::from(report.collection_page_count - 1);
284 if report.collected_proposal_count < minimum_proposal_count {
285 return Err(invalid_validation(format!(
286 "collection_page_count {} requires at least {minimum_proposal_count} collected proposals, found {}",
287 report.collection_page_count, report.collected_proposal_count
288 )));
289 }
290 validate_governance_time_interval(
291 "collection_started_at",
292 &report.collection_started_at,
293 "collection_updated_at",
294 &report.collection_updated_at,
295 )
296 .map_err(|error| invalid_validation(error.to_string()))?;
297 if report.point_in_time_guaranteed {
298 return Err(invalid_validation(
299 "sequential proposal activity cannot claim a point-in-time snapshot",
300 ));
301 }
302
303 validate_governance_collection_source(&report.network, &report.source)
304 .map_err(invalid_validation)
305}
306
307fn validate_activity_selection(
308 report: &NnsProposalActivityReport,
309) -> Result<(), NnsProposalActivityValidationError> {
310 if let (Some(from), Some(until)) = (
311 report.from_proposal_timestamp_seconds,
312 report.until_proposal_timestamp_seconds,
313 ) && from >= until
314 {
315 return Err(invalid_validation(format!(
316 "from proposal timestamp {from} must be below until timestamp {until}"
317 )));
318 }
319 if report.from_proposal_timestamp_seconds.is_none() && report.excluded_before_from_count != 0 {
320 return Err(invalid_validation(
321 "excluded_before_from_count must be zero without a lower bound",
322 ));
323 }
324 if report.until_proposal_timestamp_seconds.is_none()
325 && report.excluded_at_or_after_until_count != 0
326 {
327 return Err(invalid_validation(
328 "excluded_at_or_after_until_count must be zero without an upper bound",
329 ));
330 }
331
332 let accounted = report
333 .included_proposal_count
334 .checked_add(report.excluded_before_from_count)
335 .and_then(|count| count.checked_add(report.excluded_at_or_after_until_count))
336 .ok_or_else(|| invalid_validation("proposal selection count overflow"))?;
337 if accounted != report.collected_proposal_count {
338 return Err(invalid_validation(format!(
339 "selection accounts for {accounted} proposals, expected {}",
340 report.collected_proposal_count
341 )));
342 }
343 validate_included_range(report)
344}
345
346fn validate_included_range(
347 report: &NnsProposalActivityReport,
348) -> Result<(), NnsProposalActivityValidationError> {
349 let (earliest, latest) = match (
350 report.earliest_included_proposal_timestamp_seconds,
351 report.latest_included_proposal_timestamp_seconds,
352 ) {
353 (None, None) if report.included_proposal_count == 0 => return Ok(()),
354 (Some(earliest), Some(latest)) if report.included_proposal_count > 0 => (earliest, latest),
355 _ => {
356 return Err(invalid_validation(
357 "included timestamp range presence disagrees with included_proposal_count",
358 ));
359 }
360 };
361 if earliest == 0 || earliest > latest {
362 return Err(invalid_validation(
363 "included proposal timestamps must be nonzero and ascending",
364 ));
365 }
366 if report
367 .from_proposal_timestamp_seconds
368 .is_some_and(|from| earliest < from)
369 {
370 return Err(invalid_validation(
371 "earliest included proposal timestamp precedes the lower bound",
372 ));
373 }
374 if report
375 .until_proposal_timestamp_seconds
376 .is_some_and(|until| latest >= until)
377 {
378 return Err(invalid_validation(
379 "latest included proposal timestamp reaches or exceeds the upper bound",
380 ));
381 }
382 Ok(())
383}
384
385fn validate_code_counts(
386 report: &NnsProposalActivityReport,
387 dimension: &'static str,
388 rows: impl Iterator<Item = (i32, u64)>,
389) -> Result<(), NnsProposalActivityValidationError> {
390 let mut previous = None;
391 let mut total = 0_u64;
392 for (key, count) in rows {
393 if previous.is_some_and(|previous| previous >= key) {
394 return Err(invalid_validation(format!(
395 "{dimension} count rows are not strictly raw-code ordered"
396 )));
397 }
398 total = add_dimension_count(total, count, dimension)?;
399 previous = Some(key);
400 }
401 validate_dimension_total(total, report.included_proposal_count, dimension)
402}
403
404fn validate_day_counts(
405 report: &NnsProposalActivityReport,
406) -> Result<(), NnsProposalActivityValidationError> {
407 let mut previous = None;
408 let mut total = 0_u64;
409 for row in &report.day_counts {
410 if row.day_start_timestamp_seconds % SECONDS_PER_DAY != 0 {
411 return Err(invalid_validation(
412 "day count row is not aligned to 00:00:00 UTC",
413 ));
414 }
415 if previous.is_some_and(|day| day >= row.day_start_timestamp_seconds) {
416 return Err(invalid_validation(
417 "day count rows are not strictly time ordered",
418 ));
419 }
420 total = add_dimension_count(total, row.proposal_count, "day")?;
421 previous = Some(row.day_start_timestamp_seconds);
422 }
423 validate_dimension_total(total, report.included_proposal_count, "day")?;
424 validate_day_range(report)
425}
426
427fn validate_day_range(
428 report: &NnsProposalActivityReport,
429) -> Result<(), NnsProposalActivityValidationError> {
430 if report.included_proposal_count == 0 {
431 return Ok(());
432 }
433 let (Some(earliest), Some(latest)) = (
434 report.earliest_included_proposal_timestamp_seconds,
435 report.latest_included_proposal_timestamp_seconds,
436 ) else {
437 return Err(invalid_validation(
438 "included timestamp range is absent for positive day counts",
439 ));
440 };
441 let expected_first = earliest - (earliest % SECONDS_PER_DAY);
442 let expected_last = latest - (latest % SECONDS_PER_DAY);
443 let (Some(first), Some(last)) = (report.day_counts.first(), report.day_counts.last()) else {
444 return Err(invalid_validation(
445 "positive included count requires nonempty day counts",
446 ));
447 };
448 let first = first.day_start_timestamp_seconds;
449 let last = last.day_start_timestamp_seconds;
450 if first != expected_first || last != expected_last {
451 return Err(invalid_validation(
452 "day count endpoints do not cover the included timestamp range",
453 ));
454 }
455 Ok(())
456}
457
458fn add_dimension_count(
459 total: u64,
460 count: u64,
461 dimension: &'static str,
462) -> Result<u64, NnsProposalActivityValidationError> {
463 if count == 0 {
464 return Err(invalid_validation(format!(
465 "{dimension} count row must be nonzero"
466 )));
467 }
468 total
469 .checked_add(count)
470 .ok_or_else(|| invalid_validation(format!("{dimension} count total overflow")))
471}
472
473fn validate_dimension_total(
474 actual: u64,
475 expected: u64,
476 dimension: &'static str,
477) -> Result<(), NnsProposalActivityValidationError> {
478 if actual == expected {
479 Ok(())
480 } else {
481 Err(invalid_validation(format!(
482 "{dimension} counts sum to {actual}, expected {expected}"
483 )))
484 }
485}
486
487fn invalid_validation(reason: impl Into<String>) -> NnsProposalActivityValidationError {
488 NnsProposalActivityValidationError {
489 reason: reason.into(),
490 }
491}
492
493pub fn build_nns_proposal_activity_report(
495 request: &NnsProposalActivityRequest,
496 collection: &NnsProposalCollectionState,
497 proposals: &[NnsProposalRow],
498) -> Result<NnsProposalActivityReport, NnsProposalActivityError> {
499 validate_collection_state(collection).map_err(|error| {
500 NnsProposalActivityError::InvalidCollectionState {
501 reason: error.to_string(),
502 }
503 })?;
504 if !collection.is_complete() {
505 return Err(NnsProposalActivityError::CollectionNotComplete {
506 status: collection.status(),
507 });
508 }
509 validate_time_window(request)?;
510
511 let expected = collection.proposals_fetched();
512 let actual = u64::try_from(proposals.len()).map_err(|_| {
513 NnsProposalActivityError::AccountingOverflow {
514 field: "supplied_proposal_count",
515 }
516 })?;
517 if actual != expected {
518 return Err(NnsProposalActivityError::ProposalCountMismatch { expected, actual });
519 }
520
521 let mut proposal_ids = HashSet::with_capacity(proposals.len());
522 aggregate_activity(request, collection, proposals, |proposal| {
523 validate_proposal_row(proposal, &mut proposal_ids)
524 })
525}
526
527#[cfg(feature = "nns-host")]
528pub(super) fn build_activity_from_cache(
529 request: &NnsProposalActivityRequest,
530 cache: &super::cache::AdmittedProposalCache,
531) -> Result<NnsProposalActivityReport, NnsProposalActivityError> {
532 aggregate_activity(
534 request,
535 cache.collection_state(),
536 cache.rows(),
537 |proposal| {
538 let proposal_id = proposal
539 .proposal_id
540 .ok_or(NnsProposalActivityError::MissingProposalId)?;
541 validate_proposal_timestamp(proposal, proposal_id)
542 },
543 )
544}
545
546fn aggregate_activity(
547 request: &NnsProposalActivityRequest,
548 collection: &NnsProposalCollectionState,
549 proposals: &[NnsProposalRow],
550 mut validate_row: impl FnMut(&NnsProposalRow) -> Result<(), NnsProposalActivityError>,
551) -> Result<NnsProposalActivityReport, NnsProposalActivityError> {
552 let mut activity = ActivityAccumulator::default();
553 for proposal in proposals {
554 validate_row(proposal)?;
555 activity.observe(request, proposal)?;
556 }
557 let source = collection.source().cloned().ok_or_else(|| {
558 NnsProposalActivityError::InvalidCollectionState {
559 reason: "complete collection has no concrete source provenance".to_string(),
560 }
561 })?;
562 Ok(activity.into_report(request, collection, collection.proposals_fetched(), source))
563}
564
565#[derive(Default)]
566struct ActivityAccumulator {
567 topic_counts: BTreeMap<i32, u64>,
568 status_counts: BTreeMap<i32, u64>,
569 reward_status_counts: BTreeMap<i32, u64>,
570 day_counts: BTreeMap<u64, u64>,
571 included_proposal_count: u64,
572 excluded_before_from_count: u64,
573 excluded_at_or_after_until_count: u64,
574 included_timestamp_bounds: Option<(u64, u64)>,
575}
576
577impl ActivityAccumulator {
578 fn observe(
579 &mut self,
580 request: &NnsProposalActivityRequest,
581 proposal: &NnsProposalRow,
582 ) -> Result<(), NnsProposalActivityError> {
583 let timestamp = proposal.proposal_timestamp_seconds;
584 if request
585 .from_proposal_timestamp_seconds
586 .is_some_and(|from| timestamp < from)
587 {
588 return increment_count(
589 &mut self.excluded_before_from_count,
590 "excluded_before_from_count",
591 );
592 }
593 if request
594 .until_proposal_timestamp_seconds
595 .is_some_and(|until| timestamp >= until)
596 {
597 return increment_count(
598 &mut self.excluded_at_or_after_until_count,
599 "excluded_at_or_after_until_count",
600 );
601 }
602
603 increment_count(&mut self.included_proposal_count, "included_proposal_count")?;
604 increment_count(
605 self.topic_counts.entry(proposal.topic).or_default(),
606 "topic_count",
607 )?;
608 increment_count(
609 self.status_counts.entry(proposal.status).or_default(),
610 "status_count",
611 )?;
612 increment_count(
613 self.reward_status_counts
614 .entry(proposal.reward_status)
615 .or_default(),
616 "reward_status_count",
617 )?;
618 let day_start = timestamp - (timestamp % SECONDS_PER_DAY);
619 increment_count(self.day_counts.entry(day_start).or_default(), "day_count")?;
620 self.included_timestamp_bounds = Some(match self.included_timestamp_bounds {
621 Some((earliest, latest)) => (earliest.min(timestamp), latest.max(timestamp)),
622 None => (timestamp, timestamp),
623 });
624 Ok(())
625 }
626
627 fn into_report(
628 self,
629 request: &NnsProposalActivityRequest,
630 collection: &NnsProposalCollectionState,
631 collected_proposal_count: u64,
632 source: NnsGovernanceSourceProvenance,
633 ) -> NnsProposalActivityReport {
634 NnsProposalActivityReport {
635 schema_version: NNS_PROPOSAL_ACTIVITY_REPORT_SCHEMA_VERSION,
636 network: collection.network().to_string(),
637 governance_canister_id: collection.governance_canister_id().to_string(),
638 source,
639 collection_started_at: collection.started_at().to_string(),
640 collection_updated_at: collection.updated_at().to_string(),
641 collection_page_count: collection.pages_fetched(),
642 collected_proposal_count,
643 point_in_time_guaranteed: false,
644 from_proposal_timestamp_seconds: request.from_proposal_timestamp_seconds,
645 until_proposal_timestamp_seconds: request.until_proposal_timestamp_seconds,
646 included_proposal_count: self.included_proposal_count,
647 excluded_before_from_count: self.excluded_before_from_count,
648 excluded_at_or_after_until_count: self.excluded_at_or_after_until_count,
649 earliest_included_proposal_timestamp_seconds: self
650 .included_timestamp_bounds
651 .map(|bounds| bounds.0),
652 latest_included_proposal_timestamp_seconds: self
653 .included_timestamp_bounds
654 .map(|bounds| bounds.1),
655 topic_counts: self
656 .topic_counts
657 .into_iter()
658 .map(|(topic, proposal_count)| NnsProposalTopicCount {
659 topic,
660
661 proposal_count,
662 })
663 .collect(),
664 status_counts: self
665 .status_counts
666 .into_iter()
667 .map(|(status, proposal_count)| NnsProposalStatusCount {
668 status,
669
670 proposal_count,
671 })
672 .collect(),
673 reward_status_counts: self
674 .reward_status_counts
675 .into_iter()
676 .map(
677 |(reward_status, proposal_count)| NnsProposalRewardStatusCount {
678 reward_status,
679
680 proposal_count,
681 },
682 )
683 .collect(),
684 day_counts: self
685 .day_counts
686 .into_iter()
687 .map(
688 |(day_start_timestamp_seconds, proposal_count)| NnsProposalDayCount {
689 day_start_timestamp_seconds,
690 proposal_count,
691 },
692 )
693 .collect(),
694 }
695 }
696}
697
698pub(super) const fn validate_time_window(
699 request: &NnsProposalActivityRequest,
700) -> Result<(), NnsProposalActivityError> {
701 if let (Some(from), Some(until)) = (
702 request.from_proposal_timestamp_seconds,
703 request.until_proposal_timestamp_seconds,
704 ) && from >= until
705 {
706 return Err(NnsProposalActivityError::InvalidTimeWindow {
707 from_proposal_timestamp_seconds: from,
708 until_proposal_timestamp_seconds: until,
709 });
710 }
711 Ok(())
712}
713
714fn validate_proposal_row(
715 proposal: &NnsProposalRow,
716 proposal_ids: &mut HashSet<u64>,
717) -> Result<(), NnsProposalActivityError> {
718 let proposal_id = proposal
719 .proposal_id
720 .ok_or(NnsProposalActivityError::MissingProposalId)?;
721 if proposal_id == 0 {
722 return Err(NnsProposalActivityError::ZeroProposalId);
723 }
724 if !proposal_ids.insert(proposal_id) {
725 return Err(NnsProposalActivityError::DuplicateProposalId { proposal_id });
726 }
727 validate_proposal_timestamp(proposal, proposal_id)
728}
729
730const fn validate_proposal_timestamp(
731 proposal: &NnsProposalRow,
732 proposal_id: u64,
733) -> Result<(), NnsProposalActivityError> {
734 if proposal.proposal_timestamp_seconds == 0 {
735 return Err(NnsProposalActivityError::ZeroProposalTimestamp { proposal_id });
736 }
737
738 Ok(())
739}
740
741fn increment_count(count: &mut u64, field: &'static str) -> Result<(), NnsProposalActivityError> {
742 *count = count
743 .checked_add(1)
744 .ok_or(NnsProposalActivityError::AccountingOverflow { field })?;
745 Ok(())
746}
747
748#[cfg(test)]
749mod tests;