1use super::{
8 NnsProposalCollectionState, collection::validate_collection_state, model::NnsProposalRow,
9};
10use crate::nns::governance::{
11 NnsGovernanceCollectionStatus, NnsGovernanceSourceProvenance,
12 validate_governance_analytics_pages, validate_governance_collection_source,
13 validate_governance_report_identity, 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 validate_governance_analytics_pages(
279 report.collection_page_count,
280 report.collected_proposal_count,
281 "activity",
282 "proposals",
283 )
284 .map_err(invalid_validation)?;
285 validate_governance_time_interval(
286 "collection_started_at",
287 &report.collection_started_at,
288 "collection_updated_at",
289 &report.collection_updated_at,
290 )
291 .map_err(|error| invalid_validation(error.to_string()))?;
292 if report.point_in_time_guaranteed {
293 return Err(invalid_validation(
294 "sequential proposal activity cannot claim a point-in-time snapshot",
295 ));
296 }
297
298 validate_governance_collection_source(&report.network, &report.source)
299 .map_err(invalid_validation)
300}
301
302fn validate_activity_selection(
303 report: &NnsProposalActivityReport,
304) -> Result<(), NnsProposalActivityValidationError> {
305 if let (Some(from), Some(until)) = (
306 report.from_proposal_timestamp_seconds,
307 report.until_proposal_timestamp_seconds,
308 ) && from >= until
309 {
310 return Err(invalid_validation(format!(
311 "from proposal timestamp {from} must be below until timestamp {until}"
312 )));
313 }
314 if report.from_proposal_timestamp_seconds.is_none() && report.excluded_before_from_count != 0 {
315 return Err(invalid_validation(
316 "excluded_before_from_count must be zero without a lower bound",
317 ));
318 }
319 if report.until_proposal_timestamp_seconds.is_none()
320 && report.excluded_at_or_after_until_count != 0
321 {
322 return Err(invalid_validation(
323 "excluded_at_or_after_until_count must be zero without an upper bound",
324 ));
325 }
326
327 let accounted = report
328 .included_proposal_count
329 .checked_add(report.excluded_before_from_count)
330 .and_then(|count| count.checked_add(report.excluded_at_or_after_until_count))
331 .ok_or_else(|| invalid_validation("proposal selection count overflow"))?;
332 if accounted != report.collected_proposal_count {
333 return Err(invalid_validation(format!(
334 "selection accounts for {accounted} proposals, expected {}",
335 report.collected_proposal_count
336 )));
337 }
338 validate_included_range(report)
339}
340
341fn validate_included_range(
342 report: &NnsProposalActivityReport,
343) -> Result<(), NnsProposalActivityValidationError> {
344 let (earliest, latest) = match (
345 report.earliest_included_proposal_timestamp_seconds,
346 report.latest_included_proposal_timestamp_seconds,
347 ) {
348 (None, None) if report.included_proposal_count == 0 => return Ok(()),
349 (Some(earliest), Some(latest)) if report.included_proposal_count > 0 => (earliest, latest),
350 _ => {
351 return Err(invalid_validation(
352 "included timestamp range presence disagrees with included_proposal_count",
353 ));
354 }
355 };
356 if earliest == 0 || earliest > latest {
357 return Err(invalid_validation(
358 "included proposal timestamps must be nonzero and ascending",
359 ));
360 }
361 if report
362 .from_proposal_timestamp_seconds
363 .is_some_and(|from| earliest < from)
364 {
365 return Err(invalid_validation(
366 "earliest included proposal timestamp precedes the lower bound",
367 ));
368 }
369 if report
370 .until_proposal_timestamp_seconds
371 .is_some_and(|until| latest >= until)
372 {
373 return Err(invalid_validation(
374 "latest included proposal timestamp reaches or exceeds the upper bound",
375 ));
376 }
377 Ok(())
378}
379
380fn validate_code_counts(
381 report: &NnsProposalActivityReport,
382 dimension: &'static str,
383 rows: impl Iterator<Item = (i32, u64)>,
384) -> Result<(), NnsProposalActivityValidationError> {
385 let mut previous = None;
386 let mut total = 0_u64;
387 for (key, count) in rows {
388 if previous.is_some_and(|previous| previous >= key) {
389 return Err(invalid_validation(format!(
390 "{dimension} count rows are not strictly raw-code ordered"
391 )));
392 }
393 total = add_dimension_count(total, count, dimension)?;
394 previous = Some(key);
395 }
396 validate_dimension_total(total, report.included_proposal_count, dimension)
397}
398
399fn validate_day_counts(
400 report: &NnsProposalActivityReport,
401) -> Result<(), NnsProposalActivityValidationError> {
402 let mut previous = None;
403 let mut total = 0_u64;
404 for row in &report.day_counts {
405 if row.day_start_timestamp_seconds % SECONDS_PER_DAY != 0 {
406 return Err(invalid_validation(
407 "day count row is not aligned to 00:00:00 UTC",
408 ));
409 }
410 if previous.is_some_and(|day| day >= row.day_start_timestamp_seconds) {
411 return Err(invalid_validation(
412 "day count rows are not strictly time ordered",
413 ));
414 }
415 total = add_dimension_count(total, row.proposal_count, "day")?;
416 previous = Some(row.day_start_timestamp_seconds);
417 }
418 validate_dimension_total(total, report.included_proposal_count, "day")?;
419 validate_day_range(report)
420}
421
422fn validate_day_range(
423 report: &NnsProposalActivityReport,
424) -> Result<(), NnsProposalActivityValidationError> {
425 if report.included_proposal_count == 0 {
426 return Ok(());
427 }
428 let (Some(earliest), Some(latest)) = (
429 report.earliest_included_proposal_timestamp_seconds,
430 report.latest_included_proposal_timestamp_seconds,
431 ) else {
432 return Err(invalid_validation(
433 "included timestamp range is absent for positive day counts",
434 ));
435 };
436 let expected_first = earliest - (earliest % SECONDS_PER_DAY);
437 let expected_last = latest - (latest % SECONDS_PER_DAY);
438 let (Some(first), Some(last)) = (report.day_counts.first(), report.day_counts.last()) else {
439 return Err(invalid_validation(
440 "positive included count requires nonempty day counts",
441 ));
442 };
443 let first = first.day_start_timestamp_seconds;
444 let last = last.day_start_timestamp_seconds;
445 if first != expected_first || last != expected_last {
446 return Err(invalid_validation(
447 "day count endpoints do not cover the included timestamp range",
448 ));
449 }
450 Ok(())
451}
452
453fn add_dimension_count(
454 total: u64,
455 count: u64,
456 dimension: &'static str,
457) -> Result<u64, NnsProposalActivityValidationError> {
458 if count == 0 {
459 return Err(invalid_validation(format!(
460 "{dimension} count row must be nonzero"
461 )));
462 }
463 total
464 .checked_add(count)
465 .ok_or_else(|| invalid_validation(format!("{dimension} count total overflow")))
466}
467
468fn validate_dimension_total(
469 actual: u64,
470 expected: u64,
471 dimension: &'static str,
472) -> Result<(), NnsProposalActivityValidationError> {
473 if actual == expected {
474 Ok(())
475 } else {
476 Err(invalid_validation(format!(
477 "{dimension} counts sum to {actual}, expected {expected}"
478 )))
479 }
480}
481
482fn invalid_validation(reason: impl Into<String>) -> NnsProposalActivityValidationError {
483 NnsProposalActivityValidationError {
484 reason: reason.into(),
485 }
486}
487
488pub fn build_nns_proposal_activity_report(
490 request: &NnsProposalActivityRequest,
491 collection: &NnsProposalCollectionState,
492 proposals: &[NnsProposalRow],
493) -> Result<NnsProposalActivityReport, NnsProposalActivityError> {
494 validate_collection_state(collection).map_err(|error| {
495 NnsProposalActivityError::InvalidCollectionState {
496 reason: error.to_string(),
497 }
498 })?;
499 if !collection.is_complete() {
500 return Err(NnsProposalActivityError::CollectionNotComplete {
501 status: collection.status(),
502 });
503 }
504 validate_time_window(request)?;
505
506 let expected = collection.proposals_fetched();
507 let actual = u64::try_from(proposals.len()).map_err(|_| {
508 NnsProposalActivityError::AccountingOverflow {
509 field: "supplied_proposal_count",
510 }
511 })?;
512 if actual != expected {
513 return Err(NnsProposalActivityError::ProposalCountMismatch { expected, actual });
514 }
515
516 let mut proposal_ids = HashSet::with_capacity(proposals.len());
517 aggregate_activity(request, collection, proposals, |proposal| {
518 validate_proposal_row(proposal, &mut proposal_ids)
519 })
520}
521
522#[cfg(feature = "nns-host")]
523pub(super) fn build_activity_from_cache(
524 request: &NnsProposalActivityRequest,
525 cache: &super::cache::AdmittedProposalCache,
526) -> Result<NnsProposalActivityReport, NnsProposalActivityError> {
527 aggregate_activity(
529 request,
530 cache.collection_state(),
531 cache.rows(),
532 |proposal| {
533 let proposal_id = proposal
534 .proposal_id
535 .ok_or(NnsProposalActivityError::MissingProposalId)?;
536 validate_proposal_timestamp(proposal, proposal_id)
537 },
538 )
539}
540
541fn aggregate_activity(
542 request: &NnsProposalActivityRequest,
543 collection: &NnsProposalCollectionState,
544 proposals: &[NnsProposalRow],
545 mut validate_row: impl FnMut(&NnsProposalRow) -> Result<(), NnsProposalActivityError>,
546) -> Result<NnsProposalActivityReport, NnsProposalActivityError> {
547 let mut activity = ActivityAccumulator::default();
548 for proposal in proposals {
549 validate_row(proposal)?;
550 activity.observe(request, proposal)?;
551 }
552 let source = collection.source().cloned().ok_or_else(|| {
553 NnsProposalActivityError::InvalidCollectionState {
554 reason: "complete collection has no concrete source provenance".to_string(),
555 }
556 })?;
557 Ok(activity.into_report(request, collection, source))
558}
559
560#[derive(Default)]
561struct ActivityAccumulator {
562 topic_counts: BTreeMap<i32, u64>,
563 status_counts: BTreeMap<i32, u64>,
564 reward_status_counts: BTreeMap<i32, u64>,
565 day_counts: BTreeMap<u64, u64>,
566 included_proposal_count: u64,
567 excluded_before_from_count: u64,
568 excluded_at_or_after_until_count: u64,
569 included_timestamp_bounds: Option<(u64, u64)>,
570}
571
572impl ActivityAccumulator {
573 fn observe(
574 &mut self,
575 request: &NnsProposalActivityRequest,
576 proposal: &NnsProposalRow,
577 ) -> Result<(), NnsProposalActivityError> {
578 let timestamp = proposal.proposal_timestamp_seconds;
579 if request
580 .from_proposal_timestamp_seconds
581 .is_some_and(|from| timestamp < from)
582 {
583 return increment_count(
584 &mut self.excluded_before_from_count,
585 "excluded_before_from_count",
586 );
587 }
588 if request
589 .until_proposal_timestamp_seconds
590 .is_some_and(|until| timestamp >= until)
591 {
592 return increment_count(
593 &mut self.excluded_at_or_after_until_count,
594 "excluded_at_or_after_until_count",
595 );
596 }
597
598 increment_count(&mut self.included_proposal_count, "included_proposal_count")?;
599 increment_count(
600 self.topic_counts.entry(proposal.topic).or_default(),
601 "topic_count",
602 )?;
603 increment_count(
604 self.status_counts.entry(proposal.status).or_default(),
605 "status_count",
606 )?;
607 increment_count(
608 self.reward_status_counts
609 .entry(proposal.reward_status)
610 .or_default(),
611 "reward_status_count",
612 )?;
613 let day_start = timestamp - (timestamp % SECONDS_PER_DAY);
614 increment_count(self.day_counts.entry(day_start).or_default(), "day_count")?;
615 self.included_timestamp_bounds = Some(match self.included_timestamp_bounds {
616 Some((earliest, latest)) => (earliest.min(timestamp), latest.max(timestamp)),
617 None => (timestamp, timestamp),
618 });
619 Ok(())
620 }
621
622 fn into_report(
623 self,
624 request: &NnsProposalActivityRequest,
625 collection: &NnsProposalCollectionState,
626 source: NnsGovernanceSourceProvenance,
627 ) -> NnsProposalActivityReport {
628 NnsProposalActivityReport {
629 schema_version: NNS_PROPOSAL_ACTIVITY_REPORT_SCHEMA_VERSION,
630 network: collection.network().to_string(),
631 governance_canister_id: collection.governance_canister_id().to_string(),
632 source,
633 collection_started_at: collection.started_at().to_string(),
634 collection_updated_at: collection.updated_at().to_string(),
635 collection_page_count: collection.pages_fetched(),
636 collected_proposal_count: collection.proposals_fetched(),
637 point_in_time_guaranteed: false,
638 from_proposal_timestamp_seconds: request.from_proposal_timestamp_seconds,
639 until_proposal_timestamp_seconds: request.until_proposal_timestamp_seconds,
640 included_proposal_count: self.included_proposal_count,
641 excluded_before_from_count: self.excluded_before_from_count,
642 excluded_at_or_after_until_count: self.excluded_at_or_after_until_count,
643 earliest_included_proposal_timestamp_seconds: self
644 .included_timestamp_bounds
645 .map(|bounds| bounds.0),
646 latest_included_proposal_timestamp_seconds: self
647 .included_timestamp_bounds
648 .map(|bounds| bounds.1),
649 topic_counts: self
650 .topic_counts
651 .into_iter()
652 .map(|(topic, proposal_count)| NnsProposalTopicCount {
653 topic,
654
655 proposal_count,
656 })
657 .collect(),
658 status_counts: self
659 .status_counts
660 .into_iter()
661 .map(|(status, proposal_count)| NnsProposalStatusCount {
662 status,
663
664 proposal_count,
665 })
666 .collect(),
667 reward_status_counts: self
668 .reward_status_counts
669 .into_iter()
670 .map(
671 |(reward_status, proposal_count)| NnsProposalRewardStatusCount {
672 reward_status,
673
674 proposal_count,
675 },
676 )
677 .collect(),
678 day_counts: self
679 .day_counts
680 .into_iter()
681 .map(
682 |(day_start_timestamp_seconds, proposal_count)| NnsProposalDayCount {
683 day_start_timestamp_seconds,
684 proposal_count,
685 },
686 )
687 .collect(),
688 }
689 }
690}
691
692pub(super) const fn validate_time_window(
693 request: &NnsProposalActivityRequest,
694) -> Result<(), NnsProposalActivityError> {
695 if let (Some(from), Some(until)) = (
696 request.from_proposal_timestamp_seconds,
697 request.until_proposal_timestamp_seconds,
698 ) && from >= until
699 {
700 return Err(NnsProposalActivityError::InvalidTimeWindow {
701 from_proposal_timestamp_seconds: from,
702 until_proposal_timestamp_seconds: until,
703 });
704 }
705 Ok(())
706}
707
708fn validate_proposal_row(
709 proposal: &NnsProposalRow,
710 proposal_ids: &mut HashSet<u64>,
711) -> Result<(), NnsProposalActivityError> {
712 let proposal_id = proposal
713 .proposal_id
714 .ok_or(NnsProposalActivityError::MissingProposalId)?;
715 if proposal_id == 0 {
716 return Err(NnsProposalActivityError::ZeroProposalId);
717 }
718 if !proposal_ids.insert(proposal_id) {
719 return Err(NnsProposalActivityError::DuplicateProposalId { proposal_id });
720 }
721 validate_proposal_timestamp(proposal, proposal_id)
722}
723
724const fn validate_proposal_timestamp(
725 proposal: &NnsProposalRow,
726 proposal_id: u64,
727) -> Result<(), NnsProposalActivityError> {
728 if proposal.proposal_timestamp_seconds == 0 {
729 return Err(NnsProposalActivityError::ZeroProposalTimestamp { proposal_id });
730 }
731
732 Ok(())
733}
734
735fn increment_count(count: &mut u64, field: &'static str) -> Result<(), NnsProposalActivityError> {
736 *count = count
737 .checked_add(1)
738 .ok_or(NnsProposalActivityError::AccountingOverflow { field })?;
739 Ok(())
740}
741
742#[cfg(test)]
743mod tests;