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